mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-04 11:30:58 +08:00
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:
599
web/src/App.tsx
Normal file
599
web/src/App.tsx
Normal 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;
|
||||
298
web/src/components/ComparisonChart.tsx
Normal file
298
web/src/components/ComparisonChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
244
web/src/components/CompetitionPage.tsx
Normal file
244
web/src/components/CompetitionPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
289
web/src/components/EquityChart.tsx
Normal file
289
web/src/components/EquityChart.tsx
Normal 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
481
web/src/index.css
Normal 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
103
web/src/lib/api.ts
Normal 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
10
web/src/main.tsx
Normal 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
109
web/src/types.ts
Normal 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
92
web/src/types/index.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user