2559 lines
91 KiB
JavaScript
2559 lines
91 KiB
JavaScript
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 (
|
||
<div
|
||
style={{
|
||
minHeight: 240,
|
||
height: '100%',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
border: '1px solid #000000',
|
||
background: '#ffffff',
|
||
fontSize: 12,
|
||
fontWeight: 700,
|
||
letterSpacing: 0.4
|
||
}}
|
||
>
|
||
{label}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<div className="app">
|
||
<GlobalStyles />
|
||
|
||
{/* Header */}
|
||
<div className="header">
|
||
<Header />
|
||
|
||
<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')}`}>
|
||
{marketStatusLabel}
|
||
</span>
|
||
</>
|
||
)}
|
||
{livePriceSourceLabel && (
|
||
<>
|
||
<span className="status-sep">·</span>
|
||
<span className="market-text backtest">
|
||
{livePriceSourceLabel}
|
||
</span>
|
||
</>
|
||
)}
|
||
{historicalPriceSourceLabel && (
|
||
<>
|
||
<span className="status-sep">·</span>
|
||
<span className="market-text backtest">
|
||
{historicalPriceSourceLabel}
|
||
</span>
|
||
</>
|
||
)}
|
||
{runtimeSummaryLabel && (
|
||
<>
|
||
<span className="status-sep">·</span>
|
||
<span className="market-text backtest" title="当前运行配置">
|
||
{runtimeSummaryLabel}
|
||
</span>
|
||
</>
|
||
)}
|
||
<span className="status-sep">·</span>
|
||
<span className="time-text">{now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}</span>
|
||
</div>
|
||
|
||
{serverMode !== 'backtest' && (
|
||
<button
|
||
onClick={handleManualTrigger}
|
||
disabled={!isConnected}
|
||
style={{
|
||
padding: '6px 12px',
|
||
borderRadius: 4,
|
||
background: isConnected ? '#111111' : '#8a8a8a',
|
||
border: '1px solid #111111',
|
||
color: '#FFFFFF',
|
||
fontSize: '11px',
|
||
fontFamily: '"Courier New", monospace',
|
||
fontWeight: 700,
|
||
cursor: isConnected ? 'pointer' : 'not-allowed',
|
||
letterSpacing: '0.4px',
|
||
textTransform: 'uppercase'
|
||
}}
|
||
title="手动触发一轮分析与交易决策"
|
||
>
|
||
手动运行
|
||
</button>
|
||
)}
|
||
|
||
<WatchlistPanel
|
||
isOpen={isWatchlistPanelOpen}
|
||
isConnected={isConnected}
|
||
isSaving={isWatchlistSaving}
|
||
draftSymbols={watchlistDraftSymbols}
|
||
inputValue={watchlistInputValue}
|
||
feedback={watchlistFeedback}
|
||
suggestions={watchlistSuggestions}
|
||
onToggle={handleWatchlistPanelToggle}
|
||
onClose={() => setIsWatchlistPanelOpen(false)}
|
||
onInputChange={handleWatchlistInputChange}
|
||
onInputKeyDown={handleWatchlistInputKeyDown}
|
||
onAdd={() => commitWatchlistInput(watchlistInputValue)}
|
||
onRemove={handleWatchlistRemove}
|
||
onRestoreCurrent={handleWatchlistRestoreCurrent}
|
||
onRestoreDefault={handleWatchlistRestoreDefault}
|
||
onSuggestionClick={handleWatchlistSuggestionClick}
|
||
onSave={handleWatchlistSave}
|
||
/>
|
||
|
||
<RuntimeSettingsPanel
|
||
isOpen={isRuntimeSettingsOpen}
|
||
isConnected={isConnected}
|
||
isSaving={isRuntimeConfigSaving}
|
||
feedback={runtimeConfigFeedback}
|
||
runtimeConfig={runtimeConfig}
|
||
scheduleMode={scheduleModeDraft}
|
||
intervalMinutes={intervalMinutesDraft}
|
||
triggerTime={triggerTimeDraft}
|
||
maxCommCycles={maxCommCyclesDraft}
|
||
onToggle={handleRuntimeSettingsToggle}
|
||
onClose={() => setIsRuntimeSettingsOpen(false)}
|
||
onScheduleModeChange={setScheduleModeDraft}
|
||
onIntervalMinutesChange={setIntervalMinutesDraft}
|
||
onTriggerTimeChange={setTriggerTimeDraft}
|
||
onMaxCommCyclesChange={setMaxCommCyclesDraft}
|
||
onSave={handleRuntimeConfigSave}
|
||
onRestoreDefaults={handleRuntimeDefaultsRestore}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Main Content */}
|
||
<>
|
||
{/* Ticker Bar */}
|
||
<div className="ticker-bar">
|
||
<div className="ticker-track">
|
||
{[0, 1].map((groupIdx) => (
|
||
<div key={groupIdx} className="ticker-group">
|
||
{displayTickers.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 === 'traders' ? 'active' : ''}`}
|
||
onClick={() => setCurrentView('traders')}
|
||
>
|
||
交易员
|
||
</button>
|
||
|
||
<button
|
||
className={`view-nav-btn ${currentView === 'room' ? 'active' : ''}`}
|
||
onClick={() => setCurrentView('room')}
|
||
>
|
||
交易室
|
||
</button>
|
||
|
||
<button
|
||
className={`view-nav-btn ${currentView === 'explain' ? 'active' : ''}`}
|
||
onClick={() => setCurrentView('explain')}
|
||
>
|
||
个股分析
|
||
</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>
|
||
|
||
<div className={`view-slider-five ${
|
||
currentView === 'traders'
|
||
? 'show-traders'
|
||
: currentView === 'room'
|
||
? 'show-room'
|
||
: currentView === 'explain'
|
||
? 'show-explain'
|
||
: currentView === 'statistics'
|
||
? 'show-statistics'
|
||
: 'show-chart'
|
||
} ${!isInitialAnimating ? 'normal-speed' : ''}`}>
|
||
<div className="view-panel">
|
||
<Suspense fallback={<ViewLoadingFallback label="加载交易员视图..." />}>
|
||
<TraderView
|
||
agents={AGENTS}
|
||
agentProfilesByAgent={agentProfilesByAgent}
|
||
agentSkillsByAgent={agentSkillsByAgent}
|
||
workspaceFilesByAgent={workspaceFilesByAgent}
|
||
selectedAgentId={selectedSkillAgentId}
|
||
selectedAgentProfile={selectedAgentProfile}
|
||
selectedAgentSkills={selectedAgentSkills}
|
||
skillDetailsByName={skillDetailsByName}
|
||
localSkillDraftsByKey={localSkillDraftsByKey}
|
||
skillDetailLoadingKey={skillDetailLoadingKey}
|
||
editableFiles={EDITABLE_AGENT_WORKSPACE_FILES}
|
||
selectedWorkspaceFile={selectedWorkspaceFile}
|
||
workspaceFileContent={selectedWorkspaceContent}
|
||
workspaceDraftContent={workspaceDraftContent}
|
||
isConnected={isConnected}
|
||
isAgentSkillsLoading={isAgentSkillsLoading}
|
||
agentSkillsSavingKey={agentSkillsSavingKey}
|
||
agentSkillsFeedback={agentSkillsFeedback}
|
||
isWorkspaceFileLoading={isWorkspaceFileLoading}
|
||
workspaceFileSavingKey={workspaceFileSavingKey}
|
||
workspaceFileFeedback={workspaceFileFeedback}
|
||
onAgentChange={handleSkillAgentChange}
|
||
onCreateLocalSkill={handleCreateLocalSkill}
|
||
onSkillDetailRequest={requestSkillDetail}
|
||
onLocalSkillDraftChange={handleLocalSkillDraftChange}
|
||
onLocalSkillDelete={handleLocalSkillDelete}
|
||
onLocalSkillSave={handleLocalSkillSave}
|
||
onRemoveSharedSkill={handleRemoveSharedSkill}
|
||
onSkillToggle={handleAgentSkillToggle}
|
||
onWorkspaceFileChange={handleWorkspaceFileChange}
|
||
onWorkspaceDraftChange={setWorkspaceDraftContent}
|
||
onWorkspaceFileSave={handleWorkspaceFileSave}
|
||
/>
|
||
</Suspense>
|
||
</div>
|
||
|
||
{/* Room View Panel */}
|
||
<div className="view-panel">
|
||
<Suspense fallback={<ViewLoadingFallback label="加载交易室..." />}>
|
||
<RoomView
|
||
bubbles={bubbles}
|
||
bubbleFor={bubbleFor}
|
||
leaderboard={leaderboard}
|
||
feed={feed}
|
||
onJumpToMessage={handleJumpToMessage}
|
||
/>
|
||
</Suspense>
|
||
</div>
|
||
|
||
{/* Stock Explain View Panel */}
|
||
<div className="view-panel">
|
||
<Suspense fallback={<ViewLoadingFallback label="加载个股分析..." />}>
|
||
<StockExplainView
|
||
tickers={displayTickers}
|
||
holdings={holdings}
|
||
trades={trades}
|
||
leaderboard={leaderboard}
|
||
feed={feed}
|
||
priceHistoryByTicker={priceHistoryByTicker}
|
||
ohlcHistoryByTicker={ohlcHistoryByTicker}
|
||
selectedSymbol={selectedExplainSymbol}
|
||
onSelectedSymbolChange={setSelectedExplainSymbol}
|
||
selectedHistorySource={historySourceByTicker[selectedExplainSymbol] || null}
|
||
explainEventsSnapshot={explainEventsByTicker[selectedExplainSymbol] || null}
|
||
newsSnapshot={newsByTicker[selectedExplainSymbol] || null}
|
||
onRequestRangeExplain={requestStockRangeExplain}
|
||
onRequestNewsForDate={requestStockNewsForDate}
|
||
onRequestStory={requestStockStory}
|
||
currentDate={currentDate}
|
||
onRequestSimilarDays={requestStockSimilarDays}
|
||
onRequestStockEnrich={requestStockEnrich}
|
||
/>
|
||
</Suspense>
|
||
</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">
|
||
<Suspense fallback={<ViewLoadingFallback label="加载统计视图..." />}>
|
||
<StatisticsView
|
||
trades={trades}
|
||
holdings={holdings}
|
||
stats={stats}
|
||
baseline_vw={portfolioData.baseline_vw}
|
||
equity={portfolioData.equity}
|
||
leaderboard={leaderboard}
|
||
/>
|
||
</Suspense>
|
||
</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}%` }}>
|
||
<Suspense fallback={<ViewLoadingFallback label="加载消息流..." />}>
|
||
<AgentFeed ref={agentFeedRef} feed={feed} leaderboard={leaderboard} />
|
||
</Suspense>
|
||
</div>
|
||
</div>
|
||
</>
|
||
</div>
|
||
);
|
||
}
|