Add explain analysis workflow and UI

This commit is contained in:
2026-03-16 22:28:41 +08:00
parent 3a5558b576
commit 1f5ee3698e
49 changed files with 8888 additions and 1476 deletions

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState, useCallback } from "react";
import React, { Suspense, lazy, useEffect, useMemo, useRef, useState, useCallback } from "react";
// Configuration and constants
import { AGENTS, INITIAL_TICKERS } from './config/constants';
@@ -13,19 +13,40 @@ import { useFeedProcessor } from './hooks/useFeedProcessor';
import GlobalStyles from './styles/GlobalStyles';
// Components
import RoomView from './components/RoomView';
import NetValueChart from './components/NetValueChart';
import AgentFeed from './components/AgentFeed';
import StockLogo from './components/StockLogo';
import StatisticsView from './components/StatisticsView';
import PerformanceView from './components/PerformanceView';
import StockExplainView from './components/StockExplainView.jsx';
import Header from './components/Header.jsx';
import WatchlistPanel from './components/WatchlistPanel.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'));
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
@@ -73,6 +94,7 @@ export default function LiveTradingApp() {
const [priceHistoryByTicker, setPriceHistoryByTicker] = useState({});
const [ohlcHistoryByTicker, setOhlcHistoryByTicker] = useState({});
const [explainEventsByTicker, setExplainEventsByTicker] = useState({});
const [newsByTicker, setNewsByTicker] = useState({});
const [selectedExplainSymbol, setSelectedExplainSymbol] = useState('');
const [historySourceByTicker, setHistorySourceByTicker] = useState({});
@@ -237,35 +259,59 @@ export default function LiveTradingApp() {
const raw = typeof marketStatus.status_text === 'string' ? marketStatus.status_text.trim() : '';
const normalized = raw.toLowerCase();
if (normalized === 'market closed (non-trading day)') {
return '休市';
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 (normalized === 'market open') {
return '开盘';
if (marketStatus.status && byStatus[marketStatus.status]) {
return byStatus[marketStatus.status];
}
if (normalized === 'market closed') {
return '收盘';
}
return raw || (marketStatus.status === 'open' ? '开盘' : '收盘');
return raw || '状态未知';
}, [marketStatus]);
const priceSourceLabel = useMemo(() => {
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();
const labels = {
yfinance: '数据源 Yahoo',
finnhub: '数据源 Finnhub',
financial_datasets: '数据源 Financial Datasets',
local_csv: '数据源 CSV'
};
return labels[normalized] || `数据源 ${String(source).trim()}`;
}, [dataSources]);
return `历史 ${providerLabelMap[normalized] || String(source).trim()}`;
}, [dataSources, providerLabelMap]);
const parseWatchlistInput = useCallback((value) => {
if (typeof value !== 'string') {
@@ -413,6 +459,131 @@ export default function LiveTradingApp() {
});
}, []);
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') {
@@ -439,7 +610,21 @@ export default function LiveTradingApp() {
}
requestStockHistory(selectedExplainSymbol);
requestStockExplainEvents(selectedExplainSymbol);
}, [currentView, requestStockExplainEvents, requestStockHistory, 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(() => {
@@ -802,6 +987,193 @@ export default function LiveTradingApp() {
}));
},
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 {
@@ -1146,7 +1518,14 @@ export default function LiveTradingApp() {
clientRef.current.disconnect();
}
};
}, [addSystemMessage, buildTickersFromSymbols, processFeedEvent, processHistoricalFeed]); // Only reconnect if handlers change
}, [
addSystemMessage,
buildTickersFromSymbols,
processFeedEvent,
processHistoricalFeed,
requestStockNewsCategories,
requestStockNewsTimeline
]); // Only reconnect if handlers change
// Resizing handlers
const handleMouseDown = (e) => {
@@ -1318,16 +1697,24 @@ export default function LiveTradingApp() {
</span>
</>
)}
{priceSourceLabel && (
{livePriceSourceLabel && (
<>
<span className="status-sep">·</span>
<span className="market-text backtest">
{priceSourceLabel}
{livePriceSourceLabel}
</span>
</>
)}
{historicalPriceSourceLabel && (
<>
<span className="status-sep">·</span>
<span className="market-text backtest">
{historicalPriceSourceLabel}
</span>
</>
)}
<span className="status-sep">·</span>
<span className="time-text">{lastUpdate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}</span>
<span className="time-text">{now.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}</span>
</div>
<WatchlistPanel
@@ -1407,7 +1794,7 @@ export default function LiveTradingApp() {
className={`view-nav-btn ${currentView === 'explain' ? 'active' : ''}`}
onClick={() => setCurrentView('explain')}
>
个股解释
个股分析
</button>
<button
@@ -1437,30 +1824,41 @@ export default function LiveTradingApp() {
} ${!isInitialAnimating ? 'normal-speed' : ''}`}>
{/* Room View Panel */}
<div className="view-panel">
<RoomView
bubbles={bubbles}
bubbleFor={bubbleFor}
leaderboard={leaderboard}
feed={feed}
onJumpToMessage={handleJumpToMessage}
/>
<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">
<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}
/>
<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 */}
@@ -1502,14 +1900,16 @@ export default function LiveTradingApp() {
{/* Statistics View Panel */}
<div className="view-panel">
<StatisticsView
trades={trades}
holdings={holdings}
stats={stats}
baseline_vw={portfolioData.baseline_vw}
equity={portfolioData.equity}
leaderboard={leaderboard}
/>
<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>
@@ -1524,7 +1924,9 @@ export default function LiveTradingApp() {
{/* Right Panel: Agent Feed */}
<div className="right-panel" style={{ width: `${100 - leftWidth}%` }}>
<AgentFeed ref={agentFeedRef} feed={feed} leaderboard={leaderboard} />
<Suspense fallback={<ViewLoadingFallback label="加载消息流..." />}>
<AgentFeed ref={agentFeedRef} feed={feed} leaderboard={leaderboard} />
</Suspense>
</div>
</div>
</>