Files
evotraders/frontend/src/App.jsx

1068 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useRef, useState, useCallback } from "react";
// Configuration and constants
import { AGENTS, INITIAL_TICKERS } from './config/constants';
// Services
import { ReadOnlyClient } from './services/websocket';
// Hooks
import { useFeedProcessor } from './hooks/useFeedProcessor';
// Styles
import GlobalStyles from './styles/GlobalStyles';
// Components
import RoomView from './components/RoomView';
import NetValueChart from './components/NetValueChart';
import AgentFeed from './components/AgentFeed';
import StockLogo from './components/StockLogo';
import StatisticsView from './components/StatisticsView';
import PerformanceView from './components/PerformanceView';
import AboutModal from './components/AboutModal';
import RulesView from './components/RulesView';
import Header from './components/Header.jsx';
// Utils
import { formatNumber, formatTickerPrice } from './utils/formatters';
/**
* Live Trading Intelligence Platform - Read-Only Dashboard
* Geek Style - Terminal-inspired, minimal, monochrome
*
*/
export default function LiveTradingApp() {
const [isConnected, setIsConnected] = useState(false);
const [connectionStatus, setConnectionStatus] = useState('connecting'); // 'connecting' | 'connected' | 'disconnected'
const [systemStatus, setSystemStatus] = useState('initializing'); // 'initializing' | 'running' | 'completed'
const [currentDate, setCurrentDate] = useState(null);
const [progress, setProgress] = useState({ current: 0, total: 0 });
const [now, setNow] = useState(() => new Date());
const [showAboutModal, setShowAboutModal] = useState(false);
// View toggle: 'rules' | 'room' | 'chart' | 'statistics'
const [currentView, setCurrentView] = useState('chart'); // Start with chart, then animate to room
const [isInitialAnimating, setIsInitialAnimating] = useState(true);
const [lastUpdate, setLastUpdate] = useState(new Date());
const [isUpdating, setIsUpdating] = useState(false);
// Chart data
const [chartTab, setChartTab] = useState('all');
const [portfolioData, setPortfolioData] = useState({
netValue: 10000,
pnl: 0,
equity: [],
baseline: [], // Baseline strategy (Buy & Hold - Equal Weight)
baseline_vw: [], // Baseline strategy (Buy & Hold - Value Weighted)
momentum: [], // Momentum strategy
strategies: [] // Other strategies
});
// Feed data (using hook for simplified processing)
const { feed, processHistoricalFeed, processFeedEvent, addSystemMessage } = useFeedProcessor();
// Statistics data
const [holdings, setHoldings] = useState([]);
const [trades, setTrades] = useState([]);
const [stats, setStats] = useState(null);
const [leaderboard, setLeaderboard] = useState([]);
// Ticker prices (now from real-time data)
const [tickers, setTickers] = useState(INITIAL_TICKERS);
const [rollingTickers, setRollingTickers] = useState({});
// Room bubbles
const [bubbles, setBubbles] = useState({});
// Resizable panels
const [leftWidth, setLeftWidth] = useState(70); // percentage
const [isResizing, setIsResizing] = useState(false);
// Market status
const [serverMode, setServerMode] = useState(null); // 'live' | 'backtest' | null
const [marketStatus, setMarketStatus] = useState(null); // { status, status_text, ... }
const [virtualTime, setVirtualTime] = useState(null); // Virtual time from server (for mock mode)
const [dataSources, setDataSources] = useState(null);
const clientRef = useRef(null);
const containerRef = useRef(null);
const agentFeedRef = useRef(null);
// Track last virtual time update to calculate increment
const lastVirtualTimeRef = useRef(null);
const virtualTimeOffsetRef = useRef(0);
// Last day history for replay
const [lastDayHistory, setLastDayHistory] = useState([]);
// Determine if LIVE tab should be enabled
const isLiveEnabled = useMemo(() => {
if (!marketStatus) return false;
return marketStatus.status === 'open';
}, [marketStatus]);
// Switch away from LIVE tab when market closes
useEffect(() => {
if (!isLiveEnabled && chartTab === 'live') {
setChartTab('all');
}
}, [isLiveEnabled, chartTab]);
// Clock - use virtual time if available (for mock mode)
useEffect(() => {
if (virtualTime) {
// In mock mode, calculate offset from real time
const virtualTimeMs = new Date(virtualTime).getTime();
const realTimeMs = Date.now();
virtualTimeOffsetRef.current = virtualTimeMs - realTimeMs;
lastVirtualTimeRef.current = virtualTimeMs;
setNow(new Date(virtualTime));
// Update clock every second based on offset
const id = setInterval(() => {
const currentRealTime = Date.now();
const currentVirtualTime = currentRealTime + virtualTimeOffsetRef.current;
setNow(new Date(currentVirtualTime));
}, 1000);
return () => clearInterval(id);
} else {
// In live mode, use real time
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}
}, [virtualTime]);
// Update clock when virtual time changes (recalculate offset)
useEffect(() => {
if (virtualTime) {
const virtualTimeMs = new Date(virtualTime).getTime();
const realTimeMs = Date.now();
virtualTimeOffsetRef.current = virtualTimeMs - realTimeMs;
lastVirtualTimeRef.current = virtualTimeMs;
setNow(new Date(virtualTime));
}
}, [virtualTime]);
// Track updates with visual feedback
useEffect(() => {
setLastUpdate(new Date());
setIsUpdating(true);
const timer = setTimeout(() => setIsUpdating(false), 500);
return () => clearTimeout(timer);
}, [holdings, stats, trades, portfolioData.netValue]);
// Initial animation: show room drawer sliding in
useEffect(() => {
// Wait a bit after mount, then trigger slide to room
const slideTimer = setTimeout(() => {
setCurrentView('room');
}, 1200); // Wait 1200ms before starting animation (2x slower)
// Disable animation flag after animation completes
const completeTimer = setTimeout(() => {
setIsInitialAnimating(false);
}, 5000); // 1200ms delay + 1600ms animation duration + 400ms buffer
return () => {
clearTimeout(slideTimer);
clearTimeout(completeTimer);
};
}, []);
// Helper to check if bubble should still be visible
// Bubbles persist until replaced by ANY new message (cross-role)
// When any agent sends a new message, all previous bubbles are cleared
// Can search by agentId or agentName
const bubbleFor = (idOrName) => {
// First try direct lookup by id
let b = bubbles[idOrName];
if (b) {
return b;
}
// If not found, search by agentName
const agent = AGENTS.find(a => a.name === idOrName || a.id === idOrName);
if (agent) {
b = bubbles[agent.id];
if (b) {
return b;
}
}
return null;
};
// Handle jump to message in feed
const handleJumpToMessage = useCallback((bubble) => {
// Switch to room tab (if not already there) for better context
// Then scroll AgentFeed to the message
if (agentFeedRef.current && agentFeedRef.current.scrollToMessage) {
agentFeedRef.current.scrollToMessage(bubble);
}
}, []);
// Auto-connect to server on mount
useEffect(() => {
// Define pushEvent inside useEffect to avoid dependency issues
const handlePushEvent = (evt) => {
if (!evt) return;
try {
handleEventInternal(evt);
} catch (error) {
console.error('[Event Handler] Error:', error);
}
};
const handleEventInternal = (evt) => {
if (evt?.type && evt.type !== 'pong') {
setConnectionStatus('connected');
setIsConnected(true);
}
// Helper: Update tickers from realtime prices
const updateTickersFromPrices = (realtimePrices) => {
try {
setTickers(prevTickers => {
return prevTickers.map(ticker => {
const realtimeData = realtimePrices[ticker.symbol];
if (realtimeData && realtimeData.price !== null && realtimeData.price !== undefined) {
// Use 'ret' from realtime data (relative to open price) if available
const newChange = (realtimeData.ret !== null && realtimeData.ret !== undefined)
? realtimeData.ret
: (ticker.change !== null && ticker.change !== undefined ? ticker.change : 0);
return {
...ticker,
price: realtimeData.price,
change: newChange,
open: realtimeData.open || ticker.open
};
}
return ticker;
});
});
} catch (error) {
console.error('Error updating tickers from prices:', error);
}
};
const handlers = {
// Error response (for fast forward errors)
error: (e) => {
console.error('[Error]', e.message);
// Handle fast forward errors
if (e.message && e.message.includes('fast forward')) {
console.warn(`⚠️ ${e.message}`);
handlePushEvent({
type: 'system',
content: `⚠️ ${e.message}`,
timestamp: Date.now()
});
}
},
// Connection events
system: (e) => {
console.log('[System]', e.content);
if (
e.content.includes('Connected')
|| e.content.includes('已连接')
) {
setConnectionStatus('connected');
setIsConnected(true);
} else if (
e.content.includes('Disconnected')
|| e.content.includes('断开')
) {
setConnectionStatus('disconnected');
setIsConnected(false);
}
processFeedEvent(e);
},
// Pong response from server
pong: (e) => {
console.log('[Heartbeat] Pong received');
},
// Initial state from server
initial_state: (e) => {
try {
const state = e.state;
if (!state) return;
setConnectionStatus('connected');
setIsConnected(true);
setSystemStatus(state.status || 'initializing');
setCurrentDate(state.current_date);
// 设置服务器模式和市场状态
if (state.server_mode) {
setServerMode(state.server_mode);
}
if (state.data_sources) {
setDataSources(state.data_sources);
}
// 检查是否是mock模式
const isMockMode = state.is_mock_mode === true;
if (state.market_status) {
setMarketStatus(state.market_status);
// 只在Mock模式下如果市场状态包含虚拟时间才保存它
if (isMockMode && state.market_status.current_time) {
try {
const virtualTimeDate = new Date(state.market_status.current_time);
setVirtualTime(virtualTimeDate);
} catch (error) {
console.error('Error parsing virtual time from market_status:', error);
}
} else {
// 非Mock模式下清除virtualTime
setVirtualTime(null);
}
}
if (state.trading_days_total) {
setProgress({
current: state.trading_days_completed || 0,
total: state.trading_days_total
});
}
if (state.portfolio) {
setPortfolioData(prev => ({
...prev,
netValue: state.portfolio.total_value || prev.netValue,
pnl: state.portfolio.pnl_percent || 0,
equity: state.portfolio.equity || prev.equity,
baseline: state.portfolio.baseline || prev.baseline,
baseline_vw: state.portfolio.baseline_vw || prev.baseline_vw,
momentum: state.portfolio.momentum || prev.momentum,
strategies: state.portfolio.strategies || prev.strategies,
equity_return: state.portfolio.equity_return || prev.equity_return,
baseline_return: state.portfolio.baseline_return || prev.baseline_return,
baseline_vw_return: state.portfolio.baseline_vw_return || prev.baseline_vw_return,
momentum_return: state.portfolio.momentum_return || prev.momentum_return
}));
}
if (state.dashboard) {
if (state.dashboard.holdings) setHoldings(state.dashboard.holdings);
if (state.dashboard.trades) setTrades(state.dashboard.trades);
if (state.dashboard.stats) setStats(state.dashboard.stats);
if (state.dashboard.leaderboard) setLeaderboard(state.dashboard.leaderboard);
}
if (state.realtime_prices) updateTickersFromPrices(state.realtime_prices);
// Load and process historical feed data
if (state.feed_history && Array.isArray(state.feed_history)) {
console.log(`✅ Loading ${state.feed_history.length} historical events`);
processHistoricalFeed(state.feed_history);
}
// Load last day history for replay
if (state.last_day_history && Array.isArray(state.last_day_history)) {
setLastDayHistory(state.last_day_history);
console.log(`✅ Loaded ${state.last_day_history.length} last day events for replay`);
}
console.log('Initial state loaded');
} catch (error) {
console.error('Error loading initial state:', error);
}
},
// Market status update
market_status_update: (e) => {
if (e.market_status) {
setMarketStatus(e.market_status);
}
},
data_sources_update: (e) => {
if (e.data_sources) {
setDataSources(e.data_sources);
}
},
// Real-time price updates
price_update: (e) => {
try {
const { symbol, price, ret, open, portfolio, realtime_prices } = e;
if (!symbol || !price) {
console.warn('[Price Update] Missing symbol or price:', e);
return;
}
setConnectionStatus('connected');
setIsConnected(true);
console.log(`[Price Update] ${symbol}: $${price} (ret: ${ret !== undefined ? ret.toFixed(2) : 'N/A'}%)`);
// Update ticker price with animation
setTickers(prevTickers => {
return prevTickers.map(ticker => {
if (ticker.symbol === symbol) {
const oldPrice = ticker.price;
// Use 'ret' from server (relative to open price) if available
// Otherwise fallback to calculating change from previous price
let newChange = ticker.change;
if (ret !== null && ret !== undefined) {
// Use server-provided ret (relative to open price)
newChange = ret;
} else if (oldPrice !== null && oldPrice !== undefined && isFinite(oldPrice)) {
// Fallback: calculate change from previous price
const priceChange = ((price - oldPrice) / oldPrice) * 100;
newChange = (newChange !== null && newChange !== undefined)
? newChange + priceChange
: priceChange;
} else {
// First price received, set change to 0
newChange = 0;
}
// Trigger rolling animation only if price actually changed
if (oldPrice !== price) {
setRollingTickers(prev => ({ ...prev, [symbol]: true }));
setTimeout(() => {
setRollingTickers(prev => ({ ...prev, [symbol]: false }));
}, 500);
}
return {
...ticker,
price: price,
change: newChange,
open: open || ticker.open // Store open price
};
}
return ticker;
});
});
// Update all tickers from realtime_prices if provided
if (realtime_prices) {
updateTickersFromPrices(realtime_prices);
}
// Update portfolio value if provided
if (portfolio && portfolio.total_value) {
setPortfolioData(prev => ({
...prev,
netValue: portfolio.total_value,
pnl: portfolio.pnl_percent || 0,
equity: portfolio.equity || prev.equity // Update equity curve
}));
}
} catch (error) {
console.error('[Price Update] Error:', error);
}
},
// Day progress events
day_start: (e) => {
setCurrentDate(e.date);
if (e.progress !== undefined) {
setProgress(prev => ({
...prev,
current: Math.floor(e.progress * (prev.total || 1))
}));
}
setSystemStatus('running');
processFeedEvent(e);
},
day_complete: (e) => {
// Update from day result
const result = e.result;
if (result && typeof result === 'object') {
// Update portfolio equity if available
if (result.portfolio_summary) {
const summary = result.portfolio_summary;
setPortfolioData(prev => {
const newEquity = [...prev.equity];
// Add new data point
const dateObj = new Date(e.date);
newEquity.push({
t: dateObj.getTime(),
v: summary.total_value || summary.cash || prev.netValue
});
return {
...prev,
netValue: summary.total_value || summary.cash || prev.netValue,
pnl: summary.pnl_percent || 0,
equity: newEquity
};
});
}
}
processFeedEvent(e);
},
day_error: (e) => {
console.error('Day error:', e.date, e.error);
processFeedEvent(e);
},
conference_start: (e) => {
processFeedEvent(e);
},
conference_end: (e) => {
processFeedEvent(e);
},
agent_message: (e) => {
const agent = AGENTS.find(a => a.id === e.agentId);
// Update bubbles for room view
setBubbles({
[e.agentId]: {
text: e.content,
ts: Date.now(),
agentName: agent?.name || e.agentName || e.agentId
}
});
processFeedEvent(e);
},
conference_message: (e) => {
const agent = AGENTS.find(a => a.id === e.agentId);
// Update bubbles for room view
setBubbles({
[e.agentId]: {
text: e.content,
ts: Date.now(),
agentName: agent?.name || e.agentName || e.agentId
}
});
processFeedEvent(e);
},
memory: (e) => {
processFeedEvent(e);
},
team_summary: (e) => {
// Update portfolio data silently without creating feed messages
setPortfolioData(prev => ({
...prev,
netValue: e.balance || prev.netValue,
pnl: e.pnlPct || 0,
equity: e.equity || prev.equity,
baseline: e.baseline || prev.baseline,
baseline_vw: e.baseline_vw || prev.baseline_vw,
momentum: e.momentum || prev.momentum,
equity_return: e.equity_return || prev.equity_return,
baseline_return: e.baseline_return || prev.baseline_return,
baseline_vw_return: e.baseline_vw_return || prev.baseline_vw_return,
momentum_return: e.momentum_return || prev.momentum_return
}));
// Portfolio updates are shown in the ticker bar, no need for feed messages
},
team_portfolio: (e) => {
if (e.holdings) setHoldings(e.holdings);
},
// ✅ 监听 holdings 更新(服务器广播的事件名)
team_holdings: (e) => {
if (e.data && Array.isArray(e.data)) {
setHoldings(e.data);
console.log(`✅ Holdings updated: ${e.data.length} positions`);
}
},
team_trades: (e) => {
// 支持两种格式:完整列表或单笔交易
if (e.mode === 'full' && e.data && Array.isArray(e.data)) {
setTrades(e.data);
console.log(`✅ Trades updated (full): ${e.data.length} trades`);
} else if (Array.isArray(e.trades)) {
setTrades(e.trades);
} else if (e.trade) {
setTrades(prev => [e.trade, ...prev].slice(0, 100));
}
},
team_stats: (e) => {
if (e.data) {
setStats(e.data);
console.log('✅ Stats updated');
} else if (e.stats) {
setStats(e.stats);
}
},
team_leaderboard: (e) => {
// 服务器发送的格式: { type: 'team_leaderboard', data: [...], timestamp: ... }
if (Array.isArray(e.data)) {
setLeaderboard(e.data);
console.log('✅ Leaderboard updated:', e.data.length, 'agents');
} else if (Array.isArray(e.rows)) {
setLeaderboard(e.rows);
} else if (Array.isArray(e.leaderboard)) {
setLeaderboard(e.leaderboard);
}
},
// 虚拟时间更新Mock模式下的时间广播
time_update: (e) => {
if (e.beijing_time_str) {
const statusEmoji = {
'market_open': '📊',
'off_market': '⏸️',
'non_trading_day': '📅',
'trade_execution': '💼'
};
const emoji = statusEmoji[e.status] || '⏰';
const isMockMode = e.is_mock_mode === true;
let logMessage = `${emoji} ${isMockMode ? '虚拟时间' : '时间'}: ${e.beijing_time_str} | 状态: ${e.status}`;
if (e.hours_to_open !== undefined) {
logMessage += ` | 距离开盘: ${e.hours_to_open}小时`;
}
if (e.hours_to_trade !== undefined) {
logMessage += ` | 距离交易: ${e.hours_to_trade}小时`;
}
if (e.trading_date) {
logMessage += ` | 交易日: ${e.trading_date}`;
}
console.log(logMessage);
// 只在Mock模式下保存虚拟时间用于图表过滤和UI显示
if (isMockMode && e.beijing_time) {
try {
const virtualTimeDate = new Date(e.beijing_time);
setVirtualTime(virtualTimeDate);
} catch (error) {
console.error('Error parsing virtual time:', error);
}
} else {
// 非Mock模式下清除virtualTime
setVirtualTime(null);
}
}
// 更新市场状态如果包含在time_update中
if (e.market_status) {
setMarketStatus(e.market_status);
}
},
// 时间快进事件Mock模式
time_fast_forwarded: (e) => {
console.log(`⏩ 时间已快进 ${e.minutes} 分钟: ${e.old_time_str}${e.new_time_str}`);
// 更新虚拟时间
if (e.new_time) {
try {
const virtualTimeDate = new Date(e.new_time);
setVirtualTime(virtualTimeDate);
// 添加到feed显示
handlePushEvent({
type: 'system',
content: `⏩ 时间快进 ${e.minutes} 分钟: ${e.old_time_str}${e.new_time_str}`,
timestamp: Date.now()
});
} catch (error) {
console.error('Error parsing fast forwarded time:', error);
}
}
},
// 快进成功响应
fast_forward_success: (e) => {
console.log(`${e.message}`);
},
};
// Call handler or do nothing
try {
const handler = handlers[evt.type];
if (handler) {
handler(evt);
} else {
console.log('[handleEvent] Unknown event type:', evt.type);
}
} catch (error) {
console.error('[handleEvent] Error handling event:', evt.type, error);
}
};
// Create and connect WebSocket client
const client = new ReadOnlyClient(handlePushEvent);
clientRef.current = client;
client.connect();
setConnectionStatus('connecting');
return () => {
// Cleanup on unmount
if (clientRef.current) {
clientRef.current.disconnect();
}
};
}, []); // Empty dependency array - only run once on mount
// Resizing handlers
const handleMouseDown = (e) => {
e.preventDefault();
setIsResizing(true);
};
useEffect(() => {
if (!isResizing) return;
const handleMouseMove = (e) => {
if (!containerRef.current) return;
const containerRect = containerRef.current.getBoundingClientRect();
const newLeftWidth = ((e.clientX - containerRect.left) / containerRect.width) * 100;
// Limit between 30% and 85%
if (newLeftWidth >= 30 && newLeftWidth <= 85) {
setLeftWidth(newLeftWidth);
}
};
const handleMouseUp = () => {
setIsResizing(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isResizing]);
return (
<div className="app">
<GlobalStyles />
{/* Header */}
<div className="header">
<Header
onEvoTradersClick={() => setShowAboutModal(true)}
evoTradersLinkStyle="default"
/>
<div className="header-right" style={{ display: 'flex', alignItems: 'center', gap: 24, marginLeft: 'auto', flexWrap: 'wrap', minWidth: 0 }}>
{/* Mock Mode Indicator */}
{virtualTime && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 10px',
borderRadius: 4,
background: '#FF9800',
border: '1px solid #FFB74D'
}}>
<span style={{ fontSize: '14px' }}></span>
<span style={{
fontSize: '11px',
fontWeight: 600,
color: '#FFFFFF',
fontFamily: '"Courier New", monospace',
letterSpacing: '0.5px'
}}>
模拟模式
</span>
</div>
)}
{/* Clock Display (only in Mock mode) */}
{virtualTime && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: 8
}}>
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: 2,
padding: '4px 12px',
borderRadius: 4,
background: '#1A237E',
border: '1px solid #3F51B5'
}}>
<span style={{
fontSize: '11px',
color: '#999',
fontFamily: '"Courier New", monospace',
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}>
虚拟时间
</span>
<span style={{
fontSize: '14px',
fontWeight: 700,
color: '#FFFFFF',
fontFamily: '"Courier New", monospace',
letterSpacing: '1px'
}}>
{now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}
</span>
<span style={{
fontSize: '10px',
color: '#999',
fontFamily: '"Courier New", monospace'
}}>
{now.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
</div>
{/* Fast Forward Button (only in Mock mode) */}
<button
onClick={() => {
if (clientRef.current) {
const success = clientRef.current.send({
type: 'fast_forward_time',
minutes: 30
});
if (!success) {
console.error('Failed to send fast forward request');
}
}
}}
style={{
padding: '6px 12px',
borderRadius: 4,
background: '#3F51B5',
border: '1px solid #5C6BC0',
color: '#FFFFFF',
fontSize: '12px',
fontFamily: '"Courier New", monospace',
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.2s',
display: 'flex',
alignItems: 'center',
gap: 4,
textTransform: 'uppercase',
letterSpacing: '0.5px'
}}
onMouseEnter={(e) => {
e.target.style.background = '#5C6BC0';
e.target.style.borderColor = '#7986CB';
}}
onMouseLeave={(e) => {
e.target.style.background = '#3F51B5';
e.target.style.borderColor = '#5C6BC0';
}}
title="快进30分钟 (Mock模式)"
>
+30min
</button>
</div>
)}
{/* Unified Status Indicator */}
<div className="header-status-inline">
<span className={`status-dot ${isConnected ? (isUpdating ? 'updating' : 'live') : 'offline'}`} />
<span className={`status-text ${isConnected ? 'live' : 'offline'}`}>
{isConnected ? (isUpdating ? '同步中' : '在线') : '离线'}
</span>
{marketStatus && (
<>
<span className="status-sep">·</span>
<span className={`market-text ${serverMode === 'backtest' ? 'backtest' : (marketStatus.status === 'open' ? 'open' : 'closed')}`}>
{marketStatus.status_text || (marketStatus.status === 'open' ? '开盘' : '收盘')}
</span>
</>
)}
{dataSources?.last_success?.prices && (
<>
<span className="status-sep">·</span>
<span className="market-text backtest">
DATA {String(dataSources.last_success.prices).toUpperCase()}
</span>
</>
)}
<span className="status-sep">·</span>
<span className="time-text">{lastUpdate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}</span>
</div>
</div>
</div>
{/* Main Content */}
<>
{/* Ticker Bar */}
<div className="ticker-bar">
<div className="ticker-track">
{[0, 1].map((groupIdx) => (
<div key={groupIdx} className="ticker-group">
{tickers.map(ticker => (
<div key={`${ticker.symbol}-${groupIdx}`} className="ticker-item">
<StockLogo ticker={ticker.symbol} size={16} />
<span className="ticker-symbol">{ticker.symbol}</span>
<span className="ticker-price">
<span className={`ticker-price-value ${rollingTickers[ticker.symbol] ? 'rolling' : ''}`}>
{ticker.price !== null && ticker.price !== undefined
? `$${formatTickerPrice(ticker.price)}`
: '-'}
</span>
</span>
<span className={`ticker-change ${
ticker.change === null || ticker.change === undefined
? ''
: ticker.change >= 0 ? 'positive' : 'negative'
}`}>
{ticker.change !== null && ticker.change !== undefined
? `${ticker.change >= 0 ? '+' : ''}${ticker.change.toFixed(2)}%`
: '-'}
</span>
</div>
))}
</div>
))}
</div>
<div className="portfolio-value">
<span className="portfolio-label">投资组合</span>
<span className="portfolio-amount">${formatNumber(portfolioData.netValue)}</span>
</div>
</div>
<div className="main-container" ref={containerRef}>
{/* Left Panel: Three-View Toggle (Room/Chart/Statistics) */}
<div className="left-panel" style={{ width: `${leftWidth}%` }}>
<div className="chart-section">
<div className="view-container">
<div className="view-nav-bar">
<button
className={`view-nav-btn ${currentView === 'rules' ? 'active' : ''}`}
onClick={() => setCurrentView('rules')}
>
规则
</button>
<button
className={`view-nav-btn ${currentView === 'room' ? 'active' : ''}`}
onClick={() => setCurrentView('room')}
>
交易室
</button>
<button
className={`view-nav-btn ${currentView === 'chart' ? 'active' : ''}`}
onClick={() => setCurrentView('chart')}
>
业绩图表
</button>
<button
className={`view-nav-btn ${currentView === 'statistics' ? 'active' : ''}`}
onClick={() => setCurrentView('statistics')}
>
统计
</button>
</div>
{/* Slider container with four views */}
<div className={`view-slider-four ${currentView === 'rules' ? 'show-rules' : currentView === 'room' ? 'show-room' : currentView === 'statistics' ? 'show-statistics' : 'show-chart'} ${!isInitialAnimating ? 'normal-speed' : ''}`}>
{/* Rules View Panel */}
<div className="view-panel">
<RulesView />
</div>
{/* Room View Panel */}
<div className="view-panel">
<RoomView
bubbles={bubbles}
bubbleFor={bubbleFor}
leaderboard={leaderboard}
feed={feed}
onJumpToMessage={handleJumpToMessage}
/>
</div>
{/* Chart View Panel */}
<div className="view-panel">
<div className="chart-container">
{/* Floating Timeframe Tabs */}
<div className="chart-tabs-floating">
<button
className={`chart-tab ${chartTab === 'all' ? 'active' : ''}`}
onClick={() => setChartTab('all')}
>
日线
</button>
{/* <button
className={`chart-tab ${chartTab === 'live' ? 'active' : ''} ${!isLiveEnabled ? 'disabled' : ''}`}
onClick={() => isLiveEnabled && setChartTab('live')}
disabled={!isLiveEnabled}
title={!isLiveEnabled ? 'Live chart available during market hours only' : ''}
>
LIVE
</button> */}
</div>
<NetValueChart
equity={portfolioData.equity}
baseline={portfolioData.baseline}
baseline_vw={portfolioData.baseline_vw}
momentum={portfolioData.momentum}
strategies={portfolioData.strategies}
equity_return={portfolioData.equity_return}
baseline_return={portfolioData.baseline_return}
baseline_vw_return={portfolioData.baseline_vw_return}
momentum_return={portfolioData.momentum_return}
chartTab={chartTab}
virtualTime={virtualTime}
/>
</div>
</div>
{/* Statistics View Panel */}
<div className="view-panel">
<StatisticsView
trades={trades}
holdings={holdings}
stats={stats}
baseline_vw={portfolioData.baseline_vw}
equity={portfolioData.equity}
leaderboard={leaderboard}
/>
</div>
</div>
</div>
</div>
</div>
{/* Resizer */}
<div
className={`resizer ${isResizing ? 'resizing' : ''}`}
onMouseDown={handleMouseDown}
/>
{/* Right Panel: Agent Feed */}
<div className="right-panel" style={{ width: `${100 - leftWidth}%` }}>
<AgentFeed ref={agentFeedRef} feed={feed} leaderboard={leaderboard} />
</div>
</div>
</>
{/* About Modal */}
{showAboutModal && <AboutModal onClose={() => setShowAboutModal(false)} />}
</div>
);
}