import React, { Suspense, lazy, 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 NetValueChart from './components/NetValueChart'; import StockLogo from './components/StockLogo'; import Header from './components/Header.jsx'; import WatchlistPanel from './components/WatchlistPanel.jsx'; import RuntimeSettingsPanel from './components/RuntimeSettingsPanel.jsx'; // Utils import { formatNumber, formatTickerPrice } from './utils/formatters'; const RoomView = lazy(() => import('./components/RoomView')); const AgentFeed = lazy(() => import('./components/AgentFeed')); const StatisticsView = lazy(() => import('./components/StatisticsView')); const StockExplainView = lazy(() => import('./components/StockExplainView.jsx')); const TraderView = lazy(() => import('./components/TraderView.jsx')); const EDITABLE_AGENT_WORKSPACE_FILES = ['SOUL.md', 'PROFILE.md', 'AGENTS.md', 'MEMORY.md', 'POLICY.md', 'HEARTBEAT.md', 'ROLE.md', 'STYLE.md']; function ViewLoadingFallback({ label = '加载中...' }) { return (
{label}
); } /** * 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()); // View toggle: 'traders' | 'room' | 'explain' | 'chart' | 'statistics' const [currentView, setCurrentView] = useState('traders'); 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({}); const [priceHistoryByTicker, setPriceHistoryByTicker] = useState({}); const [ohlcHistoryByTicker, setOhlcHistoryByTicker] = useState({}); const [explainEventsByTicker, setExplainEventsByTicker] = useState({}); const [newsByTicker, setNewsByTicker] = useState({}); const [selectedExplainSymbol, setSelectedExplainSymbol] = useState(''); const [historySourceByTicker, setHistorySourceByTicker] = 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 [runtimeConfig, setRuntimeConfig] = useState(null); const [isWatchlistPanelOpen, setIsWatchlistPanelOpen] = useState(false); const [isRuntimeSettingsOpen, setIsRuntimeSettingsOpen] = useState(false); const [watchlistDraftSymbols, setWatchlistDraftSymbols] = useState([]); const [watchlistInputValue, setWatchlistInputValue] = useState(''); const [watchlistFeedback, setWatchlistFeedback] = useState(null); const [isWatchlistSaving, setIsWatchlistSaving] = useState(false); const [scheduleModeDraft, setScheduleModeDraft] = useState('daily'); const [intervalMinutesDraft, setIntervalMinutesDraft] = useState('60'); const [triggerTimeDraft, setTriggerTimeDraft] = useState('09:30'); const [maxCommCyclesDraft, setMaxCommCyclesDraft] = useState('2'); const [runtimeConfigFeedback, setRuntimeConfigFeedback] = useState(null); const [isRuntimeConfigSaving, setIsRuntimeConfigSaving] = useState(false); const [selectedSkillAgentId, setSelectedSkillAgentId] = useState(AGENTS[0]?.id || 'portfolio_manager'); const [agentProfilesByAgent, setAgentProfilesByAgent] = useState({}); const [agentSkillsByAgent, setAgentSkillsByAgent] = useState({}); const [skillDetailsByName, setSkillDetailsByName] = useState({}); const [localSkillDraftsByKey, setLocalSkillDraftsByKey] = useState({}); const [isAgentSkillsLoading, setIsAgentSkillsLoading] = useState(false); const [skillDetailLoadingKey, setSkillDetailLoadingKey] = useState(null); const [agentSkillsSavingKey, setAgentSkillsSavingKey] = useState(null); const [agentSkillsFeedback, setAgentSkillsFeedback] = useState(null); const [selectedWorkspaceFile, setSelectedWorkspaceFile] = useState(EDITABLE_AGENT_WORKSPACE_FILES[0]); const [workspaceFilesByAgent, setWorkspaceFilesByAgent] = useState({}); const [workspaceDraftContent, setWorkspaceDraftContent] = useState(''); const [isWorkspaceFileLoading, setIsWorkspaceFileLoading] = useState(false); const [workspaceFileSavingKey, setWorkspaceFileSavingKey] = useState(null); const [workspaceFileFeedback, setWorkspaceFileFeedback] = useState(null); const clientRef = useRef(null); const containerRef = useRef(null); const agentFeedRef = useRef(null); const isWatchlistSavingRef = useRef(false); const isRuntimeConfigSavingRef = useRef(false); const requestedStockHistoryRef = useRef(new Set()); // 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([]); const buildTickersFromSymbols = useCallback((symbols, previousTickers = []) => { if (!Array.isArray(symbols) || symbols.length === 0) { return previousTickers; } return symbols .filter((symbol) => typeof symbol === 'string' && symbol.trim()) .map((symbol) => { const normalized = symbol.trim().toUpperCase(); const existing = previousTickers.find((ticker) => ticker.symbol === normalized); return existing || { symbol: normalized, price: null, change: null }; }); }, []); const normalizePriceHistory = useCallback((payload) => { if (!payload || typeof payload !== 'object') { return {}; } const normalized = {}; Object.entries(payload).forEach(([symbol, points]) => { const ticker = String(symbol || '').trim().toUpperCase(); if (!ticker || !Array.isArray(points)) { return; } normalized[ticker] = points .map((point) => { if (Array.isArray(point) && point.length >= 2) { const [label, value] = point; const price = Number(value); if (!label || !Number.isFinite(price)) return null; return { timestamp: String(label), label: String(label), price }; } if (point && typeof point === 'object') { const rawTimestamp = point.timestamp ?? point.t ?? point.date ?? point.label; const price = Number(point.price ?? point.v ?? point.value ?? point.close); if (!rawTimestamp || !Number.isFinite(price)) return null; return { timestamp: String(rawTimestamp), label: String(rawTimestamp), price }; } return null; }) .filter(Boolean) .slice(-120); }); return normalized; }, []); // Determine if LIVE tab should be enabled const isLiveEnabled = useMemo(() => { if (!marketStatus) return false; return marketStatus.status === 'open'; }, [marketStatus]); const displayTickers = useMemo(() => { const symbols = runtimeConfig?.tickers; if (Array.isArray(symbols) && symbols.length > 0) { return buildTickersFromSymbols(symbols, tickers); } return tickers; }, [buildTickersFromSymbols, runtimeConfig, tickers]); const runtimeWatchlistSymbols = useMemo(() => { const symbols = runtimeConfig?.tickers; if (Array.isArray(symbols) && symbols.length > 0) { return symbols .filter((symbol) => typeof symbol === 'string' && symbol.trim()) .map((symbol) => symbol.trim().toUpperCase()); } return displayTickers .map((ticker) => ticker.symbol) .filter((symbol) => typeof symbol === 'string' && symbol.trim()); }, [displayTickers, runtimeConfig]); const runtimeSummaryLabel = useMemo(() => { if (!runtimeConfig) { return null; } const scheduleMode = String(runtimeConfig.schedule_mode || 'daily'); const intervalMinutes = Number(runtimeConfig.interval_minutes || 60); const triggerTime = String(runtimeConfig.trigger_time || '09:30'); const maxCommCycles = Number(runtimeConfig.max_comm_cycles || 2); if (scheduleMode === 'intraday') { return `调度 intraday / ${intervalMinutes}m / 讨论 ${maxCommCycles} 轮`; } return `调度 daily / ${triggerTime} ET / 讨论 ${maxCommCycles} 轮`; }, [runtimeConfig]); const selectedAgentSkills = useMemo( () => agentSkillsByAgent[selectedSkillAgentId] || [], [agentSkillsByAgent, selectedSkillAgentId] ); const selectedAgentProfile = useMemo( () => agentProfilesByAgent[selectedSkillAgentId] || null, [agentProfilesByAgent, selectedSkillAgentId] ); const selectedWorkspaceContent = useMemo( () => workspaceFilesByAgent[selectedSkillAgentId]?.[selectedWorkspaceFile] || '', [selectedSkillAgentId, selectedWorkspaceFile, workspaceFilesByAgent] ); useEffect(() => { const symbols = displayTickers .map((ticker) => ticker.symbol) .filter((symbol) => typeof symbol === 'string' && symbol.trim()); if (!symbols.length) { setSelectedExplainSymbol(''); return; } if (!selectedExplainSymbol || !symbols.includes(selectedExplainSymbol)) { setSelectedExplainSymbol(symbols[0]); } }, [displayTickers, selectedExplainSymbol]); useEffect(() => { if (!runtimeConfig) { return; } setScheduleModeDraft(String(runtimeConfig.schedule_mode || 'daily')); setIntervalMinutesDraft(String(runtimeConfig.interval_minutes || 60)); setTriggerTimeDraft(String(runtimeConfig.trigger_time || '09:30')); setMaxCommCyclesDraft(String(runtimeConfig.max_comm_cycles || 2)); }, [runtimeConfig]); const watchlistSuggestions = useMemo( () => INITIAL_TICKERS.map((ticker) => ticker.symbol).filter((symbol, index, list) => list.indexOf(symbol) === index), [] ); const isWatchlistDraftDirty = useMemo(() => { if (watchlistInputValue.trim()) { return true; } if (watchlistDraftSymbols.length !== runtimeWatchlistSymbols.length) { return true; } return watchlistDraftSymbols.some((symbol, index) => symbol !== runtimeWatchlistSymbols[index]); }, [runtimeWatchlistSymbols, watchlistDraftSymbols, watchlistInputValue]); const marketStatusLabel = useMemo(() => { if (!marketStatus) { return null; } const raw = typeof marketStatus.status_text === 'string' ? marketStatus.status_text.trim() : ''; const normalized = raw.toLowerCase(); const byStatus = { open: '开盘', closed: '休市', premarket: '盘前', afterhours: '盘后', }; const byText = { 'market closed (non-trading day)': '休市', 'market open': '开盘', 'market closed': '收盘', 'pre-market': '盘前', 'after-hours': '盘后', 'after hours': '盘后', 'backtest mode': '回测模式', }; if (normalized && byText[normalized]) { return byText[normalized]; } if (marketStatus.status && byStatus[marketStatus.status]) { return byStatus[marketStatus.status]; } return raw || '状态未知'; }, [marketStatus]); const providerLabelMap = useMemo(() => ({ yfinance: 'YFinance', finnhub: 'Finnhub', financial_datasets: 'Financial Datasets', local_csv: 'CSV', polygon: 'Polygon', mock: 'Mock', backtest: 'Backtest' }), []); const livePriceSourceLabel = useMemo(() => { const source = marketStatus?.live_quote_provider; if (!source) { return null; } const normalized = String(source).trim().toLowerCase(); return `实时 ${providerLabelMap[normalized] || String(source).trim()}`; }, [marketStatus, providerLabelMap]); const historicalPriceSourceLabel = useMemo(() => { const source = dataSources?.last_success?.prices; if (!source) { return null; } const normalized = String(source).trim().toLowerCase(); return `历史 ${providerLabelMap[normalized] || String(source).trim()}`; }, [dataSources, providerLabelMap]); const parseWatchlistInput = useCallback((value) => { if (typeof value !== 'string') { return []; } return Array.from( new Set( value .split(/[\s,]+/) .map((symbol) => symbol.trim().toUpperCase()) .filter(Boolean) ) ); }, []); const commitWatchlistInput = useCallback((value) => { const parsed = parseWatchlistInput(value); if (parsed.length === 0) { return []; } setWatchlistDraftSymbols((prev) => Array.from(new Set([...prev, ...parsed]))); setWatchlistInputValue(''); if (watchlistFeedback) { setWatchlistFeedback(null); } return parsed; }, [parseWatchlistInput, watchlistFeedback]); const handleWatchlistRemove = useCallback((symbolToRemove) => { setWatchlistDraftSymbols((prev) => prev.filter((symbol) => symbol !== symbolToRemove)); if (watchlistFeedback) { setWatchlistFeedback(null); } }, [watchlistFeedback]); const handleWatchlistPanelToggle = useCallback(() => { setIsRuntimeSettingsOpen(false); setIsWatchlistPanelOpen((open) => { const nextOpen = !open; if (nextOpen) { setWatchlistDraftSymbols(runtimeWatchlistSymbols); setWatchlistInputValue(''); setWatchlistFeedback(null); } return nextOpen; }); }, [runtimeWatchlistSymbols]); const handleWatchlistInputChange = useCallback((value) => { setWatchlistInputValue(value); if (watchlistFeedback) { setWatchlistFeedback(null); } }, [watchlistFeedback]); const handleWatchlistInputKeyDown = useCallback((e) => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); commitWatchlistInput(watchlistInputValue); } }, [commitWatchlistInput, watchlistInputValue]); const handleWatchlistSuggestionClick = useCallback((symbol) => { if (watchlistDraftSymbols.includes(symbol)) { return; } setWatchlistDraftSymbols((prev) => [...prev, symbol]); if (watchlistFeedback) { setWatchlistFeedback(null); } }, [watchlistDraftSymbols, watchlistFeedback]); const handleWatchlistRestoreCurrent = useCallback(() => { setWatchlistDraftSymbols(runtimeWatchlistSymbols); setWatchlistInputValue(''); setWatchlistFeedback(null); }, [runtimeWatchlistSymbols]); const handleWatchlistRestoreDefault = useCallback(() => { setWatchlistDraftSymbols(watchlistSuggestions); setWatchlistInputValue(''); setWatchlistFeedback(null); }, [watchlistSuggestions]); const handleWatchlistSave = useCallback(() => { const pendingTickers = parseWatchlistInput(watchlistInputValue); const nextTickers = Array.from(new Set([...watchlistDraftSymbols, ...pendingTickers])); if (nextTickers.length === 0) { setWatchlistFeedback({ type: 'error', text: '至少输入 1 个有效股票代码' }); return; } if (!clientRef.current) { setWatchlistFeedback({ type: 'error', text: '连接未就绪,稍后重试' }); return; } setIsWatchlistSaving(true); setWatchlistFeedback(null); setWatchlistDraftSymbols(nextTickers); setWatchlistInputValue(''); const success = clientRef.current.send({ type: 'update_watchlist', tickers: nextTickers }); if (!success) { setIsWatchlistSaving(false); setWatchlistFeedback({ type: 'error', text: '发送失败,请检查连接状态' }); } }, [parseWatchlistInput, watchlistDraftSymbols, watchlistInputValue]); const handleManualTrigger = useCallback(() => { if (!clientRef.current) { addSystemMessage('连接未就绪,无法手动触发'); return; } const success = clientRef.current.send({ type: 'trigger_strategy' }); if (!success) { addSystemMessage('手动触发发送失败,请检查连接状态'); return; } addSystemMessage('已发送手动触发请求'); }, [addSystemMessage]); const handleRuntimeConfigSave = useCallback(() => { if (!clientRef.current) { setRuntimeConfigFeedback({ type: 'error', text: '连接未就绪,稍后重试' }); return; } const interval = Number(intervalMinutesDraft); const maxCommCycles = Number(maxCommCyclesDraft); if (!Number.isInteger(interval) || interval <= 0) { setRuntimeConfigFeedback({ type: 'error', text: '间隔必须是正整数分钟' }); return; } if (!Number.isInteger(maxCommCycles) || maxCommCycles <= 0) { setRuntimeConfigFeedback({ type: 'error', text: '讨论轮数必须是正整数' }); return; } setIsRuntimeConfigSaving(true); setRuntimeConfigFeedback(null); const success = clientRef.current.send({ type: 'update_runtime_config', schedule_mode: scheduleModeDraft, interval_minutes: interval, trigger_time: triggerTimeDraft, max_comm_cycles: maxCommCycles }); if (!success) { setIsRuntimeConfigSaving(false); setRuntimeConfigFeedback({ type: 'error', text: '发送失败,请检查连接状态' }); } }, [intervalMinutesDraft, maxCommCyclesDraft, scheduleModeDraft, triggerTimeDraft]); const handleRuntimeDefaultsRestore = useCallback(() => { setScheduleModeDraft('daily'); setIntervalMinutesDraft('60'); setTriggerTimeDraft('09:30'); setMaxCommCyclesDraft('2'); setRuntimeConfigFeedback(null); }, []); const handleRuntimeSettingsToggle = useCallback(() => { setRuntimeConfigFeedback(null); setAgentSkillsFeedback(null); setWorkspaceFileFeedback(null); setIsRuntimeSettingsOpen((prev) => !prev); setIsWatchlistPanelOpen(false); }, []); const requestAgentSkills = useCallback((agentId) => { const normalized = typeof agentId === 'string' ? agentId.trim() : ''; if (!normalized || !clientRef.current) { return false; } setIsAgentSkillsLoading(true); setAgentSkillsFeedback(null); return clientRef.current.send({ type: 'get_agent_skills', agent_id: normalized }); }, []); const requestAgentProfile = useCallback((agentId) => { const normalized = typeof agentId === 'string' ? agentId.trim() : ''; if (!normalized || !clientRef.current) { return false; } return clientRef.current.send({ type: 'get_agent_profile', agent_id: normalized }); }, []); const requestSkillDetail = useCallback((skillName) => { const normalized = typeof skillName === 'string' ? skillName.trim() : ''; if (!normalized || !clientRef.current) { return false; } const detailKey = `${selectedSkillAgentId}:${normalized}`; setSkillDetailLoadingKey(detailKey); return clientRef.current.send({ type: 'get_skill_detail', agent_id: selectedSkillAgentId, skill_name: normalized }); }, [selectedSkillAgentId]); const handleCreateLocalSkill = useCallback((skillName) => { const normalized = typeof skillName === 'string' ? skillName.trim() : ''; if (!normalized) { setAgentSkillsFeedback({ type: 'error', text: '技能名称不能为空' }); return; } if (!clientRef.current) { setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' }); return; } setAgentSkillsSavingKey(`${selectedSkillAgentId}:${normalized}:create`); setAgentSkillsFeedback(null); const success = clientRef.current.send({ type: 'create_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: normalized }); if (!success) { setAgentSkillsSavingKey(null); setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' }); } }, [selectedSkillAgentId]); const handleLocalSkillDraftChange = useCallback((skillName, content) => { const detailKey = `${selectedSkillAgentId}:${skillName}`; setLocalSkillDraftsByKey((prev) => ({ ...prev, [detailKey]: content })); }, [selectedSkillAgentId]); const handleLocalSkillSave = useCallback((skillName) => { if (!clientRef.current) { setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' }); return; } const detailKey = `${selectedSkillAgentId}:${skillName}`; const content = localSkillDraftsByKey[detailKey]; if (typeof content !== 'string') { return; } setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:content`); setAgentSkillsFeedback(null); const success = clientRef.current.send({ type: 'update_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: skillName, content }); if (!success) { setAgentSkillsSavingKey(null); setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' }); } }, [localSkillDraftsByKey, selectedSkillAgentId]); const handleLocalSkillDelete = useCallback((skillName) => { if (!clientRef.current) { setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' }); return; } setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:delete`); setAgentSkillsFeedback(null); const success = clientRef.current.send({ type: 'delete_agent_local_skill', agent_id: selectedSkillAgentId, skill_name: skillName }); if (!success) { setAgentSkillsSavingKey(null); setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' }); } }, [selectedSkillAgentId]); const handleRemoveSharedSkill = useCallback((skillName) => { if (!clientRef.current) { setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' }); return; } setAgentSkillsSavingKey(`${selectedSkillAgentId}:${skillName}:remove`); setAgentSkillsFeedback(null); const success = clientRef.current.send({ type: 'remove_agent_skill', agent_id: selectedSkillAgentId, skill_name: skillName }); if (!success) { setAgentSkillsSavingKey(null); setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' }); } }, [selectedSkillAgentId]); const requestWorkspaceFile = useCallback((agentId, filename) => { const normalizedAgentId = typeof agentId === 'string' ? agentId.trim() : ''; const normalizedFilename = typeof filename === 'string' ? filename.trim() : ''; if (!normalizedAgentId || !normalizedFilename || !clientRef.current) { return false; } setIsWorkspaceFileLoading(true); setWorkspaceFileFeedback(null); return clientRef.current.send({ type: 'get_agent_workspace_file', agent_id: normalizedAgentId, filename: normalizedFilename }); }, []); const handleAgentSkillToggle = useCallback((skillName, enabled) => { if (!clientRef.current) { setAgentSkillsFeedback({ type: 'error', text: '连接未就绪,稍后重试' }); return; } const agentId = selectedSkillAgentId; setAgentSkillsSavingKey(`${agentId}:${skillName}`); setAgentSkillsFeedback(null); const success = clientRef.current.send({ type: 'update_agent_skill', agent_id: agentId, skill_name: skillName, enabled }); if (!success) { setAgentSkillsSavingKey(null); setAgentSkillsFeedback({ type: 'error', text: '发送失败,请检查连接状态' }); } }, [selectedSkillAgentId]); const handleSkillAgentChange = useCallback((agentId) => { setSelectedSkillAgentId(agentId); requestAgentProfile(agentId); requestAgentSkills(agentId); requestWorkspaceFile(agentId, selectedWorkspaceFile); }, [requestAgentProfile, requestAgentSkills, requestWorkspaceFile, selectedWorkspaceFile]); const handleWorkspaceFileChange = useCallback((filename) => { setSelectedWorkspaceFile(filename); requestWorkspaceFile(selectedSkillAgentId, filename); }, [requestWorkspaceFile, selectedSkillAgentId]); const handleWorkspaceFileSave = useCallback(() => { if (!clientRef.current) { setWorkspaceFileFeedback({ type: 'error', text: '连接未就绪,稍后重试' }); return; } const key = `${selectedSkillAgentId}:${selectedWorkspaceFile}`; setWorkspaceFileSavingKey(key); setWorkspaceFileFeedback(null); const success = clientRef.current.send({ type: 'update_agent_workspace_file', agent_id: selectedSkillAgentId, filename: selectedWorkspaceFile, content: workspaceDraftContent }); if (!success) { setWorkspaceFileSavingKey(null); setWorkspaceFileFeedback({ type: 'error', text: '发送失败,请检查连接状态' }); } }, [selectedSkillAgentId, selectedWorkspaceFile, workspaceDraftContent]); useEffect(() => { setWorkspaceDraftContent(selectedWorkspaceContent); }, [selectedWorkspaceContent]); useEffect(() => { if (currentView !== 'traders' || !isConnected) { return; } AGENTS.forEach((agent) => { if (!agentProfilesByAgent[agent.id]) { requestAgentProfile(agent.id); } if (!agentSkillsByAgent[agent.id]) { requestAgentSkills(agent.id); } if (!workspaceFilesByAgent[agent.id]?.['MEMORY.md']) { requestWorkspaceFile(agent.id, 'MEMORY.md'); } }); }, [agentProfilesByAgent, agentSkillsByAgent, currentView, isConnected, requestAgentProfile, requestAgentSkills, requestWorkspaceFile, workspaceFilesByAgent]); const requestStockHistory = useCallback((symbol, { force = false } = {}) => { const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; if (!normalized || !clientRef.current) { return false; } if (!force && requestedStockHistoryRef.current.has(normalized)) { return false; } const success = clientRef.current.send({ type: 'get_stock_history', ticker: normalized, lookback_days: 120 }); if (success) { requestedStockHistoryRef.current.add(normalized); } return success; }, []); const requestStockExplainEvents = useCallback((symbol) => { const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; if (!normalized || !clientRef.current) { return false; } return clientRef.current.send({ type: 'get_stock_explain_events', ticker: normalized }); }, []); const requestStockNews = useCallback((symbol) => { const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; if (!normalized || !clientRef.current) { return false; } return clientRef.current.send({ type: 'get_stock_news', ticker: normalized, lookback_days: 45, limit: 12 }); }, []); const requestStockNewsForDate = useCallback((symbol, date) => { const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; if (!normalized || !date || !clientRef.current) { return false; } return clientRef.current.send({ type: 'get_stock_news_for_date', ticker: normalized, date, limit: 20 }); }, []); const requestStockNewsTimeline = useCallback((symbol) => { const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; if (!normalized || !clientRef.current) { return false; } return clientRef.current.send({ type: 'get_stock_news_timeline', ticker: normalized, lookback_days: 90 }); }, []); const requestStockNewsCategories = useCallback((symbol) => { const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; if (!normalized || !clientRef.current) { return false; } return clientRef.current.send({ type: 'get_stock_news_categories', ticker: normalized, lookback_days: 90 }); }, []); const requestStockRangeExplain = useCallback((symbol, startDate, endDate, articleIds = []) => { const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; if (!normalized || !startDate || !endDate || !clientRef.current) { return false; } return clientRef.current.send({ type: 'get_stock_range_explain', ticker: normalized, start_date: startDate, end_date: endDate, article_ids: Array.isArray(articleIds) ? articleIds : [] }); }, []); const requestStockStory = useCallback((symbol, asOfDate = null) => { const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; if (!normalized || !clientRef.current) { return false; } return clientRef.current.send({ type: 'get_stock_story', ticker: normalized, as_of_date: asOfDate }); }, []); const requestStockSimilarDays = useCallback((symbol, date, topK = 8) => { const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; if (!normalized || !date || !clientRef.current) { return false; } return clientRef.current.send({ type: 'get_stock_similar_days', ticker: normalized, date, top_k: topK }); }, []); const requestStockEnrich = useCallback((symbol, options = {}) => { const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : ''; if (!normalized || !clientRef.current) { return false; } const startDate = typeof options.startDate === 'string' ? options.startDate.trim() : ''; const endDate = typeof options.endDate === 'string' ? options.endDate.trim() : ''; if (!startDate || !endDate) { return false; } setNewsByTicker((prev) => ({ ...prev, [normalized]: { ...(prev[normalized] || {}), maintenanceStatus: { running: true, error: null, updatedAt: new Date().toISOString(), stats: null } } })); return clientRef.current.send({ type: 'run_stock_enrich', ticker: normalized, start_date: startDate, end_date: endDate, force: Boolean(options.force), only_local_to_llm: Boolean(options.onlyLocalToLlm), rebuild_story: Boolean(options.rebuildStory), rebuild_similar_days: Boolean(options.rebuildSimilarDays), story_date: options.storyDate || null, target_date: options.targetDate || null }); }, []); // Switch away from LIVE tab when market closes useEffect(() => { if (!isLiveEnabled && chartTab === 'live') { setChartTab('all'); } }, [isLiveEnabled, chartTab]); useEffect(() => { if (!isWatchlistPanelOpen || !isWatchlistDraftDirty) { setWatchlistDraftSymbols(runtimeWatchlistSymbols); if (!isWatchlistPanelOpen) { setWatchlistInputValue(''); } } }, [isWatchlistDraftDirty, isWatchlistPanelOpen, runtimeWatchlistSymbols]); useEffect(() => { isWatchlistSavingRef.current = isWatchlistSaving; }, [isWatchlistSaving]); useEffect(() => { isRuntimeConfigSavingRef.current = isRuntimeConfigSaving; }, [isRuntimeConfigSaving]); useEffect(() => { if (currentView !== 'explain' || !selectedExplainSymbol) { return; } requestStockHistory(selectedExplainSymbol); requestStockExplainEvents(selectedExplainSymbol); requestStockNews(selectedExplainSymbol); requestStockNewsTimeline(selectedExplainSymbol); requestStockNewsCategories(selectedExplainSymbol); requestStockStory(selectedExplainSymbol, currentDate); }, [ currentDate, currentView, requestStockExplainEvents, requestStockHistory, requestStockNews, requestStockNewsCategories, requestStockNewsTimeline, requestStockStory, selectedExplainSymbol ]); // 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 flag for slider speed useEffect(() => { const completeTimer = setTimeout(() => { setIsInitialAnimating(false); }, 1800); return () => { 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) => { const message = typeof e.message === 'string' ? e.message : '请求失败'; console.error('[Error]', message); setIsAgentSkillsLoading(false); setSkillDetailLoadingKey(null); setAgentSkillsSavingKey(null); setIsWorkspaceFileLoading(false); setWorkspaceFileSavingKey(null); if (isWatchlistSavingRef.current) { setIsWatchlistSaving(false); setWatchlistFeedback({ type: 'error', text: message || '更新 watchlist 失败' }); } if (isRuntimeConfigSavingRef.current) { setIsRuntimeConfigSaving(false); setRuntimeConfigFeedback({ type: 'error', text: message }); } if (message.includes('skill') || message.includes('agent_id')) { setAgentSkillsFeedback({ type: 'error', text: message || '更新技能失败' }); } if (message.includes('workspace_file') || message.includes('filename')) { setWorkspaceFileFeedback({ type: 'error', text: message || '更新工作区文件失败' }); } // Handle fast forward errors if (message.includes('fast forward')) { console.warn(`⚠️ ${message}`); handlePushEvent({ type: 'system', content: `⚠️ ${message}`, timestamp: Date.now() }); } addSystemMessage(message); }, // 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); } if (state.runtime_config) { setRuntimeConfig(state.runtime_config); } if (Array.isArray(state.tickers) && state.tickers.length > 0) { setTickers(prevTickers => buildTickersFromSymbols(state.tickers, prevTickers)); } // 检查是否是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); if (state.price_history) { setPriceHistoryByTicker(normalizePriceHistory(state.price_history)); } // 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); } }, runtime_assets_reloaded: (e) => { if (e.runtime_config_applied) { setRuntimeConfig(e.runtime_config_applied); } if (Array.isArray(e.runtime_config_applied?.tickers)) { setTickers(prevTickers => buildTickersFromSymbols(e.runtime_config_applied.tickers, prevTickers)); setWatchlistDraftSymbols(e.runtime_config_applied.tickers.map((symbol) => String(symbol).trim().toUpperCase())); setWatchlistInputValue(''); } if (isWatchlistSavingRef.current) { setIsWatchlistSaving(false); } if (isRuntimeConfigSavingRef.current) { setIsRuntimeConfigSaving(false); setRuntimeConfigFeedback({ type: 'success', text: '运行配置已保存并生效' }); } const warnings = Array.isArray(e.runtime_config_warnings) ? e.runtime_config_warnings : []; warnings.forEach((warning) => addSystemMessage(warning)); addSystemMessage('运行时配置已热更新'); }, agent_skills_loaded: (e) => { const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : ''; if (!agentId) { setIsAgentSkillsLoading(false); return; } setAgentSkillsByAgent((prev) => ({ ...prev, [agentId]: Array.isArray(e.skills) ? e.skills : [] })); setIsAgentSkillsLoading(false); setAgentSkillsSavingKey(null); }, agent_profile_loaded: (e) => { const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : ''; if (!agentId) { return; } setAgentProfilesByAgent((prev) => ({ ...prev, [agentId]: e.profile && typeof e.profile === 'object' ? e.profile : {} })); }, skill_detail_loaded: (e) => { const skillName = typeof e.skill?.skill_name === 'string' ? e.skill.skill_name.trim() : ''; const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : selectedSkillAgentId; if (!skillName) { setSkillDetailLoadingKey(null); return; } const detailKey = `${agentId}:${skillName}`; setSkillDetailsByName((prev) => ({ ...prev, [detailKey]: e.skill })); setLocalSkillDraftsByKey((prev) => ({ ...prev, [detailKey]: typeof e.skill?.content === 'string' ? e.skill.content : '' })); setSkillDetailLoadingKey(null); }, agent_skill_updated: (e) => { const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : ''; const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : ''; if (!agentId || !skillName) { return; } setAgentSkillsFeedback({ type: 'success', text: `${agentId} ${e.enabled ? '已启用' : '已禁用'} ${skillName}` }); }, agent_local_skill_created: (e) => { const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : ''; const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : ''; setAgentSkillsSavingKey(null); if (!agentId || !skillName) { return; } setAgentSkillsFeedback({ type: 'success', text: `${agentId} 已创建本地技能 ${skillName}` }); }, agent_local_skill_updated: (e) => { const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : ''; const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : ''; setAgentSkillsSavingKey(null); if (!agentId || !skillName) { return; } setAgentSkillsFeedback({ type: 'success', text: `${agentId} 的本地技能 ${skillName} 已保存` }); }, agent_local_skill_deleted: (e) => { const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : ''; const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : ''; setAgentSkillsSavingKey(null); if (!agentId || !skillName) { return; } setSkillDetailsByName((prev) => { const next = { ...prev }; delete next[`${agentId}:${skillName}`]; return next; }); setLocalSkillDraftsByKey((prev) => { const next = { ...prev }; delete next[`${agentId}:${skillName}`]; return next; }); setAgentSkillsFeedback({ type: 'success', text: `${agentId} 的本地技能 ${skillName} 已删除` }); }, agent_skill_removed: (e) => { const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : ''; const skillName = typeof e.skill_name === 'string' ? e.skill_name.trim() : ''; setAgentSkillsSavingKey(null); if (!agentId || !skillName) { return; } setAgentSkillsFeedback({ type: 'success', text: `${agentId} 已移除共享技能 ${skillName}` }); }, agent_workspace_file_loaded: (e) => { const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : ''; const filename = typeof e.filename === 'string' ? e.filename.trim() : ''; if (!agentId || !filename) { setIsWorkspaceFileLoading(false); return; } setWorkspaceFilesByAgent((prev) => ({ ...prev, [agentId]: { ...(prev[agentId] || {}), [filename]: typeof e.content === 'string' ? e.content : '' } })); setIsWorkspaceFileLoading(false); setWorkspaceFileSavingKey(null); }, agent_workspace_file_updated: (e) => { const agentId = typeof e.agent_id === 'string' ? e.agent_id.trim() : ''; const filename = typeof e.filename === 'string' ? e.filename.trim() : ''; if (!agentId || !filename) { return; } setWorkspaceFileFeedback({ type: 'success', text: `${agentId} 的 ${filename} 已保存` }); }, watchlist_updated: (e) => { if (Array.isArray(e.tickers)) { const normalizedTickers = e.tickers.map((symbol) => String(symbol).trim().toUpperCase()); requestedStockHistoryRef.current = new Set( Array.from(requestedStockHistoryRef.current).filter((symbol) => normalizedTickers.includes(symbol)) ); setRuntimeConfig((prev) => ({ ...(prev || {}), tickers: normalizedTickers })); setTickers((prevTickers) => buildTickersFromSymbols(normalizedTickers, prevTickers)); setWatchlistDraftSymbols(normalizedTickers); setWatchlistInputValue(''); } setIsWatchlistSaving(false); setWatchlistFeedback({ type: 'success', text: `已更新为 ${Array.isArray(e.tickers) ? e.tickers.join(', ') : '最新列表'}` }); }, stock_history_loaded: (e) => { const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : ''; if (!symbol) { return; } if (Array.isArray(e.prices)) { setOhlcHistoryByTicker((prev) => ({ ...prev, [symbol]: e.prices })); setHistorySourceByTicker((prev) => ({ ...prev, [symbol]: e.source || null })); } }, stock_explain_events_loaded: (e) => { const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : ''; if (!symbol) { return; } setExplainEventsByTicker((prev) => ({ ...prev, [symbol]: { events: Array.isArray(e.events) ? e.events : [], signals: Array.isArray(e.signals) ? e.signals : [], trades: Array.isArray(e.trades) ? e.trades : [] } })); }, stock_news_loaded: (e) => { const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : ''; if (!symbol) { return; } setNewsByTicker((prev) => ({ ...prev, [symbol]: { ...(prev[symbol] || {}), items: Array.isArray(e.news) ? e.news : [], source: e.source || null, startDate: e.start_date || null, endDate: e.end_date || null } })); requestStockNewsTimeline(symbol); }, stock_news_for_date_loaded: (e) => { const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : ''; const date = typeof e.date === 'string' ? e.date.trim() : ''; if (!symbol || !date) { return; } setNewsByTicker((prev) => ({ ...prev, [symbol]: { ...(prev[symbol] || {}), byDate: { ...((prev[symbol] && prev[symbol].byDate) || {}), [date]: Array.isArray(e.news) ? e.news : [] } } })); }, stock_news_timeline_loaded: (e) => { const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : ''; if (!symbol) { return; } setNewsByTicker((prev) => ({ ...prev, [symbol]: { ...(prev[symbol] || {}), timeline: Array.isArray(e.timeline) ? e.timeline : [], timelineStartDate: e.start_date || null, timelineEndDate: e.end_date || null } })); }, stock_news_categories_loaded: (e) => { const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : ''; if (!symbol) { return; } setNewsByTicker((prev) => ({ ...prev, [symbol]: { ...(prev[symbol] || {}), categories: e.categories || {}, categoriesStartDate: e.start_date || null, categoriesEndDate: e.end_date || null } })); }, stock_range_explain_loaded: (e) => { const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : ''; if (!symbol) { return; } const result = e.result && typeof e.result === 'object' ? e.result : null; if (!result?.start_date || !result?.end_date) { return; } const cacheKey = `${result.start_date}:${result.end_date}`; setNewsByTicker((prev) => ({ ...prev, [symbol]: { ...(prev[symbol] || {}), rangeExplainCache: { ...((prev[symbol] && prev[symbol].rangeExplainCache) || {}), [cacheKey]: result } } })); }, stock_story_loaded: (e) => { const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : ''; const asOfDate = typeof e.as_of_date === 'string' ? e.as_of_date.trim() : ''; if (!symbol || !asOfDate) { return; } setNewsByTicker((prev) => ({ ...prev, [symbol]: { ...(prev[symbol] || {}), storyCache: { ...((prev[symbol] && prev[symbol].storyCache) || {}), [asOfDate]: { story: e.story || '', source: e.source || null, asOfDate } } } })); }, stock_similar_days_loaded: (e) => { const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : ''; const date = typeof e.target_date === 'string' ? e.target_date.trim() : typeof e.date === 'string' ? e.date.trim() : ''; if (!symbol || !date) { return; } setNewsByTicker((prev) => ({ ...prev, [symbol]: { ...(prev[symbol] || {}), similarDaysCache: { ...((prev[symbol] && prev[symbol].similarDaysCache) || {}), [date]: { target_features: e.target_features || {}, items: Array.isArray(e.items) ? e.items : [], error: e.error || null } } } })); }, stock_enrich_completed: (e) => { const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : ''; if (!symbol) { return; } const completedAt = new Date().toISOString(); const historyEntry = { timestamp: completedAt, startDate: e.start_date || '', endDate: e.end_date || '', force: Boolean(e.force), onlyLocalToLlm: Boolean(e.only_local_to_llm), error: e.error || null, stats: e.stats || null, storyStatus: e.story_status || null, similarStatus: e.similar_status || null }; setNewsByTicker((prev) => ({ ...prev, [symbol]: { ...(prev[symbol] || {}), items: [], byDate: {}, timeline: [], categories: {}, rangeExplainCache: {}, storyCache: {}, similarDaysCache: {}, maintenanceStatus: { running: false, error: e.error || null, updatedAt: completedAt, stats: e.stats || null, storyStatus: e.story_status || null, similarStatus: e.similar_status || null }, maintenanceHistory: [ historyEntry, ...(((prev[symbol] && prev[symbol].maintenanceHistory) || []).slice(0, 7)) ] } })); if (!e.error) { requestStockNews(symbol); requestStockNewsTimeline(symbol); requestStockNewsCategories(symbol); } }, // 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'}%)`); setPriceHistoryByTicker((prev) => { const ticker = String(symbol).trim().toUpperCase(); const nextPoint = { timestamp: new Date().toISOString(), label: now.toISOString(), price: Number(price) }; const existing = Array.isArray(prev[ticker]) ? prev[ticker] : []; const lastPoint = existing[existing.length - 1]; if (lastPoint && Number(lastPoint.price) === Number(nextPoint.price)) { return prev; } return { ...prev, [ticker]: [...existing, nextPoint].slice(-120) }; }); // 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(); } }; }, [ addSystemMessage, buildTickersFromSymbols, processFeedEvent, processHistoricalFeed, requestStockNewsCategories, requestStockNewsTimeline ]); // Only reconnect if handlers change // 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 (
{/* Header */}
{/* Mock Mode Indicator */} {virtualTime && (
模拟模式
)} {/* Clock Display (only in Mock mode) */} {virtualTime && (
虚拟时间 {now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })} {now.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
{/* Fast Forward Button (only in Mock mode) */}
)} {/* Unified Status Indicator */}
{isConnected ? (isUpdating ? '同步中' : '在线') : '离线'} {marketStatus && ( <> · {marketStatusLabel} )} {livePriceSourceLabel && ( <> · {livePriceSourceLabel} )} {historicalPriceSourceLabel && ( <> · {historicalPriceSourceLabel} )} {runtimeSummaryLabel && ( <> · {runtimeSummaryLabel} )} · {now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}
{serverMode !== 'backtest' && ( )} setIsWatchlistPanelOpen(false)} onInputChange={handleWatchlistInputChange} onInputKeyDown={handleWatchlistInputKeyDown} onAdd={() => commitWatchlistInput(watchlistInputValue)} onRemove={handleWatchlistRemove} onRestoreCurrent={handleWatchlistRestoreCurrent} onRestoreDefault={handleWatchlistRestoreDefault} onSuggestionClick={handleWatchlistSuggestionClick} onSave={handleWatchlistSave} /> setIsRuntimeSettingsOpen(false)} onScheduleModeChange={setScheduleModeDraft} onIntervalMinutesChange={setIntervalMinutesDraft} onTriggerTimeChange={setTriggerTimeDraft} onMaxCommCyclesChange={setMaxCommCyclesDraft} onSave={handleRuntimeConfigSave} onRestoreDefaults={handleRuntimeDefaultsRestore} />
{/* Main Content */} <> {/* Ticker Bar */}
{[0, 1].map((groupIdx) => (
{displayTickers.map(ticker => (
{ticker.symbol} {ticker.price !== null && ticker.price !== undefined ? `$${formatTickerPrice(ticker.price)}` : '-'} = 0 ? 'positive' : 'negative' }`}> {ticker.change !== null && ticker.change !== undefined ? `${ticker.change >= 0 ? '+' : ''}${ticker.change.toFixed(2)}%` : '-'}
))}
))}
投资组合 ${formatNumber(portfolioData.netValue)}
{/* Left Panel: Three-View Toggle (Room/Chart/Statistics) */}
}>
{/* Room View Panel */}
}>
{/* Stock Explain View Panel */}
}>
{/* Chart View Panel */}
{/* Floating Timeframe Tabs */}
{/* */}
{/* Statistics View Panel */}
}>
{/* Resizer */}
{/* Right Panel: Agent Feed */}
}>
); }