Initial commit: NOFX AI Trading System

- Multi-AI competition mode (Qwen vs DeepSeek)
- Binance Futures integration
- AI self-learning mechanism
- Professional web dashboard
- Complete risk management system
This commit is contained in:
tinkle-community
2025-10-28 15:47:34 +08:00
parent 976d6d74f6
commit 8d3981fd3e
12369 changed files with 1739210 additions and 0 deletions

599
web/src/App.tsx Normal file
View File

@@ -0,0 +1,599 @@
import { useEffect, useState } from 'react';
import useSWR from 'swr';
import { api } from './lib/api';
import { EquityChart } from './components/EquityChart';
import { CompetitionPage } from './components/CompetitionPage';
import type {
SystemStatus,
AccountInfo,
Position,
DecisionRecord,
Statistics,
TraderInfo,
} from './types';
type Page = 'competition' | 'trader';
function App() {
const [currentPage, setCurrentPage] = useState<Page>('competition');
const [selectedTraderId, setSelectedTraderId] = useState<string | undefined>();
const [lastUpdate, setLastUpdate] = useState<string>('--:--:--');
// 获取trader列表
const { data: traders } = useSWR<TraderInfo[]>('traders', api.getTraders, {
refreshInterval: 10000,
});
// 当获取到traders后设置默认选中第一个
useEffect(() => {
if (traders && traders.length > 0 && !selectedTraderId) {
setSelectedTraderId(traders[0].trader_id);
}
}, [traders, selectedTraderId]);
// 如果在trader页面获取该trader的数据
const { data: status } = useSWR<SystemStatus>(
currentPage === 'trader' && selectedTraderId
? `status-${selectedTraderId}`
: null,
() => api.getStatus(selectedTraderId),
{
refreshInterval: 5000,
revalidateOnFocus: true,
dedupingInterval: 0,
}
);
const { data: account } = useSWR<AccountInfo>(
currentPage === 'trader' && selectedTraderId
? `account-${selectedTraderId}`
: null,
() => api.getAccount(selectedTraderId),
{
refreshInterval: 5000,
revalidateOnFocus: true,
dedupingInterval: 0,
}
);
const { data: positions } = useSWR<Position[]>(
currentPage === 'trader' && selectedTraderId
? `positions-${selectedTraderId}`
: null,
() => api.getPositions(selectedTraderId),
{
refreshInterval: 5000,
revalidateOnFocus: true,
dedupingInterval: 0,
}
);
const { data: decisions } = useSWR<DecisionRecord[]>(
currentPage === 'trader' && selectedTraderId
? `decisions/latest-${selectedTraderId}`
: null,
() => api.getLatestDecisions(selectedTraderId),
{ refreshInterval: 10000 }
);
const { data: stats } = useSWR<Statistics>(
currentPage === 'trader' && selectedTraderId
? `statistics-${selectedTraderId}`
: null,
() => api.getStatistics(selectedTraderId),
{ refreshInterval: 10000 }
);
useEffect(() => {
if (account) {
const now = new Date().toLocaleTimeString();
setLastUpdate(now);
}
}, [account]);
const selectedTrader = traders?.find((t) => t.trader_id === selectedTraderId);
return (
<div className="min-h-screen" style={{ background: '#0B0E11', color: '#EAECEF' }}>
{/* Header - Binance Style */}
<header className="glass sticky top-0 z-50 backdrop-blur-xl">
<div className="max-w-7xl mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-xl" style={{ background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)' }}>
</div>
<div>
<h1 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
AI Trading Competition
</h1>
<p className="text-xs mono" style={{ color: '#848E9C' }}>
Qwen vs DeepSeek · Real-time
</p>
</div>
</div>
<div className="flex items-center gap-3">
{/* Page Toggle */}
<div className="flex gap-1 rounded p-1" style={{ background: '#1E2329' }}>
<button
onClick={() => setCurrentPage('competition')}
className={`px-4 py-2 rounded text-sm font-semibold transition-all ${
currentPage === 'competition' ? '' : ''
}`}
style={currentPage === 'competition'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
}
>
Competition
</button>
<button
onClick={() => setCurrentPage('trader')}
className={`px-4 py-2 rounded text-sm font-semibold transition-all`}
style={currentPage === 'trader'
? { background: '#F0B90B', color: '#000' }
: { background: 'transparent', color: '#848E9C' }
}
>
Details
</button>
</div>
{/* Trader Selector (only show on trader page) */}
{currentPage === 'trader' && traders && traders.length > 0 && (
<select
value={selectedTraderId}
onChange={(e) => setSelectedTraderId(e.target.value)}
className="rounded px-3 py-2 text-sm font-medium cursor-pointer transition-colors"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
{traders.map((trader) => (
<option key={trader.trader_id} value={trader.trader_id}>
{trader.trader_name} ({trader.ai_model.toUpperCase()})
</option>
))}
</select>
)}
{/* Status Indicator (only show on trader page) */}
{currentPage === 'trader' && status && (
<div
className="flex items-center gap-2 px-3 py-2 rounded"
style={status.is_running
? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81', border: '1px solid rgba(14, 203, 129, 0.2)' }
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D', border: '1px solid rgba(246, 70, 93, 0.2)' }
}
>
<div
className={`w-2 h-2 rounded-full ${status.is_running ? 'pulse-glow' : ''}`}
style={{ background: status.is_running ? '#0ECB81' : '#F6465D' }}
/>
<span className="font-semibold mono text-xs">
{status.is_running ? 'RUNNING' : 'STOPPED'}
</span>
</div>
)}
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 py-6">
{currentPage === 'competition' ? (
<CompetitionPage />
) : (
<TraderDetailsPage
selectedTrader={selectedTrader}
status={status}
account={account}
positions={positions}
decisions={decisions}
stats={stats}
lastUpdate={lastUpdate}
/>
)}
</main>
{/* Footer */}
<footer className="mt-16" style={{ borderTop: '1px solid #2B3139', background: '#181A20' }}>
<div className="max-w-7xl mx-auto px-4 py-6 text-center text-sm" style={{ color: '#5E6673' }}>
<p>NOFX - AI Trading Competition System</p>
<p className="mt-1"> Trading involves risk. Use at your own discretion.</p>
</div>
</footer>
</div>
);
}
// Trader Details Page Component
function TraderDetailsPage({
selectedTrader,
status,
account,
positions,
decisions,
stats,
lastUpdate,
}: {
selectedTrader?: TraderInfo;
status?: SystemStatus;
account?: AccountInfo;
positions?: Position[];
decisions?: DecisionRecord[];
stats?: Statistics;
lastUpdate: string;
}) {
if (!selectedTrader) {
return (
<div className="space-y-6">
{/* Loading Skeleton - Binance Style */}
<div className="binance-card p-6 animate-pulse">
<div className="skeleton h-8 w-48 mb-3"></div>
<div className="flex gap-4">
<div className="skeleton h-4 w-32"></div>
<div className="skeleton h-4 w-24"></div>
<div className="skeleton h-4 w-28"></div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="binance-card p-5 animate-pulse">
<div className="skeleton h-4 w-24 mb-3"></div>
<div className="skeleton h-8 w-32"></div>
</div>
))}
</div>
<div className="binance-card p-6 animate-pulse">
<div className="skeleton h-6 w-40 mb-4"></div>
<div className="skeleton h-64 w-full"></div>
</div>
</div>
);
}
return (
<div>
{/* Trader Header */}
<div className="mb-6 rounded p-6 animate-scale-in" style={{ background: 'linear-gradient(135deg, rgba(240, 185, 11, 0.15) 0%, rgba(252, 213, 53, 0.05) 100%)', border: '1px solid rgba(240, 185, 11, 0.2)', boxShadow: '0 0 30px rgba(240, 185, 11, 0.15)' }}>
<h2 className="text-2xl font-bold mb-3 flex items-center gap-2" style={{ color: '#EAECEF' }}>
<span className="w-10 h-10 rounded-full flex items-center justify-center text-xl" style={{ background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)' }}>
🤖
</span>
{selectedTrader.trader_name}
</h2>
<div className="flex items-center gap-4 text-sm" style={{ color: '#848E9C' }}>
<span>AI Model: <span className="font-semibold" style={{ color: selectedTrader.ai_model === 'qwen' ? '#c084fc' : '#60a5fa' }}>{selectedTrader.ai_model.toUpperCase()}</span></span>
{status && (
<>
<span></span>
<span>Cycles: {status.call_count}</span>
<span></span>
<span>Runtime: {status.runtime_minutes} min</span>
</>
)}
</div>
</div>
{/* Debug Info */}
{account && (
<div className="mb-4 p-3 rounded text-xs font-mono" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
<div style={{ color: '#848E9C' }}>
🔄 Last Update: {lastUpdate} | Total Equity: {account.total_equity.toFixed(2)} |
Available: {account.available_balance.toFixed(2)} | P&L: {account.total_pnl.toFixed(2)}{' '}
({account.total_pnl_pct.toFixed(2)}%)
</div>
</div>
)}
{/* Account Overview */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<StatCard
title="Total Equity"
value={`${account?.total_equity.toFixed(2) || '0.00'} USDT`}
change={account?.total_pnl_pct || 0}
positive={account ? account.total_pnl > 0 : false}
/>
<StatCard
title="Available Balance"
value={`${account?.available_balance.toFixed(2) || '0.00'} USDT`}
subtitle={`${((account?.available_balance / account?.total_equity) * 100 || 0).toFixed(1)}% Free`}
/>
<StatCard
title="Total P&L"
value={`${account?.total_pnl >= 0 ? '+' : ''}${account?.total_pnl.toFixed(2) || '0.00'} USDT`}
change={account?.total_pnl_pct || 0}
positive={account ? account.total_pnl >= 0 : false}
/>
<StatCard
title="Positions"
value={`${account?.position_count || 0}`}
subtitle={`Margin: ${account?.margin_used_pct.toFixed(1) || '0.0'}%`}
/>
</div>
{/* Equity Chart */}
<div className="mb-8 animate-slide-in" style={{ animationDelay: '0.1s' }}>
<EquityChart traderId={selectedTrader.trader_id} />
</div>
{/* Statistics */}
{stats && (
<div className="binance-card p-6 mb-6 animate-slide-in" style={{ animationDelay: '0.2s' }}>
<h2 className="text-xl font-bold mb-5 flex items-center gap-2" style={{ color: '#EAECEF' }}>
📊 Statistics
</h2>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div>
<div className="text-xs" style={{ color: '#848E9C' }}>Total Cycles</div>
<div className="text-2xl font-bold" style={{ color: '#EAECEF' }}>{stats.total_cycles}</div>
</div>
<div>
<div className="text-xs" style={{ color: '#848E9C' }}>Successful</div>
<div className="text-2xl font-bold" style={{ color: '#0ECB81' }}>
{stats.successful_cycles}
</div>
</div>
<div>
<div className="text-xs" style={{ color: '#848E9C' }}>Failed</div>
<div className="text-2xl font-bold" style={{ color: '#F6465D' }}>{stats.failed_cycles}</div>
</div>
<div>
<div className="text-xs" style={{ color: '#848E9C' }}>Open Positions</div>
<div className="text-2xl font-bold" style={{ color: '#EAECEF' }}>{stats.total_open_positions}</div>
</div>
<div>
<div className="text-xs" style={{ color: '#848E9C' }}>Close Positions</div>
<div className="text-2xl font-bold" style={{ color: '#EAECEF' }}>{stats.total_close_positions}</div>
</div>
</div>
</div>
)}
{/* Positions */}
<div className="binance-card p-6 mb-6 animate-slide-in" style={{ animationDelay: '0.3s' }}>
<div className="flex items-center justify-between mb-5">
<h2 className="text-xl font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
📈 Current Positions
</h2>
{positions && positions.length > 0 && (
<div className="text-xs px-3 py-1 rounded" style={{ background: 'rgba(240, 185, 11, 0.1)', color: '#F0B90B', border: '1px solid rgba(240, 185, 11, 0.2)' }}>
{positions.length} Active
</div>
)}
</div>
{positions && positions.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left border-b border-gray-800">
<tr>
<th className="pb-3 font-semibold text-gray-400">Symbol</th>
<th className="pb-3 font-semibold text-gray-400">Side</th>
<th className="pb-3 font-semibold text-gray-400">Entry Price</th>
<th className="pb-3 font-semibold text-gray-400">Mark Price</th>
<th className="pb-3 font-semibold text-gray-400">Quantity</th>
<th className="pb-3 font-semibold text-gray-400">Position Value</th>
<th className="pb-3 font-semibold text-gray-400">Leverage</th>
<th className="pb-3 font-semibold text-gray-400">Unrealized P&L</th>
<th className="pb-3 font-semibold text-gray-400">Liq. Price</th>
</tr>
</thead>
<tbody>
{positions.map((pos, i) => (
<tr key={i} className="border-b border-gray-800 last:border-0">
<td className="py-3 font-mono font-semibold">{pos.symbol}</td>
<td className="py-3">
<span
className="px-2 py-1 rounded text-xs font-bold"
style={pos.side === 'long'
? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81' }
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }
}
>
{pos.side.toUpperCase()}
</span>
</td>
<td className="py-3 font-mono" style={{ color: '#EAECEF' }}>{pos.entry_price.toFixed(4)}</td>
<td className="py-3 font-mono" style={{ color: '#EAECEF' }}>{pos.mark_price.toFixed(4)}</td>
<td className="py-3 font-mono" style={{ color: '#EAECEF' }}>{pos.quantity.toFixed(4)}</td>
<td className="py-3 font-mono font-bold" style={{ color: '#EAECEF' }}>
{(pos.quantity * pos.mark_price).toFixed(2)} USDT
</td>
<td className="py-3 font-mono" style={{ color: '#F0B90B' }}>{pos.leverage}x</td>
<td className="py-3 font-mono">
<span
style={{ color: pos.unrealized_pnl >= 0 ? '#0ECB81' : '#F6465D', fontWeight: 'bold' }}
>
{pos.unrealized_pnl >= 0 ? '+' : ''}
{pos.unrealized_pnl.toFixed(2)} ({pos.unrealized_pnl_pct.toFixed(2)}%)
</span>
</td>
<td className="py-3 font-mono" style={{ color: '#848E9C' }}>
{pos.liquidation_price.toFixed(4)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-16" style={{ color: '#848E9C' }}>
<div className="text-6xl mb-4 opacity-50">📊</div>
<div className="text-lg font-semibold mb-2"></div>
<div className="text-sm"></div>
</div>
)}
</div>
{/* Recent Decisions */}
<div className="binance-card p-6 animate-slide-in" style={{ animationDelay: '0.4s' }}>
<div className="flex items-center justify-between mb-5">
<h2 className="text-xl font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
🧠 Recent Decisions
</h2>
{decisions && decisions.length > 0 && (
<div className="text-xs px-3 py-1 rounded" style={{ background: 'rgba(240, 185, 11, 0.1)', color: '#F0B90B', border: '1px solid rgba(240, 185, 11, 0.2)' }}>
Last {decisions.length} Cycles
</div>
)}
</div>
{decisions && decisions.length > 0 ? (
<div className="space-y-4">
{decisions.map((decision, i) => (
<DecisionCard key={i} decision={decision} />
))}
</div>
) : (
<div className="text-center py-16" style={{ color: '#848E9C' }}>
<div className="text-6xl mb-4 opacity-50">🧠</div>
<div className="text-lg font-semibold mb-2"></div>
<div className="text-sm">AI交易决策将在这里显示</div>
</div>
)}
</div>
</div>
);
}
// Stat Card Component - Binance Style Enhanced
function StatCard({
title,
value,
change,
positive,
subtitle,
}: {
title: string;
value: string;
change?: number;
positive?: boolean;
subtitle?: string;
}) {
return (
<div className="stat-card animate-fade-in">
<div className="text-xs mb-2 mono uppercase tracking-wider" style={{ color: '#848E9C' }}>{title}</div>
<div className="text-2xl font-bold mb-1 mono" style={{ color: '#EAECEF' }}>{value}</div>
{change !== undefined && (
<div className="flex items-center gap-1">
<div
className="text-sm mono font-bold"
style={{ color: positive ? '#0ECB81' : '#F6465D' }}
>
{positive ? '▲' : '▼'} {positive ? '+' : ''}
{change.toFixed(2)}%
</div>
</div>
)}
{subtitle && <div className="text-xs mt-2 mono" style={{ color: '#848E9C' }}>{subtitle}</div>}
</div>
);
}
// Decision Card Component with CoT Trace - Binance Style
function DecisionCard({ decision }: { decision: DecisionRecord }) {
const [showCoT, setShowCoT] = useState(false);
return (
<div className="rounded p-5 transition-all duration-300 hover:translate-y-[-2px]" style={{ border: '1px solid #2B3139', background: '#1E2329', boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)' }}>
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div>
<div className="font-semibold" style={{ color: '#EAECEF' }}>Cycle #{decision.cycle_number}</div>
<div className="text-xs" style={{ color: '#848E9C' }}>
{new Date(decision.timestamp).toLocaleString()}
</div>
</div>
<div
className="px-3 py-1 rounded text-xs font-bold"
style={decision.success
? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81' }
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }
}
>
{decision.success ? 'Success' : 'Failed'}
</div>
</div>
{/* AI Chain of Thought - Collapsible */}
{decision.cot_trace && (
<div className="mb-3">
<button
onClick={() => setShowCoT(!showCoT)}
className="flex items-center gap-2 text-sm transition-colors"
style={{ color: '#F0B90B' }}
>
<span className="font-semibold">💭 AI思维链分析</span>
<span className="text-xs">{showCoT ? '▼ 收起' : '▶ 展开'}</span>
</button>
{showCoT && (
<div className="mt-2 rounded p-4 text-sm font-mono whitespace-pre-wrap max-h-96 overflow-y-auto" style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}>
{decision.cot_trace}
</div>
)}
</div>
)}
{/* Decisions Actions */}
{decision.decisions && decision.decisions.length > 0 && (
<div className="space-y-2 mb-3">
{decision.decisions.map((action, j) => (
<div key={j} className="flex items-center gap-2 text-sm rounded px-3 py-2" style={{ background: '#0B0E11' }}>
<span className="font-mono font-bold" style={{ color: '#EAECEF' }}>{action.symbol}</span>
<span
className="px-2 py-0.5 rounded text-xs font-bold"
style={action.action.includes('open')
? { background: 'rgba(96, 165, 250, 0.1)', color: '#60a5fa' }
: { background: 'rgba(240, 185, 11, 0.1)', color: '#F0B90B' }
}
>
{action.action}
</span>
{action.leverage > 0 && <span style={{ color: '#F0B90B' }}>{action.leverage}x</span>}
{action.price > 0 && (
<span className="font-mono text-xs" style={{ color: '#848E9C' }}>@{action.price.toFixed(4)}</span>
)}
<span style={{ color: action.success ? '#0ECB81' : '#F6465D' }}>
{action.success ? '✓' : '✗'}
</span>
{action.error && <span className="text-xs ml-2" style={{ color: '#F6465D' }}>{action.error}</span>}
</div>
))}
</div>
)}
{/* Account State Summary */}
{decision.account_state && (
<div className="flex gap-4 text-xs mb-3 rounded px-3 py-2" style={{ background: '#0B0E11', color: '#848E9C' }}>
<span>: {decision.account_state.total_balance.toFixed(2)} USDT</span>
<span>: {decision.account_state.available_balance.toFixed(2)} USDT</span>
<span>: {decision.account_state.margin_used_pct.toFixed(1)}%</span>
<span>: {decision.account_state.position_count}</span>
</div>
)}
{/* Execution Logs */}
{decision.execution_log && decision.execution_log.length > 0 && (
<div className="space-y-1">
{decision.execution_log.map((log, k) => (
<div
key={k}
className="text-xs font-mono"
style={{ color: log.includes('✓') || log.includes('成功') ? '#0ECB81' : '#F6465D' }}
>
{log}
</div>
))}
</div>
)}
{/* Error Message */}
{decision.error_message && (
<div className="text-sm rounded px-3 py-2 mt-3" style={{ color: '#F6465D', background: 'rgba(246, 70, 93, 0.1)' }}>
{decision.error_message}
</div>
)}
</div>
);
}
export default App;

View File

@@ -0,0 +1,298 @@
import { useState, useEffect } from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
Legend,
} from 'recharts';
import useSWR from 'swr';
import { api } from '../lib/api';
import type { CompetitionTraderData } from '../types';
interface ComparisonChartProps {
traders: CompetitionTraderData[];
}
export function ComparisonChart({ traders }: ComparisonChartProps) {
const [combinedData, setCombinedData] = useState<any[]>([]);
// 获取所有trader的历史数据
const traderHistories = traders.map((trader) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
return useSWR(`equity-history-${trader.trader_id}`, () =>
api.getEquityHistory(trader.trader_id),
{ refreshInterval: 10000 }
);
});
useEffect(() => {
// 等待所有数据加载完成
const allLoaded = traderHistories.every((h) => h.data);
if (!allLoaded) return;
// 合并所有trader的数据 - 使用cycle_number作为key确保数据对齐
const cycleMap = new Map<number, any>();
traderHistories.forEach((history, index) => {
const trader = traders[index];
history.data?.forEach((point: any) => {
const cycleNumber = point.cycle_number || 0;
const time = new Date(point.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
if (!cycleMap.has(cycleNumber)) {
cycleMap.set(cycleNumber, {
cycle: cycleNumber,
time,
timestamp: point.timestamp
});
}
const entry = cycleMap.get(cycleNumber);
entry[`${trader.trader_id}_pnl_pct`] = point.total_pnl_pct;
entry[`${trader.trader_id}_equity`] = point.total_equity;
});
});
// 转换为数组并按cycle排序
const combined = Array.from(cycleMap.values())
.filter(item => {
// 只保留所有trader都有数据的点
return traders.every(t => item[`${t.trader_id}_pnl_pct`] !== undefined);
})
.sort((a, b) => a.cycle - b.cycle);
setCombinedData(combined);
}, [traderHistories.map((h) => h.data).join(',')]);
const isLoading = traderHistories.some((h) => !h.data);
if (isLoading) {
return (
<div className="text-center py-16" style={{ color: '#848E9C' }}>
<div className="spinner mx-auto mb-4"></div>
<div className="text-sm font-semibold">Loading comparison data...</div>
</div>
);
}
if (combinedData.length === 0) {
return (
<div className="text-center py-16" style={{ color: '#848E9C' }}>
<div className="text-6xl mb-4 opacity-50">📊</div>
<div className="text-lg font-semibold mb-2"></div>
<div className="text-sm">线</div>
</div>
);
}
// 限制显示数据点
const MAX_DISPLAY_POINTS = 2000;
const displayData =
combinedData.length > MAX_DISPLAY_POINTS
? combinedData.slice(-MAX_DISPLAY_POINTS)
: combinedData;
// 计算Y轴范围
const calculateYDomain = () => {
const allValues: number[] = [];
displayData.forEach((point) => {
traders.forEach((trader) => {
const value = point[`${trader.trader_id}_pnl_pct`];
if (value !== undefined) {
allValues.push(value);
}
});
});
if (allValues.length === 0) return [-5, 5];
const minVal = Math.min(...allValues);
const maxVal = Math.max(...allValues);
const range = Math.max(Math.abs(maxVal), Math.abs(minVal));
const padding = Math.max(range * 0.2, 1); // 至少留1%余量
return [
Math.floor(minVal - padding),
Math.ceil(maxVal + padding)
];
};
// Trader颜色配置 - 使用更鲜艳对比度更高的颜色
const getTraderColor = (traderId: string) => {
const trader = traders.find((t) => t.trader_id === traderId);
if (trader?.ai_model === 'qwen') {
return '#c084fc'; // purple-400 (更亮)
} else {
return '#60a5fa'; // blue-400 (更亮)
}
};
// 自定义Tooltip - Binance Style
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="rounded p-3 shadow-xl" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
<div className="text-xs mb-2" style={{ color: '#848E9C' }}>
Cycle #{data.cycle} - {data.time}
</div>
{traders.map((trader) => {
const pnlPct = data[`${trader.trader_id}_pnl_pct`];
const equity = data[`${trader.trader_id}_equity`];
if (pnlPct === undefined) return null;
return (
<div key={trader.trader_id} className="mb-1.5 last:mb-0">
<div
className="text-xs font-semibold mb-0.5"
style={{ color: getTraderColor(trader.trader_id) }}
>
{trader.trader_name}
</div>
<div className="text-sm mono font-bold" style={{ color: pnlPct >= 0 ? '#0ECB81' : '#F6465D' }}>
{pnlPct >= 0 ? '+' : ''}{pnlPct.toFixed(2)}%
<span className="text-xs ml-2 font-normal" style={{ color: '#848E9C' }}>
({equity?.toFixed(2)} USDT)
</span>
</div>
</div>
);
})}
</div>
);
}
return null;
};
// 计算当前差距
const currentGap = displayData.length > 0 ? (() => {
const lastPoint = displayData[displayData.length - 1];
const values = traders.map(t => lastPoint[`${t.trader_id}_pnl_pct`] || 0);
return Math.abs(values[0] - values[1]);
})() : 0;
return (
<div>
<div style={{ borderRadius: '8px', overflow: 'hidden' }}>
<ResponsiveContainer width="100%" height={520}>
<LineChart data={displayData} margin={{ top: 20, right: 30, left: 20, bottom: 40 }}>
<defs>
{traders.map((trader) => (
<linearGradient
key={`gradient-${trader.trader_id}`}
id={`gradient-${trader.trader_id}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="5%" stopColor={getTraderColor(trader.trader_id)} stopOpacity={0.9} />
<stop offset="95%" stopColor={getTraderColor(trader.trader_id)} stopOpacity={0.2} />
</linearGradient>
))}
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#2B3139" />
<XAxis
dataKey="time"
stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 11 }}
tickLine={{ stroke: '#2B3139' }}
interval={Math.floor(displayData.length / 12)}
angle={-15}
textAnchor="end"
height={60}
/>
<YAxis
stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 12 }}
tickLine={{ stroke: '#2B3139' }}
domain={calculateYDomain()}
tickFormatter={(value) => `${value.toFixed(1)}%`}
width={60}
/>
<Tooltip content={<CustomTooltip />} />
<ReferenceLine
y={0}
stroke="#474D57"
strokeDasharray="5 5"
strokeWidth={1.5}
label={{
value: 'Break Even',
fill: '#848E9C',
fontSize: 11,
position: 'right',
}}
/>
{traders.map((trader, index) => (
<Line
key={trader.trader_id}
type="monotone"
dataKey={`${trader.trader_id}_pnl_pct`}
stroke={getTraderColor(trader.trader_id)}
strokeWidth={3}
dot={displayData.length < 50 ? { fill: getTraderColor(trader.trader_id), r: 3 } : false}
activeDot={{ r: 6, fill: getTraderColor(trader.trader_id), stroke: '#fff', strokeWidth: 2 }}
name={trader.trader_name}
connectNulls
/>
))}
<Legend
wrapperStyle={{ paddingTop: '20px' }}
iconType="line"
formatter={(value, entry: any) => {
const traderId = traders.find((t) => value === t.trader_name)?.trader_id;
const trader = traders.find((t) => t.trader_id === traderId);
return (
<span style={{ color: entry.color, fontWeight: 600, fontSize: '14px' }}>
{trader?.trader_name} ({trader?.ai_model.toUpperCase()})
</span>
);
}}
/>
</LineChart>
</ResponsiveContainer>
</div>
{/* Stats */}
<div className="mt-6 grid grid-cols-4 gap-4 pt-5" style={{ borderTop: '1px solid #2B3139' }}>
<div className="p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}></div>
<div className="text-base font-bold" style={{ color: '#EAECEF' }}>PnL %</div>
</div>
<div className="p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}></div>
<div className="text-base font-bold mono" style={{ color: '#EAECEF' }}>{combinedData.length} </div>
</div>
<div className="p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}></div>
<div className="text-base font-bold mono" style={{ color: currentGap > 1 ? '#F0B90B' : '#EAECEF' }}>
{currentGap.toFixed(2)}%
</div>
</div>
<div className="p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}></div>
<div className="text-base font-bold mono" style={{ color: '#EAECEF' }}>
{combinedData.length > MAX_DISPLAY_POINTS
? `最近 ${MAX_DISPLAY_POINTS}`
: '全部数据'}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,244 @@
import useSWR from 'swr';
import { api } from '../lib/api';
import type { CompetitionData } from '../types';
import { ComparisonChart } from './ComparisonChart';
export function CompetitionPage() {
const { data: competition } = useSWR<CompetitionData>(
'competition',
api.getCompetition,
{
refreshInterval: 5000,
revalidateOnFocus: true,
}
);
if (!competition || !competition.traders) {
return (
<div className="space-y-6">
<div className="binance-card p-8 animate-pulse">
<div className="flex items-center justify-between mb-6">
<div className="space-y-3 flex-1">
<div className="skeleton h-8 w-64"></div>
<div className="skeleton h-4 w-48"></div>
</div>
<div className="skeleton h-12 w-32"></div>
</div>
</div>
<div className="binance-card p-6">
<div className="skeleton h-6 w-40 mb-4"></div>
<div className="space-y-3">
<div className="skeleton h-20 w-full rounded"></div>
<div className="skeleton h-20 w-full rounded"></div>
</div>
</div>
</div>
);
}
// 按收益率排序
const sortedTraders = [...competition.traders].sort(
(a, b) => b.total_pnl_pct - a.total_pnl_pct
);
// 找出领先者
const leader = sortedTraders[0];
return (
<div className="space-y-6 animate-fade-in">
{/* Competition Header - Binance Style */}
<div className="rounded p-6 animate-scale-in" style={{ background: 'linear-gradient(135deg, rgba(240, 185, 11, 0.15) 0%, rgba(252, 213, 53, 0.05) 100%)', border: '1px solid rgba(240, 185, 11, 0.2)', boxShadow: '0 0 30px rgba(240, 185, 11, 0.15)' }}>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold mb-1 flex items-center gap-3" style={{ color: '#EAECEF' }}>
<span>🏆</span>
AI Trading Competition
<span className="text-sm font-normal px-2 py-1 rounded" style={{ background: 'rgba(240, 185, 11, 0.15)', color: '#F0B90B' }}>
{competition.count} Traders
</span>
</h1>
<p className="text-sm" style={{ color: '#848E9C' }}>
Qwen vs DeepSeek · Real-time Performance Battle
</p>
</div>
<div className="text-right">
<div className="text-xs" style={{ color: '#848E9C' }}>Current Leader</div>
<div className="text-lg font-bold" style={{ color: '#F0B90B' }}>{leader?.trader_name}</div>
</div>
</div>
</div>
{/* Leader Board - Binance Style */}
<div className="binance-card p-6 animate-slide-in" style={{ animationDelay: '0.1s' }}>
<div className="flex items-center justify-between mb-5">
<h2 className="text-xl font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
🥇 Leaderboard
</h2>
<div className="text-xs px-3 py-1 rounded" style={{ background: 'rgba(240, 185, 11, 0.1)', color: '#F0B90B', border: '1px solid rgba(240, 185, 11, 0.2)' }}>
LIVE
</div>
</div>
<div className="space-y-3">
{sortedTraders.map((trader, index) => {
const isLeader = index === 0;
const aiModelColor = trader.ai_model === 'qwen' ? '#c084fc' : '#60a5fa';
return (
<div
key={trader.trader_id}
className="rounded p-4 transition-all duration-300 hover:translate-y-[-2px]"
style={{
background: isLeader ? 'linear-gradient(135deg, rgba(240, 185, 11, 0.08) 0%, #0B0E11 100%)' : '#0B0E11',
border: `1px solid ${isLeader ? 'rgba(240, 185, 11, 0.4)' : '#2B3139'}`,
boxShadow: isLeader ? '0 4px 20px rgba(240, 185, 11, 0.15), 0 0 0 1px rgba(240, 185, 11, 0.2)' : '0 2px 8px rgba(0, 0, 0, 0.3)'
}}
>
<div className="flex items-center justify-between">
{/* Rank & Name */}
<div className="flex items-center gap-4">
<div className="text-3xl w-8">
{index === 0 ? '🥇' : index === 1 ? '🥈' : '🥉'}
</div>
<div>
<div className="font-bold text-base" style={{ color: '#EAECEF' }}>{trader.trader_name}</div>
<div className="text-xs mono font-semibold" style={{ color: aiModelColor }}>
{trader.ai_model.toUpperCase()} Model
</div>
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-6">
{/* Total Equity */}
<div className="text-right">
<div className="text-xs" style={{ color: '#848E9C' }}>Total Equity</div>
<div className="text-base font-bold mono" style={{ color: '#EAECEF' }}>
{trader.total_equity.toFixed(2)} USDT
</div>
</div>
{/* P&L */}
<div className="text-right min-w-[120px]">
<div className="text-xs" style={{ color: '#848E9C' }}>Total P&L</div>
<div
className="text-2xl font-bold mono"
style={{ color: trader.total_pnl >= 0 ? '#0ECB81' : '#F6465D' }}
>
{trader.total_pnl >= 0 ? '+' : ''}
{trader.total_pnl_pct.toFixed(2)}%
</div>
<div className="text-xs mono" style={{ color: '#848E9C' }}>
{trader.total_pnl >= 0 ? '+' : ''}
{trader.total_pnl.toFixed(2)} USDT
</div>
</div>
{/* Positions */}
<div className="text-right">
<div className="text-xs" style={{ color: '#848E9C' }}>Positions</div>
<div className="text-base font-bold mono" style={{ color: '#EAECEF' }}>
{trader.position_count}
</div>
<div className="text-xs" style={{ color: '#848E9C' }}>
Margin: {trader.margin_used_pct.toFixed(1)}%
</div>
</div>
{/* Cycles */}
<div className="text-right">
<div className="text-xs" style={{ color: '#848E9C' }}>Cycles</div>
<div className="text-base font-bold mono" style={{ color: '#EAECEF' }}>{trader.call_count}</div>
</div>
{/* Status */}
<div>
<div
className="px-3 py-1 rounded text-xs font-bold"
style={trader.is_running
? { background: 'rgba(14, 203, 129, 0.1)', color: '#0ECB81' }
: { background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }
}
>
{trader.is_running ? 'RUNNING' : 'STOPPED'}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
{/* Performance Comparison Chart */}
<div className="binance-card p-6 animate-slide-in" style={{ animationDelay: '0.2s' }}>
<div className="flex items-center justify-between mb-5">
<h2 className="text-xl font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
📈 Performance Comparison
</h2>
<div className="text-xs" style={{ color: '#848E9C' }}>
Real-time PnL % Chart
</div>
</div>
<ComparisonChart traders={sortedTraders} />
</div>
{/* Head-to-Head Stats */}
{competition.traders.length === 2 && (
<div className="binance-card p-6 animate-slide-in" style={{ animationDelay: '0.3s' }}>
<h2 className="text-xl font-bold mb-5 flex items-center gap-2" style={{ color: '#EAECEF' }}>
Head-to-Head Battle
</h2>
<div className="grid grid-cols-2 gap-6">
{sortedTraders.map((trader, index) => {
const isWinning = index === 0;
const opponent = sortedTraders[1 - index];
const gap = trader.total_pnl_pct - opponent.total_pnl_pct;
return (
<div
key={trader.trader_id}
className="p-6 rounded transition-all duration-300 hover:scale-105"
style={isWinning
? {
background: 'linear-gradient(135deg, rgba(14, 203, 129, 0.08) 0%, rgba(14, 203, 129, 0.02) 100%)',
border: '2px solid rgba(14, 203, 129, 0.3)',
boxShadow: '0 4px 20px rgba(14, 203, 129, 0.15)'
}
: {
background: '#0B0E11',
border: '1px solid #2B3139',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.3)'
}
}
>
<div className="text-center">
<div
className="text-lg font-bold mb-2"
style={{ color: trader.ai_model === 'qwen' ? '#c084fc' : '#60a5fa' }}
>
{trader.trader_name}
</div>
<div className="text-3xl font-bold mono mb-2" style={{ color: trader.total_pnl >= 0 ? '#0ECB81' : '#F6465D' }}>
{trader.total_pnl >= 0 ? '+' : ''}{trader.total_pnl_pct.toFixed(2)}%
</div>
{isWinning && gap > 0 && (
<div className="text-sm font-semibold" style={{ color: '#0ECB81' }}>
Leading by {gap.toFixed(2)}%
</div>
)}
{!isWinning && gap < 0 && (
<div className="text-sm font-semibold" style={{ color: '#F6465D' }}>
Behind by {Math.abs(gap).toFixed(2)}%
</div>
)}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,289 @@
import { useState } from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from 'recharts';
import useSWR from 'swr';
import { api } from '../lib/api';
interface EquityPoint {
timestamp: string;
total_equity: number;
pnl: number;
pnl_pct: number;
cycle_number: number;
}
interface EquityChartProps {
traderId?: string;
}
export function EquityChart({ traderId }: EquityChartProps) {
const [displayMode, setDisplayMode] = useState<'dollar' | 'percent'>('dollar');
const { data: history, error } = useSWR<EquityPoint[]>(
traderId ? `equity-history-${traderId}` : 'equity-history',
() => api.getEquityHistory(traderId),
{
refreshInterval: 10000, // 每10秒刷新
}
);
const { data: account } = useSWR(
traderId ? `account-${traderId}` : 'account',
() => api.getAccount(traderId),
{
refreshInterval: 5000,
}
);
if (error) {
return (
<div className="binance-card p-6">
<div className="flex items-center gap-3 p-4 rounded" style={{ background: 'rgba(246, 70, 93, 0.1)', border: '1px solid rgba(246, 70, 93, 0.2)' }}>
<div className="text-2xl"></div>
<div>
<div className="font-semibold" style={{ color: '#F6465D' }}></div>
<div className="text-sm" style={{ color: '#848E9C' }}>{error.message}</div>
</div>
</div>
</div>
);
}
if (!history || history.length === 0) {
return (
<div className="binance-card p-6">
<h3 className="text-lg font-semibold mb-6" style={{ color: '#EAECEF' }}>线</h3>
<div className="text-center py-16" style={{ color: '#848E9C' }}>
<div className="text-6xl mb-4 opacity-50">📊</div>
<div className="text-lg font-semibold mb-2"></div>
<div className="text-sm">线</div>
</div>
</div>
);
}
// 限制显示最近的数据点(性能优化)
// 如果数据超过2000个点只显示最近2000个
const MAX_DISPLAY_POINTS = 2000;
const displayHistory = history.length > MAX_DISPLAY_POINTS
? history.slice(-MAX_DISPLAY_POINTS)
: history;
// 计算初始余额(使用第一个数据点)
const initialBalance = history[0]?.total_equity || 1000;
// 转换数据格式
const chartData = displayHistory.map((point) => {
const pnl = point.total_equity - initialBalance;
const pnlPct = ((pnl / initialBalance) * 100).toFixed(2);
return {
time: new Date(point.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
}),
value: displayMode === 'dollar' ? point.total_equity : parseFloat(pnlPct),
cycle: point.cycle_number,
raw_equity: point.total_equity,
raw_pnl: pnl,
raw_pnl_pct: parseFloat(pnlPct),
};
});
const currentValue = chartData[chartData.length - 1];
const isProfit = currentValue.raw_pnl >= 0;
// 计算Y轴范围
const calculateYDomain = () => {
if (displayMode === 'percent') {
// 百分比模式找到最大最小值留20%余量
const values = chartData.map(d => d.value);
const minVal = Math.min(...values);
const maxVal = Math.max(...values);
const range = Math.max(Math.abs(maxVal), Math.abs(minVal));
const padding = Math.max(range * 0.2, 1); // 至少留1%余量
return [Math.floor(minVal - padding), Math.ceil(maxVal + padding)];
} else {
// 美元模式以初始余额为基准上下留10%余量
const values = chartData.map(d => d.value);
const minVal = Math.min(...values, initialBalance);
const maxVal = Math.max(...values, initialBalance);
const range = maxVal - minVal;
const padding = Math.max(range * 0.15, initialBalance * 0.01); // 至少留1%余量
return [
Math.floor(minVal - padding),
Math.ceil(maxVal + padding)
];
}
};
// 自定义Tooltip - Binance Style
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="rounded p-3 shadow-xl" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
<div className="text-xs mb-1" style={{ color: '#848E9C' }}>Cycle #{data.cycle}</div>
<div className="font-bold mono" style={{ color: '#EAECEF' }}>
{data.raw_equity.toFixed(2)} USDT
</div>
<div
className="text-sm mono font-bold"
style={{ color: data.raw_pnl >= 0 ? '#0ECB81' : '#F6465D' }}
>
{data.raw_pnl >= 0 ? '+' : ''}
{data.raw_pnl.toFixed(2)} USDT ({data.raw_pnl_pct >= 0 ? '+' : ''}
{data.raw_pnl_pct}%)
</div>
</div>
);
}
return null;
};
return (
<div className="binance-card p-6 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-bold mb-2" style={{ color: '#EAECEF' }}>线</h3>
<div className="flex items-baseline gap-4">
<span className="text-3xl font-bold mono" style={{ color: '#EAECEF' }}>
{account?.total_equity.toFixed(2) || '0.00'}
<span className="text-lg ml-1" style={{ color: '#848E9C' }}>USDT</span>
</span>
<div className="flex items-center gap-2">
<span
className="text-lg font-bold mono px-3 py-1 rounded"
style={{
color: isProfit ? '#0ECB81' : '#F6465D',
background: isProfit ? 'rgba(14, 203, 129, 0.1)' : 'rgba(246, 70, 93, 0.1)',
border: `1px solid ${isProfit ? 'rgba(14, 203, 129, 0.2)' : 'rgba(246, 70, 93, 0.2)'}`
}}
>
{isProfit ? '▲' : '▼'} {isProfit ? '+' : ''}
{currentValue.raw_pnl_pct}%
</span>
<span className="text-sm mono" style={{ color: '#848E9C' }}>
({isProfit ? '+' : ''}{currentValue.raw_pnl.toFixed(2)} USDT)
</span>
</div>
</div>
</div>
{/* Display Mode Toggle */}
<div className="flex gap-1 rounded p-1" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
<button
onClick={() => setDisplayMode('dollar')}
className="px-4 py-2 rounded text-sm font-bold transition-all"
style={displayMode === 'dollar'
? { background: '#F0B90B', color: '#000', boxShadow: '0 2px 8px rgba(240, 185, 11, 0.4)' }
: { background: 'transparent', color: '#848E9C' }
}
>
💵 USDT
</button>
<button
onClick={() => setDisplayMode('percent')}
className="px-4 py-2 rounded text-sm font-bold transition-all"
style={displayMode === 'percent'
? { background: '#F0B90B', color: '#000', boxShadow: '0 2px 8px rgba(240, 185, 11, 0.4)' }
: { background: 'transparent', color: '#848E9C' }
}
>
📊 %
</button>
</div>
</div>
{/* Chart */}
<div className="my-2" style={{ borderRadius: '8px', overflow: 'hidden' }}>
<ResponsiveContainer width="100%" height={420}>
<LineChart data={chartData} margin={{ top: 20, right: 30, left: 10, bottom: 40 }}>
<defs>
<linearGradient id="colorGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#F0B90B" stopOpacity={0.8} />
<stop offset="95%" stopColor="#FCD535" stopOpacity={0.2} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#2B3139" />
<XAxis
dataKey="time"
stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 11 }}
tickLine={{ stroke: '#2B3139' }}
interval={Math.floor(chartData.length / 10)}
angle={-15}
textAnchor="end"
height={60}
/>
<YAxis
stroke="#5E6673"
tick={{ fill: '#848E9C', fontSize: 12 }}
tickLine={{ stroke: '#2B3139' }}
domain={calculateYDomain()}
tickFormatter={(value) =>
displayMode === 'dollar' ? `$${value.toFixed(0)}` : `${value}%`
}
/>
<Tooltip content={<CustomTooltip />} />
<ReferenceLine
y={displayMode === 'dollar' ? initialBalance : 0}
stroke="#474D57"
strokeDasharray="3 3"
label={{
value: displayMode === 'dollar' ? '初始' : '0%',
fill: '#848E9C',
fontSize: 12,
}}
/>
<Line
type="monotone"
dataKey="value"
stroke="url(#colorGradient)"
strokeWidth={2.5}
dot={chartData.length > 50 ? false : { fill: '#F0B90B', r: 3 }}
activeDot={{ r: 6, fill: '#FCD535', stroke: '#F0B90B', strokeWidth: 2 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
{/* Footer Stats */}
<div className="mt-5 grid grid-cols-4 gap-4 pt-5" style={{ borderTop: '1px solid #2B3139' }}>
<div className="p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}></div>
<div className="text-base font-bold mono" style={{ color: '#EAECEF' }}>
{initialBalance.toFixed(2)} USDT
</div>
</div>
<div className="p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}></div>
<div className="text-base font-bold mono" style={{ color: '#EAECEF' }}>
{currentValue.raw_equity.toFixed(2)} USDT
</div>
</div>
<div className="p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}></div>
<div className="text-base font-bold mono" style={{ color: '#EAECEF' }}>{history.length} </div>
</div>
<div className="p-3 rounded transition-all hover:bg-opacity-50" style={{ background: 'rgba(240, 185, 11, 0.05)' }}>
<div className="text-xs mb-1 uppercase tracking-wider" style={{ color: '#848E9C' }}></div>
<div className="text-base font-bold mono" style={{ color: '#EAECEF' }}>
{history.length > MAX_DISPLAY_POINTS
? `最近 ${MAX_DISPLAY_POINTS}`
: '全部数据'
}
</div>
</div>
</div>
</div>
);
}

481
web/src/index.css Normal file
View File

@@ -0,0 +1,481 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
/* Binance Theme Colors */
--binance-yellow: #F0B90B;
--binance-yellow-dark: #C99400;
--binance-yellow-light: #FCD535;
--binance-yellow-glow: rgba(240, 185, 11, 0.2);
--background: #0B0E11;
--background-elevated: #181A20;
--foreground: #EAECEF;
--panel-bg: #1E2329;
--panel-bg-hover: #252930;
--panel-border: #2B3139;
--panel-border-hover: #474D57;
/* Binance Signature Colors */
--binance-green: #0ECB81;
--binance-green-bg: rgba(14, 203, 129, 0.1);
--binance-green-border: rgba(14, 203, 129, 0.2);
--binance-red: #F6465D;
--binance-red-bg: rgba(246, 70, 93, 0.1);
--binance-red-border: rgba(246, 70, 93, 0.2);
/* UI Colors */
--text-primary: #EAECEF;
--text-secondary: #848E9C;
--text-tertiary: #5E6673;
--text-disabled: #474D57;
/* Chart Colors */
--grid-stroke: #2B3139;
--axis-tick: #5E6673;
--ref-line: #474D57;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6), 0 10px 10px -5px rgba(0, 0, 0, 0.4);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
line-height: 1.6;
font-weight: 400;
color-scheme: dark;
color: var(--foreground);
background-color: var(--background);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-feature-settings: "tnum";
font-variant-numeric: tabular-nums;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background-color: var(--background);
}
#root {
width: 100%;
margin: 0 auto;
}
/* Scrollbar - Binance style */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--background);
}
::-webkit-scrollbar-thumb {
background: var(--panel-border);
border-radius: 4px;
transition: background 0.2s;
}
::-webkit-scrollbar-thumb:hover {
background: var(--panel-border-hover);
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes pulse-glow {
0%, 100% {
opacity: 1;
box-shadow: 0 0 8px currentColor;
}
50% {
opacity: 0.7;
box-shadow: 0 0 16px currentColor;
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
@keyframes pulse-scale {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
.animate-slide-in {
animation: slideIn 0.4s ease-out;
}
.animate-scale-in {
animation: scaleIn 0.3s ease-out;
}
.pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
/* Glass effect - Binance header style */
.glass {
background: var(--background-elevated);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--panel-border);
box-shadow: var(--shadow-sm);
}
/* Monospace numbers */
.mono {
font-family: 'IBM Plex Mono', 'Courier New', monospace;
font-variant-numeric: tabular-nums;
}
/* Button styles - Binance */
button {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
font-weight: 600;
}
button:hover:not(:disabled) {
filter: brightness(1.1);
transform: translateY(-1px);
}
button:active:not(:disabled) {
transform: scale(0.98) translateY(0);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Binance Card - Enhanced */
.binance-card {
background: var(--panel-bg);
border: 1px solid var(--panel-border);
border-radius: 4px;
transition: all 0.2s ease;
box-shadow: var(--shadow-sm);
}
.binance-card:hover {
border-color: var(--panel-border-hover);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.binance-card-no-hover {
background: var(--panel-bg);
border: 1px solid var(--panel-border);
border-radius: 4px;
box-shadow: var(--shadow-sm);
}
/* Binance gradient backgrounds */
.binance-gradient {
background: linear-gradient(135deg, var(--binance-yellow) 0%, var(--binance-yellow-light) 100%);
}
.binance-gradient-subtle {
background: linear-gradient(135deg, rgba(240, 185, 11, 0.15) 0%, rgba(252, 213, 53, 0.05) 100%);
border: 1px solid rgba(240, 185, 11, 0.2);
}
.binance-glow {
box-shadow: 0 0 20px var(--binance-yellow-glow);
}
/* Status colors */
.text-profit {
color: var(--binance-green);
font-weight: 700;
}
.text-loss {
color: var(--binance-red);
font-weight: 700;
}
.bg-profit {
background-color: var(--binance-green-bg);
color: var(--binance-green);
border: 1px solid var(--binance-green-border);
}
.bg-loss {
background-color: var(--binance-red-bg);
color: var(--binance-red);
border: 1px solid var(--binance-red-border);
}
/* Skeleton loading */
.skeleton {
background: var(--panel-bg);
background-image: linear-gradient(
90deg,
transparent,
rgba(240, 185, 11, 0.05),
transparent
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
/* Binance button variants */
.btn-binance {
background: var(--binance-yellow);
color: #000;
font-weight: 700;
border: none;
border-radius: 4px;
padding: 0.625rem 1.25rem;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(240, 185, 11, 0.3);
}
.btn-binance:hover {
background: var(--binance-yellow-light);
box-shadow: 0 4px 8px rgba(240, 185, 11, 0.4);
transform: translateY(-2px);
}
.btn-binance:active {
background: var(--binance-yellow-dark);
transform: translateY(0);
}
.btn-outline {
background: transparent;
color: var(--binance-yellow);
border: 1.5px solid var(--binance-yellow);
border-radius: 4px;
padding: 0.5rem 1rem;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 600;
}
.btn-outline:hover {
background: rgba(240, 185, 11, 0.1);
border-color: var(--binance-yellow-light);
}
/* Table styles - Binance */
table {
width: 100%;
border-collapse: collapse;
}
th {
color: var(--text-secondary);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.75rem 0;
}
td {
color: var(--text-primary);
padding: 0.75rem 0;
}
tr {
border-bottom: 1px solid var(--panel-border);
transition: background 0.2s ease;
}
tr:last-child {
border-bottom: none;
}
tr:hover {
background: rgba(240, 185, 11, 0.03);
}
/* Badge/Chip styles */
.badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 700;
border: 1px solid;
transition: all 0.2s ease;
}
.badge-yellow {
background: rgba(240, 185, 11, 0.1);
border-color: rgba(240, 185, 11, 0.3);
color: var(--binance-yellow);
}
.badge-green {
background: var(--binance-green-bg);
border-color: var(--binance-green-border);
color: var(--binance-green);
}
.badge-red {
background: var(--binance-red-bg);
border-color: var(--binance-red-border);
color: var(--binance-red);
}
/* Number formatting */
.number-up {
color: var(--binance-green);
}
.number-down {
color: var(--binance-red);
}
.number-neutral {
color: var(--text-secondary);
}
/* Divider */
.divider {
height: 1px;
background: var(--panel-border);
margin: 1rem 0;
}
/* Tooltip style */
.tooltip {
background: var(--panel-bg);
border: 1px solid var(--panel-border);
border-radius: 4px;
padding: 0.75rem;
box-shadow: var(--shadow-lg);
z-index: 1000;
}
/* Loading spinner */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.spinner {
border: 2px solid var(--panel-border);
border-top-color: var(--binance-yellow);
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 0.6s linear infinite;
}
/* Stat card enhancements */
.stat-card {
background: var(--panel-bg);
border: 1px solid var(--panel-border);
border-radius: 4px;
padding: 1.25rem;
transition: all 0.2s ease;
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--binance-yellow), transparent);
opacity: 0;
transition: opacity 0.2s ease;
}
.stat-card:hover::before {
opacity: 1;
}
.stat-card:hover {
border-color: rgba(240, 185, 11, 0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
transform: translateY(-2px);
}
/* Chart container */
.chart-container {
background: var(--panel-bg);
border: 1px solid var(--panel-border);
border-radius: 4px;
padding: 1.25rem;
box-shadow: var(--shadow-sm);
}
/* Responsive utilities */
@media (max-width: 768px) {
.binance-card {
padding: 1rem;
}
.stat-card {
padding: 1rem;
}
}

103
web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,103 @@
import type {
SystemStatus,
AccountInfo,
Position,
DecisionRecord,
Statistics,
TraderInfo,
CompetitionData,
} from '../types';
const API_BASE = '/api';
export const api = {
// 竞赛相关接口
async getCompetition(): Promise<CompetitionData> {
const res = await fetch(`${API_BASE}/competition`);
if (!res.ok) throw new Error('获取竞赛数据失败');
return res.json();
},
async getTraders(): Promise<TraderInfo[]> {
const res = await fetch(`${API_BASE}/traders`);
if (!res.ok) throw new Error('获取trader列表失败');
return res.json();
},
// 获取系统状态支持trader_id
async getStatus(traderId?: string): Promise<SystemStatus> {
const url = traderId
? `${API_BASE}/status?trader_id=${traderId}`
: `${API_BASE}/status`;
const res = await fetch(url);
if (!res.ok) throw new Error('获取系统状态失败');
return res.json();
},
// 获取账户信息支持trader_id
async getAccount(traderId?: string): Promise<AccountInfo> {
const url = traderId
? `${API_BASE}/account?trader_id=${traderId}`
: `${API_BASE}/account`;
const res = await fetch(url, {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
},
});
if (!res.ok) throw new Error('获取账户信息失败');
const data = await res.json();
console.log('Account data fetched:', data);
return data;
},
// 获取持仓列表支持trader_id
async getPositions(traderId?: string): Promise<Position[]> {
const url = traderId
? `${API_BASE}/positions?trader_id=${traderId}`
: `${API_BASE}/positions`;
const res = await fetch(url);
if (!res.ok) throw new Error('获取持仓列表失败');
return res.json();
},
// 获取决策日志支持trader_id
async getDecisions(traderId?: string): Promise<DecisionRecord[]> {
const url = traderId
? `${API_BASE}/decisions?trader_id=${traderId}`
: `${API_BASE}/decisions`;
const res = await fetch(url);
if (!res.ok) throw new Error('获取决策日志失败');
return res.json();
},
// 获取最新决策支持trader_id
async getLatestDecisions(traderId?: string): Promise<DecisionRecord[]> {
const url = traderId
? `${API_BASE}/decisions/latest?trader_id=${traderId}`
: `${API_BASE}/decisions/latest`;
const res = await fetch(url);
if (!res.ok) throw new Error('获取最新决策失败');
return res.json();
},
// 获取统计信息支持trader_id
async getStatistics(traderId?: string): Promise<Statistics> {
const url = traderId
? `${API_BASE}/statistics?trader_id=${traderId}`
: `${API_BASE}/statistics`;
const res = await fetch(url);
if (!res.ok) throw new Error('获取统计信息失败');
return res.json();
},
// 获取收益率历史数据支持trader_id
async getEquityHistory(traderId?: string): Promise<any[]> {
const url = traderId
? `${API_BASE}/equity-history?trader_id=${traderId}`
: `${API_BASE}/equity-history`;
const res = await fetch(url);
if (!res.ok) throw new Error('获取历史数据失败');
return res.json();
},
};

10
web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

109
web/src/types.ts Normal file
View File

@@ -0,0 +1,109 @@
export interface SystemStatus {
trader_id: string;
trader_name: string;
ai_model: string;
is_running: boolean;
start_time: string;
runtime_minutes: number;
call_count: number;
initial_balance: number;
scan_interval: string;
stop_until: string;
last_reset_time: string;
ai_provider: string;
}
export interface AccountInfo {
total_equity: number;
wallet_balance: number;
unrealized_profit: number;
available_balance: number;
total_pnl: number;
total_pnl_pct: number;
total_unrealized_pnl: number;
initial_balance: number;
daily_pnl: number;
position_count: number;
margin_used: number;
margin_used_pct: number;
}
export interface Position {
symbol: string;
side: string;
entry_price: number;
mark_price: number;
quantity: number;
leverage: number;
unrealized_pnl: number;
unrealized_pnl_pct: number;
liquidation_price: number;
margin_used: number;
}
export interface DecisionAction {
action: string;
symbol: string;
quantity: number;
leverage: number;
price: number;
order_id: number;
timestamp: string;
success: boolean;
error?: string;
}
export interface AccountSnapshot {
total_balance: number;
available_balance: number;
total_unrealized_profit: number;
position_count: number;
margin_used_pct: number;
}
export interface DecisionRecord {
timestamp: string;
cycle_number: number;
cot_trace: string;
decision_json: string;
account_state: AccountSnapshot;
positions: any[];
candidate_coins: string[];
decisions: DecisionAction[];
execution_log: string[];
success: boolean;
error_message?: string;
}
export interface Statistics {
total_cycles: number;
successful_cycles: number;
failed_cycles: number;
total_open_positions: number;
total_close_positions: number;
}
// 新增:竞赛相关类型
export interface TraderInfo {
trader_id: string;
trader_name: string;
ai_model: string;
}
export interface CompetitionTraderData {
trader_id: string;
trader_name: string;
ai_model: string;
total_equity: number;
total_pnl: number;
total_pnl_pct: number;
position_count: number;
margin_used_pct: number;
call_count: number;
is_running: boolean;
}
export interface CompetitionData {
traders: CompetitionTraderData[];
count: number;
}

92
web/src/types/index.ts Normal file
View File

@@ -0,0 +1,92 @@
// 系统状态
export interface SystemStatus {
is_running: boolean;
start_time: string;
runtime_minutes: number;
call_count: number;
initial_balance: number;
scan_interval: string;
stop_until: string;
last_reset_time: string;
ai_provider: string;
}
// 账户信息
export interface AccountInfo {
total_equity: number;
available_balance: number;
total_pnl: number;
total_pnl_pct: number;
total_unrealized_pnl: number;
margin_used: number;
margin_used_pct: number;
position_count: number;
initial_balance: number;
daily_pnl: number;
}
// 持仓信息
export interface Position {
symbol: string;
side: string;
entry_price: number;
mark_price: number;
quantity: number;
leverage: number;
unrealized_pnl: number;
unrealized_pnl_pct: number;
liquidation_price: number;
margin_used: number;
}
// 决策动作
export interface DecisionAction {
action: string;
symbol: string;
quantity: number;
leverage: number;
price: number;
order_id: number;
timestamp: string;
success: boolean;
error: string;
}
// 决策记录
export interface DecisionRecord {
timestamp: string;
cycle_number: number;
cot_trace: string;
decision_json: string;
account_state: {
total_balance: number;
available_balance: number;
total_unrealized_profit: number;
position_count: number;
margin_used_pct: number;
};
positions: Array<{
symbol: string;
side: string;
position_amt: number;
entry_price: number;
mark_price: number;
unrealized_profit: number;
leverage: number;
liquidation_price: number;
}>;
candidate_coins: string[];
decisions: DecisionAction[];
execution_log: string[];
success: boolean;
error_message: string;
}
// 统计信息
export interface Statistics {
total_cycles: number;
successful_cycles: number;
failed_cycles: number;
total_open_positions: number;
total_close_positions: number;
}