1068 lines
38 KiB
JavaScript
1068 lines
38 KiB
JavaScript
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>
|
||
);
|
||
}
|