Add explain analysis workflow and UI
This commit is contained in:
@@ -7,6 +7,8 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"preview": "vite preview",
|
||||
"preview:host": "vite preview --host"
|
||||
},
|
||||
@@ -37,6 +39,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
@@ -45,11 +48,13 @@
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"jsdom": "^29.0.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.2",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ export default function WatchlistPanel({
|
||||
onSave
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, position: 'relative' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, position: 'relative', marginLeft: -6 }}>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
@@ -36,7 +36,7 @@ export default function WatchlistPanel({
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
WATCHLIST
|
||||
自选股
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
|
||||
157
frontend/src/components/explain/ExplainEventsSection.jsx
Normal file
157
frontend/src/components/explain/ExplainEventsSection.jsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import React from 'react';
|
||||
import { formatDateTime } from '../../utils/formatters';
|
||||
|
||||
export default function ExplainEventsSection({
|
||||
explainTimeline,
|
||||
isOpen,
|
||||
onToggle,
|
||||
availableEventDates,
|
||||
selectedEventDate,
|
||||
onSelectEventDate,
|
||||
eventCategoryCounts,
|
||||
activeEventCategory,
|
||||
onSelectEventCategory,
|
||||
eventCategoryMeta,
|
||||
visibleExplainEvents,
|
||||
}) {
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">关键事件时间线</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
图上点击事件点可切换对应日期
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起关键事件' : `展开关键事件 ${explainTimeline.length}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{explainTimeline.length === 0 ? (
|
||||
<div className="empty-state">当前还没有可以串起来看的关键事件。</div>
|
||||
) : !isOpen ? (
|
||||
<div className="empty-state">关键事件默认收起,需要时再展开查看和筛选。</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 14 }}>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{availableEventDates.map((dateKey) => {
|
||||
const isActive = dateKey === selectedEventDate;
|
||||
return (
|
||||
<button
|
||||
key={dateKey}
|
||||
onClick={() => onSelectEventDate(dateKey)}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isActive ? '#111111' : '#ffffff',
|
||||
color: isActive ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{dateKey}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
{Object.entries(eventCategoryMeta)
|
||||
.filter(([key]) => (eventCategoryCounts[key] || 0) > 0 || key === 'all')
|
||||
.map(([key, meta]) => {
|
||||
const isActive = key === activeEventCategory;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onSelectEventCategory(key)}
|
||||
style={{
|
||||
border: `1px solid ${meta.color}`,
|
||||
background: isActive ? meta.color : '#ffffff',
|
||||
color: isActive ? '#ffffff' : meta.color,
|
||||
padding: '8px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{meta.label} {eventCategoryCounts[key] || 0}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{visibleExplainEvents.length === 0 ? (
|
||||
<div className="empty-state">当前日期下没有符合筛选条件的事件</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
|
||||
{visibleExplainEvents.map((event) => {
|
||||
const accent = event.tone === 'positive' ? '#00C853' : event.tone === 'negative' ? '#FF1744' : '#000000';
|
||||
const categoryMeta = eventCategoryMeta[event.category] || eventCategoryMeta.other;
|
||||
return (
|
||||
<div
|
||||
key={event.id}
|
||||
style={{
|
||||
border: '1px solid #000000',
|
||||
background: '#ffffff',
|
||||
padding: 14,
|
||||
minHeight: 180
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
padding: '2px 6px',
|
||||
border: `1px solid ${categoryMeta.color}`,
|
||||
color: categoryMeta.color,
|
||||
fontSize: 10,
|
||||
fontWeight: 700
|
||||
}}>
|
||||
{categoryMeta.label}
|
||||
</span>
|
||||
<strong style={{ fontSize: 13 }}>{event.title}</strong>
|
||||
</div>
|
||||
<span style={{ fontSize: 10, color: '#666666', whiteSpace: 'nowrap' }}>
|
||||
{formatDateTime(event.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
||||
<span style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: accent
|
||||
}} />
|
||||
<span style={{ fontSize: 10, color: '#666666', textTransform: 'uppercase', letterSpacing: 0.6 }}>
|
||||
{event.meta}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#000000', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{event.body}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
249
frontend/src/components/explain/ExplainMaintenanceSection.jsx
Normal file
249
frontend/src/components/explain/ExplainMaintenanceSection.jsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import React from 'react';
|
||||
|
||||
function toggleButtonStyle(active, accent = '#111111') {
|
||||
return {
|
||||
border: `1px solid ${accent}`,
|
||||
background: active ? accent : '#ffffff',
|
||||
color: active ? '#ffffff' : accent,
|
||||
padding: '6px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
};
|
||||
}
|
||||
|
||||
export default function ExplainMaintenanceSection({
|
||||
selectedSymbol,
|
||||
enrichStartDate,
|
||||
enrichEndDate,
|
||||
onChangeStartDate,
|
||||
onChangeEndDate,
|
||||
forceEnrich,
|
||||
onToggleForce,
|
||||
onlyLocalToLlm,
|
||||
onToggleOnlyLocalToLlm,
|
||||
rebuildStory,
|
||||
onToggleRebuildStory,
|
||||
rebuildSimilarDays,
|
||||
onToggleRebuildSimilarDays,
|
||||
isRunning,
|
||||
onRunEnrich,
|
||||
maintenanceStatus,
|
||||
maintenanceHistory,
|
||||
onSelectHistory,
|
||||
onReplayHistory,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) {
|
||||
const stats = maintenanceStatus?.stats || null;
|
||||
const summary = stats?.execution_summary || null;
|
||||
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">分析数据维护</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
当前标的 {selectedSymbol || '-'}
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起刷新工具' : '展开刷新工具'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isOpen ? (
|
||||
<div className="empty-state">刷新工具默认收起,需要时再展开重新分析数据或查看历史。</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #000000',
|
||||
background: '#ffffff',
|
||||
padding: 14,
|
||||
display: 'grid',
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
|
||||
<label style={{ display: 'grid', gap: 6, fontSize: 11, fontWeight: 700 }}>
|
||||
开始日期
|
||||
<input type="date" value={enrichStartDate} onChange={(e) => onChangeStartDate(e.target.value)} style={{ border: '1px solid #111111', padding: '8px 10px', fontFamily: 'inherit' }} />
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: 6, fontSize: 11, fontWeight: 700 }}>
|
||||
结束日期
|
||||
<input type="date" value={enrichEndDate} onChange={(e) => onChangeEndDate(e.target.value)} style={{ border: '1px solid #111111', padding: '8px 10px', fontFamily: 'inherit' }} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button onClick={onToggleForce} style={toggleButtonStyle(forceEnrich, '#b91c1c')}>
|
||||
{forceEnrich ? '覆盖已有分析' : '仅补缺失'}
|
||||
</button>
|
||||
<button onClick={onToggleOnlyLocalToLlm} style={toggleButtonStyle(onlyLocalToLlm, '#7c3aed')}>
|
||||
{onlyLocalToLlm ? '仅将规则分析升级为 LLM分析' : '不限制分析来源'}
|
||||
</button>
|
||||
<button onClick={onToggleRebuildStory} style={toggleButtonStyle(rebuildStory, '#2563eb')}>
|
||||
{rebuildStory ? '重建主线叙事' : '跳过主线叙事'}
|
||||
</button>
|
||||
<button onClick={onToggleRebuildSimilarDays} style={toggleButtonStyle(rebuildSimilarDays, '#15803d')}>
|
||||
{rebuildSimilarDays ? '重建相似交易日' : '跳过相似交易日'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={onRunEnrich}
|
||||
disabled={isRunning || !selectedSymbol || !enrichStartDate || !enrichEndDate}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isRunning ? '#d1d5db' : '#111111',
|
||||
color: '#ffffff',
|
||||
padding: '9px 14px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: isRunning ? 'wait' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{isRunning ? '执行中...' : '重新分析当前区间'}
|
||||
</button>
|
||||
{maintenanceStatus?.updatedAt ? (
|
||||
<span style={{ fontSize: 11, color: '#666666' }}>
|
||||
最近一次执行: {maintenanceStatus.updatedAt}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{maintenanceStatus?.error ? (
|
||||
<div style={{ fontSize: 11, color: '#991b1b', lineHeight: 1.7 }}>
|
||||
执行失败: {maintenanceStatus.error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{stats ? (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 10 }}>
|
||||
{[
|
||||
['新闻总数', stats.news_count],
|
||||
['待处理', stats.queued_count],
|
||||
['已分析', stats.analyzed],
|
||||
['已跳过', stats.skipped_existing_count],
|
||||
['去重数', stats.deduped_count],
|
||||
['LLM分析', stats.llm_count],
|
||||
['规则分析', stats.local_count],
|
||||
['升级数', stats.upgraded_local_to_llm_count],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} style={{ border: '1px solid #111111', padding: 10 }}>
|
||||
<div style={{ fontSize: 10, color: '#666666' }}>{label}</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700 }}>{value ?? '-'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{summary ? (
|
||||
<div style={{ border: '1px solid #111111', padding: 12, fontSize: 11, lineHeight: 1.8 }}>
|
||||
{summary.upgraded_dates?.length ? (
|
||||
<div><strong>升级日期:</strong> {summary.upgraded_dates.join(', ')}</div>
|
||||
) : null}
|
||||
{summary.remaining_local_titles?.length ? (
|
||||
<div><strong>仍为规则分析:</strong> {summary.remaining_local_titles.join(' / ')}</div>
|
||||
) : null}
|
||||
{typeof summary.skipped_non_local_count === 'number' ? (
|
||||
<div><strong>跳过非规则分析:</strong> {summary.skipped_non_local_count}</div>
|
||||
) : null}
|
||||
{typeof summary.skipped_missing_analysis_count === 'number' ? (
|
||||
<div><strong>跳过无历史分析:</strong> {summary.skipped_missing_analysis_count}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{Array.isArray(maintenanceHistory) && maintenanceHistory.length > 0 ? (
|
||||
<div style={{ border: '1px solid #111111', padding: 12, display: 'grid', gap: 8 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700 }}>最近刷新历史</div>
|
||||
{maintenanceHistory.slice(0, 5).map((item, index) => (
|
||||
<div
|
||||
key={`${item.timestamp || 'history'}-${index}`}
|
||||
style={{
|
||||
borderTop: index === 0 ? 'none' : '1px solid #e5e7eb',
|
||||
paddingTop: index === 0 ? 0 : 8,
|
||||
fontSize: 11,
|
||||
lineHeight: 1.8,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>{item.startDate || '-'}</strong> ~ <strong>{item.endDate || '-'}</strong>
|
||||
{' · '}
|
||||
{item.onlyLocalToLlm ? '规则分析→LLM分析' : item.force ? '覆盖重跑' : '补缺失'}
|
||||
{item.storyStatus ? ' · 主线叙事' : ''}
|
||||
{item.similarStatus ? ' · 相似交易日' : ''}
|
||||
</div>
|
||||
<div style={{ color: item.error ? '#991b1b' : '#4b5563' }}>
|
||||
{item.timestamp || '-'}
|
||||
{item.error
|
||||
? ` · 失败: ${item.error}`
|
||||
: ` · 已分析 ${item.stats?.analyzed ?? 0},已升级 ${item.stats?.upgraded_local_to_llm_count ?? 0}`}
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<button
|
||||
onClick={() => onSelectHistory?.(item)}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: '#ffffff',
|
||||
color: '#111111',
|
||||
padding: '4px 8px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
回填到表单
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onReplayHistory?.(item)}
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
border: '1px solid #111111',
|
||||
background: '#111111',
|
||||
color: '#ffffff',
|
||||
padding: '4px 8px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
直接重跑
|
||||
</button>
|
||||
{!item.error ? (
|
||||
<span style={{ marginLeft: 8, fontSize: 10, color: '#666666' }}>
|
||||
{item.stats?.execution_summary?.upgraded_dates?.length
|
||||
? `升级日 ${item.stats.execution_summary.upgraded_dates.join(', ')}`
|
||||
: '无升级日期摘要'}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
frontend/src/components/explain/ExplainMentionsSection.jsx
Normal file
77
frontend/src/components/explain/ExplainMentionsSection.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { formatDateTime } from '../../utils/formatters';
|
||||
|
||||
export default function ExplainMentionsSection({
|
||||
recentMentions,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) {
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">讨论提及</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
从交易讨论和分析 feed 提取
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起讨论摘录' : `展开讨论摘录 ${recentMentions.length}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recentMentions.length === 0 ? (
|
||||
<div className="empty-state">最近没有在讨论里提到这只股票。</div>
|
||||
) : !isOpen ? (
|
||||
<div className="empty-state">讨论摘录默认收起,需要时再展开查看。</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
|
||||
{recentMentions.map((message, index) => (
|
||||
<div
|
||||
key={`${message.feedId || message.id}-${index}`}
|
||||
style={{
|
||||
border: '1px solid #000000',
|
||||
background: '#fafafa',
|
||||
padding: 14,
|
||||
minHeight: 150
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 10 }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, color: '#000000' }}>{message.agent || '未知角色'}</div>
|
||||
<div style={{ fontSize: 10, color: '#666666' }}>
|
||||
{message.conferenceTitle || (message.feedType === 'conference' ? '投资讨论' : '即时消息')}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#666666', whiteSpace: 'nowrap' }}>
|
||||
{formatDateTime(message.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
lineHeight: 1.7,
|
||||
color: '#000000',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
}}>
|
||||
{String(message.content || '')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
308
frontend/src/components/explain/ExplainNewsSection.jsx
Normal file
308
frontend/src/components/explain/ExplainNewsSection.jsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import React from 'react';
|
||||
import { formatDateTime } from '../../utils/formatters';
|
||||
|
||||
function categoryLabel(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
const labels = {
|
||||
market: '市场交易',
|
||||
policy: '政策监管',
|
||||
earnings: '业绩财报',
|
||||
product_tech: '产品技术',
|
||||
competition: '竞争格局',
|
||||
management: '管理层动态',
|
||||
};
|
||||
return labels[normalized] || value || '';
|
||||
}
|
||||
|
||||
function relevanceLabel(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
const labels = {
|
||||
high: '高相关',
|
||||
medium: '中相关',
|
||||
low: '低相关',
|
||||
relevant: '高相关',
|
||||
};
|
||||
return labels[normalized] || value || '';
|
||||
}
|
||||
|
||||
function analysisSourceLabel(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'llm') return 'LLM分析';
|
||||
if (normalized === 'local') return '规则分析';
|
||||
return value || '';
|
||||
}
|
||||
|
||||
function sentimentStyle(sentiment) {
|
||||
const normalized = String(sentiment || '').trim().toLowerCase();
|
||||
if (normalized === 'positive') {
|
||||
return { border: '#16a34a', background: '#f0fdf4', color: '#166534', label: '利多' };
|
||||
}
|
||||
if (normalized === 'negative') {
|
||||
return { border: '#dc2626', background: '#fef2f2', color: '#991b1b', label: '利空' };
|
||||
}
|
||||
return { border: '#6b7280', background: '#f9fafb', color: '#4b5563', label: '中性' };
|
||||
}
|
||||
|
||||
export default function ExplainNewsSection({
|
||||
newsSnapshot,
|
||||
visibleNewsByCategory,
|
||||
visibleNews,
|
||||
activeNewsCategory,
|
||||
onSelectNewsCategory,
|
||||
activeNewsSentiment,
|
||||
onSelectNewsSentiment,
|
||||
newsCategories,
|
||||
tickerNews,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) {
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">新闻面板</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
{newsSnapshot?.source ? `最近 ${visibleNewsByCategory.length} 条 · ${newsSnapshot.source}` : `最近 ${visibleNewsByCategory.length} 条真实新闻`}
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起新闻面板' : '展开新闻面板'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isOpen ? (
|
||||
<div className="empty-state">新闻面板已收起,需要时再展开查看分类、情绪和新闻卡片。</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 14 }}>
|
||||
<button
|
||||
onClick={() => onSelectNewsCategory('all')}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: activeNewsCategory === 'all' ? '#111111' : '#ffffff',
|
||||
color: activeNewsCategory === 'all' ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
全部 {visibleNews.length}
|
||||
</button>
|
||||
{Object.entries(newsCategories)
|
||||
.filter(([, meta]) => Number(meta?.count || 0) > 0)
|
||||
.map(([key, meta]) => {
|
||||
const isActive = activeNewsCategory === key;
|
||||
const pos = Number(meta?.positive_ids?.length || 0);
|
||||
const neg = Number(meta?.negative_ids?.length || 0);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onSelectNewsCategory(key)}
|
||||
style={{
|
||||
border: '1px solid #2563eb',
|
||||
background: isActive ? '#2563eb' : '#ffffff',
|
||||
color: isActive ? '#ffffff' : '#2563eb',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{categoryLabel(meta.label || key)} {meta.count}{pos || neg ? ` · +${pos}/-${neg}` : ''}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 14 }}>
|
||||
{[
|
||||
{ key: 'all', label: '全部情绪' },
|
||||
{ key: 'positive', label: '利多' },
|
||||
{ key: 'negative', label: '利空' },
|
||||
{ key: 'neutral', label: '中性' }
|
||||
].map((item) => {
|
||||
const isActive = activeNewsSentiment === item.key;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => onSelectNewsSentiment(item.key)}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isActive ? '#111111' : '#ffffff',
|
||||
color: isActive ? '#ffffff' : '#111111',
|
||||
padding: '6px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{tickerNews.length === 0 ? (
|
||||
<div className="empty-state">当前数据源没有返回相关新闻</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: 16 }}>
|
||||
{visibleNewsByCategory.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
border: '1px solid #000000',
|
||||
background: '#ffffff',
|
||||
padding: 14,
|
||||
minHeight: 180
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const sentimentMeta = sentimentStyle(item.sentiment);
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
padding: '2px 6px',
|
||||
border: `1px solid ${sentimentMeta.border}`,
|
||||
background: sentimentMeta.background,
|
||||
color: sentimentMeta.color,
|
||||
fontSize: 10,
|
||||
fontWeight: 700
|
||||
}}>
|
||||
{sentimentMeta.label}
|
||||
</span>
|
||||
{item.relevance ? (
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
padding: '2px 6px',
|
||||
border: '1px solid #111111',
|
||||
color: '#111111',
|
||||
fontSize: 10,
|
||||
fontWeight: 700
|
||||
}}>
|
||||
{relevanceLabel(item.relevance)}
|
||||
</span>
|
||||
) : null}
|
||||
{item.analysisSource ? (
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
padding: '2px 6px',
|
||||
border: '1px solid #6b7280',
|
||||
color: '#4b5563',
|
||||
fontSize: 10,
|
||||
fontWeight: 700
|
||||
}}>
|
||||
{analysisSourceLabel(item.analysisSource)}
|
||||
</span>
|
||||
) : null}
|
||||
{item.analysisModelLabel ? (
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
padding: '2px 6px',
|
||||
border: '1px solid #9ca3af',
|
||||
color: '#374151',
|
||||
fontSize: 10,
|
||||
fontWeight: 700
|
||||
}}>
|
||||
{item.analysisModelLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{typeof item.retT0 === 'number' ? (
|
||||
<span style={{ fontSize: 10, color: item.retT0 >= 0 ? '#15803d' : '#b91c1c', fontWeight: 700 }}>
|
||||
T0 {item.retT0 >= 0 ? '+' : ''}{(item.retT0 * 100).toFixed(2)}%
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
{item.category ? (
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
padding: '2px 6px',
|
||||
border: '1px solid #111111',
|
||||
color: '#111111',
|
||||
fontSize: 10,
|
||||
fontWeight: 700
|
||||
}}>
|
||||
{categoryLabel(item.category)}
|
||||
</span>
|
||||
) : null}
|
||||
<strong style={{ fontSize: 13 }}>{item.title}</strong>
|
||||
</div>
|
||||
<span style={{ fontSize: 10, color: '#666666', whiteSpace: 'nowrap' }}>
|
||||
{formatDateTime(item.date)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 10, color: '#666666', textTransform: 'uppercase', letterSpacing: 0.6 }}>
|
||||
{item.source}
|
||||
</span>
|
||||
{item.related ? (
|
||||
<span style={{ fontSize: 10, color: '#666666' }}>
|
||||
关联: {item.related}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#000000', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{item.summary || '该新闻没有可用摘要。'}
|
||||
</div>
|
||||
|
||||
{item.keyDiscussion ? (
|
||||
<div style={{ marginTop: 10, fontSize: 11, lineHeight: 1.7, color: '#374151' }}>
|
||||
<strong>核心讨论:</strong> {item.keyDiscussion}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{item.reasonGrowth ? (
|
||||
<div style={{ marginTop: 8, fontSize: 11, lineHeight: 1.7, color: '#166534' }}>
|
||||
<strong>利多逻辑:</strong> {item.reasonGrowth}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{item.reasonDecrease ? (
|
||||
<div style={{ marginTop: 8, fontSize: 11, lineHeight: 1.7, color: '#991b1b' }}>
|
||||
<strong>利空逻辑:</strong> {item.reasonDecrease}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{item.url ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ fontSize: 11, fontWeight: 700, color: '#111111', textDecoration: 'underline' }}
|
||||
>
|
||||
查看原文
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
frontend/src/components/explain/ExplainPriceSection.jsx
Normal file
155
frontend/src/components/explain/ExplainPriceSection.jsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
import { formatTickerPrice } from '../../utils/formatters';
|
||||
|
||||
export default function ExplainPriceSection({
|
||||
ohlcSeries,
|
||||
priceSeries,
|
||||
selectedHistorySource,
|
||||
chartModel,
|
||||
selectedTicker,
|
||||
onSelectEventDate,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) {
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">价格与事件</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
{ohlcSeries.length > 1
|
||||
? `最近 ${ohlcSeries.length} 根日线K线${selectedHistorySource ? ` · ${selectedHistorySource}` : ''}`
|
||||
: `最近 ${priceSeries.length} 个价格点聚合为 ${chartModel.bucketCount || 0} 根简化K线`}
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起价格区' : '展开价格区'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ohlcSeries.length === 0 && priceSeries.length === 0 ? (
|
||||
<div className="empty-state">当前还没有可绘制的价格历史</div>
|
||||
) : !isOpen ? (
|
||||
<div className="empty-state">价格区已收起,需要时再展开查看图表和事件点。</div>
|
||||
) : (
|
||||
<div style={{ border: '1px solid #000000', background: '#ffffff', padding: 16 }}>
|
||||
<svg
|
||||
viewBox={`0 0 ${chartModel.width} ${chartModel.height}`}
|
||||
style={{ width: '100%', height: '220px', display: 'block', overflow: 'visible' }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="stockExplainFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="rgba(0,0,0,0.18)" />
|
||||
<stop offset="100%" stopColor="rgba(0,0,0,0.02)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect x="0" y="0" width={chartModel.width} height={chartModel.height} fill="#fafafa" />
|
||||
<line
|
||||
x1={chartModel.padding}
|
||||
y1={chartModel.height - chartModel.padding}
|
||||
x2={chartModel.width - chartModel.padding}
|
||||
y2={chartModel.height - chartModel.padding}
|
||||
stroke="#000000"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
{chartModel.candles.length > 1 ? chartModel.candles.map((candle) => {
|
||||
const rising = candle.close >= candle.open;
|
||||
const stroke = rising ? '#00C853' : '#FF1744';
|
||||
const fill = rising ? 'rgba(0, 200, 83, 0.16)' : 'rgba(255, 23, 68, 0.16)';
|
||||
return (
|
||||
<g key={candle.id}>
|
||||
<line
|
||||
x1={candle.centerX}
|
||||
y1={candle.highY}
|
||||
x2={candle.centerX}
|
||||
y2={candle.lowY}
|
||||
stroke={stroke}
|
||||
strokeWidth="1.4"
|
||||
/>
|
||||
<rect
|
||||
x={candle.x}
|
||||
y={candle.bodyY}
|
||||
width={candle.width}
|
||||
height={candle.bodyHeight}
|
||||
fill={fill}
|
||||
stroke={stroke}
|
||||
strokeWidth="1.4"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}) : chartModel.path && (
|
||||
<>
|
||||
<path d={`${chartModel.path} L${chartModel.width - chartModel.padding},${chartModel.height - chartModel.padding} L${chartModel.padding},${chartModel.height - chartModel.padding} Z`} fill="url(#stockExplainFill)" />
|
||||
<path d={chartModel.path} fill="none" stroke="#000000" strokeWidth="2.5" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{chartModel.markers.map((marker) => {
|
||||
const fill = marker.tone === 'positive'
|
||||
? '#00C853'
|
||||
: marker.tone === 'negative'
|
||||
? '#FF1744'
|
||||
: marker.tone === 'news'
|
||||
? '#2563eb'
|
||||
: '#000000';
|
||||
return (
|
||||
<g
|
||||
key={marker.id}
|
||||
onClick={() => onSelectEventDate(marker.dateKey)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<line x1={marker.x} y1={marker.y} x2={marker.x} y2={chartModel.height - chartModel.padding} stroke={fill} strokeDasharray="3 3" strokeWidth="1" />
|
||||
<circle
|
||||
cx={marker.x}
|
||||
cy={marker.y}
|
||||
r={marker.markerType === 'news'
|
||||
? (marker.isSelected ? '5.5' : '4')
|
||||
: (marker.isSelected ? '6' : '4.5')}
|
||||
fill={fill}
|
||||
stroke={marker.isSelected ? '#111111' : '#ffffff'}
|
||||
strokeWidth={marker.isSelected ? '2.5' : '2'}
|
||||
/>
|
||||
<title>{`${marker.title} · ${marker.dateKey || ''}${marker.count ? ` · ${marker.count} 条新闻` : ''}`}</title>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
<text x={chartModel.padding} y="14" fontSize="11" fill="#666666">
|
||||
{chartModel.maxPrice != null ? `高点 $${formatTickerPrice(chartModel.maxPrice)}` : ''}
|
||||
</text>
|
||||
<text x={chartModel.padding} y={chartModel.height - 6} fontSize="11" fill="#666666">
|
||||
{chartModel.minPrice != null ? `低点 $${formatTickerPrice(chartModel.minPrice)}` : ''}
|
||||
</text>
|
||||
<text x={chartModel.width - chartModel.padding} y="14" fontSize="11" fill="#666666" textAnchor="end">
|
||||
{selectedTicker?.price != null ? `现价 $${formatTickerPrice(selectedTicker.price)}` : ''}
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', marginTop: 12 }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
图表说明:{ohlcSeries.length > 1 ? '历史日线K线' : '基于盘中价格点聚合的简化K线'}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#2563eb' }}>蓝点:新闻日期</div>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>黑点:讨论提及</div>
|
||||
<div style={{ fontSize: 11, color: '#00C853' }}>绿点:偏多信号或做多成交</div>
|
||||
<div style={{ fontSize: 11, color: '#FF1744' }}>红点:偏空信号或做空成交</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
220
frontend/src/components/explain/ExplainRangeSection.jsx
Normal file
220
frontend/src/components/explain/ExplainRangeSection.jsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React from 'react';
|
||||
import { formatTickerPrice } from '../../utils/formatters';
|
||||
|
||||
function renderSentimentLabel(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'positive') return '利多';
|
||||
if (normalized === 'negative') return '利空';
|
||||
if (normalized === 'neutral') return '中性';
|
||||
return value || '';
|
||||
}
|
||||
|
||||
function renderCategoryLabel(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
const labels = {
|
||||
market: '市场交易',
|
||||
policy: '政策监管',
|
||||
earnings: '业绩财报',
|
||||
product_tech: '产品技术',
|
||||
competition: '竞争格局',
|
||||
management: '管理层动态',
|
||||
};
|
||||
return labels[normalized] || value || '';
|
||||
}
|
||||
|
||||
function renderAnalysisSourceLabel(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
if (normalized === 'llm') return 'LLM分析';
|
||||
if (normalized === 'local') return '规则分析';
|
||||
return value || '';
|
||||
}
|
||||
|
||||
function MetricRow({ label, value, valueColor = '#111111' }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, gap: 12 }}>
|
||||
<span style={{ color: '#4b5563' }}>{label}</span>
|
||||
<strong style={{ color: valueColor, textAlign: 'right' }}>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TagList({ items, tone = 'neutral', emptyText }) {
|
||||
const palette = {
|
||||
positive: { border: '#86efac', background: '#f0fdf4', color: '#166534' },
|
||||
negative: { border: '#fca5a5', background: '#fef2f2', color: '#991b1b' },
|
||||
neutral: { border: '#d1d5db', background: '#f9fafb', color: '#374151' },
|
||||
};
|
||||
const colors = palette[tone] || palette.neutral;
|
||||
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return <div style={{ fontSize: 12, lineHeight: 1.7, color: '#6b7280' }}>{emptyText}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={`${tone}-${index}-${item}`}
|
||||
style={{
|
||||
border: `1px solid ${colors.border}`,
|
||||
background: colors.background,
|
||||
color: colors.color,
|
||||
padding: '6px 10px',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExplainRangeSection({
|
||||
selectedRangeWindow,
|
||||
selectedRangeExplain,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) {
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">区间涨跌分析</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
{selectedRangeWindow
|
||||
? `${selectedRangeWindow.startDate} ~ ${selectedRangeWindow.endDate}`
|
||||
: '先在图上选择一个事件日期'}
|
||||
</div>
|
||||
{selectedRangeExplain?.analysis?.analysis_source ? (
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
{selectedRangeExplain.analysis.analysis_source === 'llm'
|
||||
? `分析来源 · ${renderAnalysisSourceLabel(selectedRangeExplain.analysis.analysis_source)} · ${selectedRangeExplain.analysis.analysis_model_label || 'LLM'}`
|
||||
: `分析来源 · ${renderAnalysisSourceLabel(selectedRangeExplain.analysis.analysis_source)}`}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起区间涨跌分析' : '展开区间涨跌分析'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedRangeWindow ? (
|
||||
<div className="empty-state">选择图上的日期后,会自动生成最近 7 天的区间涨跌分析。</div>
|
||||
) : !isOpen ? (
|
||||
<div className="empty-state">区间涨跌分析已收起,需要时再展开查看摘要和快照。</div>
|
||||
) : !selectedRangeExplain ? (
|
||||
<div className="empty-state">正在生成区间涨跌分析...</div>
|
||||
) : selectedRangeExplain.error ? (
|
||||
<div className="empty-state">{selectedRangeExplain.error}</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: 16 }}>
|
||||
<div style={{ border: '1px solid #000000', background: '#ffffff', padding: 16 }}>
|
||||
<div style={{ fontSize: 11, color: '#666666', marginBottom: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
区间摘要
|
||||
</div>
|
||||
<div style={{ fontSize: 13, lineHeight: 1.8 }}>
|
||||
{selectedRangeExplain.analysis?.summary || '暂无区间摘要'}
|
||||
</div>
|
||||
{selectedRangeExplain.analysis?.trend_analysis ? (
|
||||
<div style={{ marginTop: 10, fontSize: 12, lineHeight: 1.7, color: '#4b5563' }}>
|
||||
<strong>趋势拆解:</strong> {selectedRangeExplain.analysis.trend_analysis}
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ marginTop: 14, display: 'grid', gap: 8 }}>
|
||||
{(selectedRangeExplain.analysis?.key_events || []).slice(0, 6).map((event, index) => (
|
||||
<div key={`${event.id || event.title}-${index}`} style={{ borderTop: index === 0 ? 'none' : '1px solid #e5e7eb', paddingTop: index === 0 ? 0 : 8 }}>
|
||||
<div style={{ fontSize: 11, color: '#666666', marginBottom: 4 }}>
|
||||
{event.date || '-'} {event.category ? `· ${renderCategoryLabel(event.category)}` : ''} {event.sentiment ? `· ${renderSentimentLabel(event.sentiment)}` : ''}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, marginBottom: 4 }}>{event.title}</div>
|
||||
<div style={{ fontSize: 12, lineHeight: 1.6 }}>{event.summary || '暂无摘要'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 16 }}>
|
||||
<div style={{ fontSize: 11, color: '#666666', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
区间快照
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<div style={{ border: '1px solid #e5e7eb', background: '#ffffff', padding: 12, display: 'grid', gap: 10 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#374151' }}>事实概览</div>
|
||||
<MetricRow
|
||||
label="区间涨跌"
|
||||
value={`${Number(selectedRangeExplain.price_change_pct) >= 0 ? '+' : ''}${Number(selectedRangeExplain.price_change_pct || 0).toFixed(2)}%`}
|
||||
valueColor={Number(selectedRangeExplain.price_change_pct) >= 0 ? '#00C853' : '#FF1744'}
|
||||
/>
|
||||
<MetricRow label="关联新闻" value={selectedRangeExplain.news_count || 0} />
|
||||
<MetricRow label="区间高点" value={`$${formatTickerPrice(selectedRangeExplain.high_price)}`} />
|
||||
<MetricRow label="区间低点" value={`$${formatTickerPrice(selectedRangeExplain.low_price)}`} />
|
||||
<MetricRow label="交易日数" value={selectedRangeExplain.trading_days || 0} />
|
||||
</div>
|
||||
|
||||
<div style={{ border: '1px solid #e5e7eb', background: '#ffffff', padding: 12, display: 'grid', gap: 10 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#374151' }}>主题分布</div>
|
||||
{(selectedRangeExplain.dominant_categories || []).length > 0 ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{selectedRangeExplain.dominant_categories.map((item) => (
|
||||
<div
|
||||
key={`${item.category}-${item.count}`}
|
||||
style={{
|
||||
border: '1px solid #d1d5db',
|
||||
background: '#f9fafb',
|
||||
color: '#374151',
|
||||
padding: '6px 10px',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{renderCategoryLabel(item.category)} · {item.count}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#6b7280' }}>
|
||||
当前没有识别出明显的主题聚类。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ border: '1px solid #e5e7eb', background: '#ffffff', padding: 12, display: 'grid', gap: 10 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#374151' }}>驱动因素</div>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#166534' }}>利多因素</div>
|
||||
<TagList
|
||||
items={selectedRangeExplain.analysis?.bullish_factors || []}
|
||||
tone="positive"
|
||||
emptyText="当前区间内未提炼出明确的利多因素。"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#991b1b' }}>利空因素</div>
|
||||
<TagList
|
||||
items={selectedRangeExplain.analysis?.bearish_factors || []}
|
||||
tone="negative"
|
||||
emptyText="当前区间内未提炼出明确的利空因素。"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
frontend/src/components/explain/ExplainSignalsSection.jsx
Normal file
123
frontend/src/components/explain/ExplainSignalsSection.jsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function ExplainSignalsSection({
|
||||
tickerSignals,
|
||||
signalSummary,
|
||||
latestSignal,
|
||||
eventDateKey,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) {
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">分析师观点</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
最近 {tickerSignals.length} 条相关信号
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起分析师观点' : '展开分析师观点'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isOpen ? (
|
||||
<div className="empty-state">分析师观点已收起,需要时再展开查看信号统计和明细。</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="stats-grid" style={{ marginBottom: 16 }}>
|
||||
<div className="stat-card">
|
||||
<div className="stat-card-label">看涨</div>
|
||||
<div className="stat-card-value positive">{signalSummary.bullish}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-card-label">看跌</div>
|
||||
<div className="stat-card-value negative">{signalSummary.bearish}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-card-label">中性</div>
|
||||
<div className="stat-card-value">{signalSummary.neutral}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-card-label">最新结论</div>
|
||||
<div className="stat-card-value" style={{ fontSize: 22 }}>
|
||||
{latestSignal
|
||||
? latestSignal.normalizedDirection === 'bullish'
|
||||
? '偏多'
|
||||
: latestSignal.normalizedDirection === 'bearish'
|
||||
? '偏空'
|
||||
: '观望'
|
||||
: '暂无'}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: '#666666' }}>
|
||||
{latestSignal ? `${latestSignal.agentName} · ${latestSignal.date || eventDateKey(latestSignal.timestamp)}` : '还没有历史信号'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tickerSignals.length === 0 ? (
|
||||
<div className="empty-state">该股票还没有分析师信号记录</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>日期</th>
|
||||
<th>分析师</th>
|
||||
<th>方向</th>
|
||||
<th>实际收益</th>
|
||||
<th>结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tickerSignals.slice(0, 8).map((signal, index) => {
|
||||
const realReturn = typeof signal.real_return === 'number'
|
||||
? `${signal.real_return >= 0 ? '+' : ''}${(signal.real_return * 100).toFixed(2)}%`
|
||||
: '未判定';
|
||||
const status = signal.is_correct === true ? '命中' : signal.is_correct === false ? '未命中' : '待判定';
|
||||
const directionText = signal.normalizedDirection === 'bullish'
|
||||
? '看涨'
|
||||
: signal.normalizedDirection === 'bearish'
|
||||
? '看跌'
|
||||
: '中性';
|
||||
const directionColor = signal.normalizedDirection === 'bullish'
|
||||
? '#00C853'
|
||||
: signal.normalizedDirection === 'bearish'
|
||||
? '#FF1744'
|
||||
: '#666666';
|
||||
|
||||
return (
|
||||
<tr key={signal.id || `${signal.agentId}-${signal.date}-${index}`}>
|
||||
<td>{signal.date || eventDateKey(signal.timestamp) || '-'}</td>
|
||||
<td>
|
||||
<div style={{ fontWeight: 700 }}>{signal.agentName}</div>
|
||||
<div style={{ fontSize: 10, color: '#666666' }}>{signal.role}</div>
|
||||
</td>
|
||||
<td style={{ color: directionColor, fontWeight: 700 }}>{directionText}</td>
|
||||
<td>{realReturn}</td>
|
||||
<td>{status}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
frontend/src/components/explain/ExplainSimilarDaysSection.jsx
Normal file
111
frontend/src/components/explain/ExplainSimilarDaysSection.jsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function ExplainSimilarDaysSection({
|
||||
selectedSimilarDays,
|
||||
selectedEventDate,
|
||||
onSelectSimilarDate,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) {
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">历史相似交易日</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
{selectedEventDate || '先选择一个事件日期'}
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起相似交易日' : '展开相似交易日'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedEventDate ? (
|
||||
<div className="empty-state">选择图上的日期后,会检索这只股票历史上的相似交易日。</div>
|
||||
) : !isOpen ? (
|
||||
<div className="empty-state">相似交易日默认收起,需要时再展开查看。</div>
|
||||
) : !selectedSimilarDays ? (
|
||||
<div className="empty-state">正在检索相似交易日...</div>
|
||||
) : selectedSimilarDays.error ? (
|
||||
<div className="empty-state">{selectedSimilarDays.error}</div>
|
||||
) : !Array.isArray(selectedSimilarDays.items) || selectedSimilarDays.items.length === 0 ? (
|
||||
<div className="empty-state">当前没有足够历史样本来计算相似交易日。</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 16 }}>
|
||||
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 16 }}>
|
||||
<div style={{ fontSize: 11, color: '#666666', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
目标日快照
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}>
|
||||
<div style={{ fontSize: 12 }}>
|
||||
<div style={{ color: '#666666', marginBottom: 4 }}>新闻数量</div>
|
||||
<strong>{selectedSimilarDays.target_features?.n_articles ?? 0}</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: 12 }}>
|
||||
<div style={{ color: '#666666', marginBottom: 4 }}>情绪分数</div>
|
||||
<strong>{Number(selectedSimilarDays.target_features?.sentiment_score ?? 0).toFixed(2)}</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: 12 }}>
|
||||
<div style={{ color: '#666666', marginBottom: 4 }}>前一日涨跌</div>
|
||||
<strong>{Number(selectedSimilarDays.target_features?.ret_1d ?? 0).toFixed(2)}%</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: 12 }}>
|
||||
<div style={{ color: '#666666', marginBottom: 4 }}>高相关新闻</div>
|
||||
<strong>{selectedSimilarDays.target_features?.high_relevance_count ?? 0}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 16 }}>
|
||||
{selectedSimilarDays.items.map((item) => (
|
||||
<button
|
||||
key={item.date}
|
||||
onClick={() => onSelectSimilarDate?.(item.date)}
|
||||
style={{
|
||||
border: '1px solid #000000',
|
||||
background: '#ffffff',
|
||||
padding: 14,
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, marginBottom: 8 }}>
|
||||
<strong style={{ fontSize: 13 }}>{item.date}</strong>
|
||||
<span style={{ fontSize: 11, color: '#666666' }}>
|
||||
相似度 {(Number(item.score || 0) * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 6, fontSize: 12, marginBottom: 10 }}>
|
||||
<div>新闻数 {item.n_articles ?? 0}</div>
|
||||
<div>情绪分数 {Number(item.sentiment_score ?? 0).toFixed(2)}</div>
|
||||
<div>前一日涨跌 {Number(item.ret_1d ?? 0).toFixed(2)}%</div>
|
||||
<div>次日表现 {item.ret_t1_after != null ? `${item.ret_t1_after >= 0 ? '+' : ''}${Number(item.ret_t1_after).toFixed(2)}%` : '-'}</div>
|
||||
<div>三日表现 {item.ret_t3_after != null ? `${item.ret_t3_after >= 0 ? '+' : ''}${Number(item.ret_t3_after).toFixed(2)}%` : '-'}</div>
|
||||
</div>
|
||||
{(item.top_reasons || []).length > 0 ? (
|
||||
<div style={{ fontSize: 11, lineHeight: 1.7, color: '#4b5563' }}>
|
||||
<strong>主要线索:</strong> {item.top_reasons.join(' / ')}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
frontend/src/components/explain/ExplainStorySection.jsx
Normal file
58
frontend/src/components/explain/ExplainStorySection.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
export default function ExplainStorySection({
|
||||
selectedStory,
|
||||
selectedSymbol,
|
||||
currentDate,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) {
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">主线叙事</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
{selectedStory?.asOfDate || currentDate || '按当前解释窗口生成'}
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起主线叙事' : '展开主线叙事'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedSymbol ? (
|
||||
<div className="empty-state">先选择一只股票</div>
|
||||
) : !isOpen ? (
|
||||
<div className="empty-state">主线叙事默认收起,需要时再展开查看完整叙事。</div>
|
||||
) : !selectedStory?.story ? (
|
||||
<div className="empty-state">正在生成主线叙事...</div>
|
||||
) : (
|
||||
<div style={{ border: '1px solid #000000', background: '#ffffff', padding: 18 }}>
|
||||
<div style={{ fontSize: 11, color: '#666666', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
{selectedStory?.source ? `来源 · ${selectedStory.source}` : '自动生成'}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, lineHeight: 1.8 }}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{selectedStory.story}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
frontend/src/components/explain/ExplainSummarySection.jsx
Normal file
87
frontend/src/components/explain/ExplainSummarySection.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function ExplainSummarySection({
|
||||
explainSummary,
|
||||
tickerSignals,
|
||||
recentMentions,
|
||||
tickerTrades,
|
||||
tickerNews,
|
||||
selectedSymbol,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) {
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">分析摘要</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
基于当前持仓、信号和讨论自动汇总
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起分析摘要' : '展开分析摘要'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isOpen ? (
|
||||
<div className="empty-state">分析摘要已收起,需要时再展开查看概览和密度信息。</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: 16 }}>
|
||||
<div style={{ border: '1px solid #000000', background: '#fafafa', padding: 16 }}>
|
||||
<div style={{ fontSize: 11, color: '#666666', marginBottom: 10, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
当前解释
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{explainSummary.map((line, index) => (
|
||||
<div key={`${selectedSymbol}-summary-${index}`} style={{ fontSize: 13, lineHeight: 1.7, color: '#000000' }}>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ border: '1px solid #000000', background: '#ffffff', padding: 16 }}>
|
||||
<div style={{ fontSize: 11, color: '#666666', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 1 }}>
|
||||
信号密度
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
|
||||
<span>分析师信号</span>
|
||||
<strong>{tickerSignals.length}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
|
||||
<span>讨论提及</span>
|
||||
<strong>{recentMentions.length}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
|
||||
<span>成交记录</span>
|
||||
<strong>{tickerTrades.length}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12 }}>
|
||||
<span>新闻条目</span>
|
||||
<strong>{tickerNews.length}</strong>
|
||||
</div>
|
||||
<div style={{ height: 1, background: '#e0e0e0', margin: '4px 0' }} />
|
||||
<div style={{ fontSize: 12, lineHeight: 1.7, color: '#666666' }}>
|
||||
当前分析优先读取已落库的历史记录,缺失时再回退到本次运行中的实时事件。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
frontend/src/components/explain/ExplainTradesSection.jsx
Normal file
74
frontend/src/components/explain/ExplainTradesSection.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { formatDateTime } from '../../utils/formatters';
|
||||
|
||||
export default function ExplainTradesSection({
|
||||
tickerTrades,
|
||||
selectedSymbol,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}) {
|
||||
const sideLabel = (value) => {
|
||||
if (value === 'LONG') return '做多';
|
||||
if (value === 'SHORT') return '做空';
|
||||
return value || '-';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">成交记录</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 11, color: '#666666' }}>
|
||||
{tickerTrades.length} 笔与 {selectedSymbol} 相关的交易
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
border: '1px solid #111111',
|
||||
background: isOpen ? '#111111' : '#ffffff',
|
||||
color: isOpen ? '#ffffff' : '#111111',
|
||||
padding: '7px 10px',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{isOpen ? '收起成交记录' : `展开成交记录 ${tickerTrades.length}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tickerTrades.length === 0 ? (
|
||||
<div className="empty-state">该股票暂无成交记录</div>
|
||||
) : !isOpen ? (
|
||||
<div className="empty-state">成交记录默认收起,需要时再展开查看。</div>
|
||||
) : (
|
||||
<div className="table-wrapper">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>方向</th>
|
||||
<th>数量</th>
|
||||
<th>价格</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tickerTrades.slice(0, 10).map((trade, index) => (
|
||||
<tr key={trade.id || `${trade.ticker}-${trade.timestamp}-${index}`}>
|
||||
<td>{formatDateTime(trade.timestamp)}</td>
|
||||
<td style={{ fontWeight: 700, color: trade.side === 'LONG' ? '#00C853' : trade.side === 'SHORT' ? '#FF1744' : '#000000' }}>
|
||||
{sideLabel(trade.side)}
|
||||
</td>
|
||||
<td>{trade.qty}</td>
|
||||
<td>${Number(trade.price).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
281
frontend/src/components/explain/explainUtils.js
Normal file
281
frontend/src/components/explain/explainUtils.js
Normal file
@@ -0,0 +1,281 @@
|
||||
export function normalizeSignalDirection(signal) {
|
||||
const value = String(signal || '').trim().toLowerCase();
|
||||
if (!value) return 'neutral';
|
||||
if (value.includes('bull') || value === 'long' || value === 'buy') return 'bullish';
|
||||
if (value.includes('bear') || value === 'short' || value === 'sell') return 'bearish';
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
export function includesTicker(content, ticker) {
|
||||
if (!ticker || typeof content !== 'string') return false;
|
||||
const normalized = ticker.trim().toUpperCase();
|
||||
if (!normalized) return false;
|
||||
return new RegExp(`\\b${normalized}\\b`, 'i').test(content);
|
||||
}
|
||||
|
||||
export function flattenFeedMessages(feed) {
|
||||
if (!Array.isArray(feed)) return [];
|
||||
const items = [];
|
||||
|
||||
feed.forEach((item) => {
|
||||
if (!item || !item.type || !item.data) return;
|
||||
|
||||
if (item.type === 'message' || item.type === 'memory') {
|
||||
items.push({ ...item.data, feedType: item.type, feedId: item.id });
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.type === 'conference' && Array.isArray(item.data.messages)) {
|
||||
item.data.messages.forEach((message) => {
|
||||
items.push({
|
||||
...message,
|
||||
feedType: 'conference',
|
||||
feedId: item.id,
|
||||
conferenceTitle: item.data.title
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function snippetText(content, ticker) {
|
||||
const raw = String(content || '').replace(/\s+/g, ' ').trim();
|
||||
if (!raw) return '';
|
||||
const normalizedTicker = String(ticker || '').trim().toUpperCase();
|
||||
if (!normalizedTicker) {
|
||||
return raw.length > 220 ? `${raw.slice(0, 220)}...` : raw;
|
||||
}
|
||||
|
||||
const upper = raw.toUpperCase();
|
||||
const idx = upper.indexOf(normalizedTicker);
|
||||
if (idx === -1) {
|
||||
return raw.length > 220 ? `${raw.slice(0, 220)}...` : raw;
|
||||
}
|
||||
|
||||
const start = Math.max(0, idx - 90);
|
||||
const end = Math.min(raw.length, idx + normalizedTicker.length + 130);
|
||||
const snippet = raw.slice(start, end).trim();
|
||||
return `${start > 0 ? '...' : ''}${snippet}${end < raw.length ? '...' : ''}`;
|
||||
}
|
||||
|
||||
export function buildLinePath(points, width, height, padding) {
|
||||
if (!Array.isArray(points) || points.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const prices = points.map((point) => Number(point.price)).filter(Number.isFinite);
|
||||
if (!prices.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const minPrice = Math.min(...prices);
|
||||
const maxPrice = Math.max(...prices);
|
||||
const span = maxPrice - minPrice || 1;
|
||||
const innerWidth = width - padding * 2;
|
||||
const innerHeight = height - padding * 2;
|
||||
|
||||
return points.map((point, index) => {
|
||||
const x = padding + (innerWidth * index) / Math.max(points.length - 1, 1);
|
||||
const y = height - padding - ((Number(point.price) - minPrice) / span) * innerHeight;
|
||||
return `${index === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
export function parsePointTime(point) {
|
||||
const raw = point?.timestamp ?? point?.label;
|
||||
if (!raw) return NaN;
|
||||
const direct = new Date(raw).getTime();
|
||||
if (Number.isFinite(direct)) return direct;
|
||||
return new Date(`${raw}T00:00:00`).getTime();
|
||||
}
|
||||
|
||||
export function aggregatePriceSeriesToCandles(points) {
|
||||
if (!Array.isArray(points) || points.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const bucketTarget = points.length >= 36 ? 12 : points.length >= 18 ? 8 : 4;
|
||||
const bucketSize = Math.max(1, Math.ceil(points.length / bucketTarget));
|
||||
const candles = [];
|
||||
|
||||
for (let index = 0; index < points.length; index += bucketSize) {
|
||||
const bucket = points.slice(index, index + bucketSize);
|
||||
const prices = bucket.map((point) => Number(point.price)).filter(Number.isFinite);
|
||||
if (!prices.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
candles.push({
|
||||
id: `${bucket[0]?.timestamp || index}-${bucket[bucket.length - 1]?.timestamp || index + bucket.length}`,
|
||||
open: Number(bucket[0].price),
|
||||
high: Math.max(...prices),
|
||||
low: Math.min(...prices),
|
||||
close: Number(bucket[bucket.length - 1].price),
|
||||
startTimestamp: parsePointTime(bucket[0]),
|
||||
endTimestamp: parsePointTime(bucket[bucket.length - 1]),
|
||||
startLabel: bucket[0]?.label || bucket[0]?.timestamp || '',
|
||||
endLabel: bucket[bucket.length - 1]?.label || bucket[bucket.length - 1]?.timestamp || ''
|
||||
});
|
||||
}
|
||||
|
||||
return candles;
|
||||
}
|
||||
|
||||
export function eventDateKey(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
const parsed = new Date(timestamp);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
return parsed.toISOString().slice(0, 10);
|
||||
}
|
||||
return String(timestamp).slice(0, 10);
|
||||
}
|
||||
|
||||
export function resolveEventCategory(event) {
|
||||
if (!event) return 'other';
|
||||
if (event.type === 'trade') return 'trade';
|
||||
if (event.type === 'mention') return 'discussion';
|
||||
if (event.type !== 'signal') return 'other';
|
||||
|
||||
const role = String(event.meta || '').toLowerCase();
|
||||
if (role.includes('technical')) return 'technical';
|
||||
if (role.includes('fundamental')) return 'fundamental';
|
||||
if (role.includes('sentiment')) return 'sentiment';
|
||||
if (role.includes('valuation')) return 'valuation';
|
||||
if (role.includes('risk')) return 'risk';
|
||||
if (role.includes('portfolio')) return 'portfolio';
|
||||
return 'signal';
|
||||
}
|
||||
|
||||
export function normalizeTradeRow(row, fallbackIndex = 0) {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
const timestamp = row.timestamp || row.ts || row.created_at || null;
|
||||
const ticker = row.ticker || '';
|
||||
const side = row.side || '';
|
||||
const qtyValue = Number(row.qty ?? row.quantity ?? 0);
|
||||
const priceValue = Number(row.price ?? 0);
|
||||
return {
|
||||
id: row.id || `trade-${ticker}-${timestamp || fallbackIndex}-${fallbackIndex}`,
|
||||
timestamp,
|
||||
trading_date: row.trading_date || row.trade_date || null,
|
||||
ticker,
|
||||
side,
|
||||
qty: Number.isFinite(qtyValue) ? qtyValue : 0,
|
||||
price: Number.isFinite(priceValue) ? priceValue : 0
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSignalRow(row, fallbackIndex = 0) {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
const timestamp = row.timestamp || row.created_at || null;
|
||||
const date = row.date || row.trade_date || eventDateKey(timestamp) || '';
|
||||
const rawSignal = row.signal || row.title || '';
|
||||
const normalizedDirection = normalizeSignalDirection(rawSignal);
|
||||
const confidenceValue = Number(row.confidence);
|
||||
const realReturnValue = Number(row.real_return);
|
||||
const parsedCorrect = typeof row.is_correct === 'string'
|
||||
? row.is_correct.toLowerCase() === 'true'
|
||||
? true
|
||||
: row.is_correct.toLowerCase() === 'false'
|
||||
? false
|
||||
: null
|
||||
: typeof row.is_correct === 'boolean'
|
||||
? row.is_correct
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: row.id || `signal-${row.agent_id || row.agentId || 'agent'}-${date || fallbackIndex}-${fallbackIndex}`,
|
||||
timestamp,
|
||||
date,
|
||||
ticker: row.ticker || '',
|
||||
signal: rawSignal,
|
||||
confidence: Number.isFinite(confidenceValue) ? confidenceValue : null,
|
||||
real_return: Number.isFinite(realReturnValue) ? realReturnValue : null,
|
||||
is_correct: parsedCorrect,
|
||||
agentId: row.agent_id || row.agentId || '',
|
||||
agentName: row.agent_name || row.agentName || row.meta || '未知分析师',
|
||||
role: row.role || row.meta || '',
|
||||
normalizedDirection
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeMentionRow(row, fallbackIndex = 0) {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
return {
|
||||
id: row.id || `mention-${fallbackIndex}`,
|
||||
feedId: row.id || `mention-${fallbackIndex}`,
|
||||
timestamp: row.timestamp || null,
|
||||
agent: row.agent || row.agentName || '未知角色',
|
||||
content: row.body || row.content || '',
|
||||
conferenceTitle: row.meta || '',
|
||||
feedType: 'sqlite'
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeNewsRow(row, fallbackIndex = 0) {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
const date = row.date || row.published_utc || row.timestamp || null;
|
||||
const source = row.source || row.publisher || '新闻源';
|
||||
const title = row.title || '未命名新闻';
|
||||
const summary = row.summary || row.description || '';
|
||||
return {
|
||||
id: row.id || row.url || `news-${fallbackIndex}`,
|
||||
date,
|
||||
dateKey: eventDateKey(date),
|
||||
ticker: row.ticker || '',
|
||||
title,
|
||||
source,
|
||||
category: row.category || '',
|
||||
related: row.related || '',
|
||||
summary,
|
||||
url: row.url || row.article_url || '',
|
||||
tradeDate: row.trade_date || null,
|
||||
relevance: row.relevance || '',
|
||||
sentiment: row.sentiment || '',
|
||||
keyDiscussion: row.key_discussion || '',
|
||||
reasonGrowth: row.reason_growth || '',
|
||||
reasonDecrease: row.reason_decrease || '',
|
||||
retT0: Number.isFinite(Number(row.ret_t0)) ? Number(row.ret_t0) : null,
|
||||
retT1: Number.isFinite(Number(row.ret_t1)) ? Number(row.ret_t1) : null,
|
||||
retT3: Number.isFinite(Number(row.ret_t3)) ? Number(row.ret_t3) : null,
|
||||
retT5: Number.isFinite(Number(row.ret_t5)) ? Number(row.ret_t5) : null,
|
||||
retT10: Number.isFinite(Number(row.ret_t10)) ? Number(row.ret_t10) : null,
|
||||
analysisSource: row.analysis_source || '',
|
||||
analysisModelLabel: row.analysis_model_label || ''
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeNewsTimelineRow(row, fallbackIndex = 0) {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
const date = row.date || row.trade_date || null;
|
||||
if (!date) return null;
|
||||
const countValue = Number(row.count ?? 0);
|
||||
const sourceCountValue = Number(row.source_count ?? 0);
|
||||
return {
|
||||
id: row.id || `news-timeline-${date}-${fallbackIndex}`,
|
||||
date,
|
||||
dateKey: eventDateKey(date),
|
||||
count: Number.isFinite(countValue) ? countValue : 0,
|
||||
sourceCount: Number.isFinite(sourceCountValue) ? sourceCountValue : 0,
|
||||
topTitle: row.top_title || '',
|
||||
positiveCount: Number.isFinite(Number(row.positive_count)) ? Number(row.positive_count) : 0,
|
||||
negativeCount: Number.isFinite(Number(row.negative_count)) ? Number(row.negative_count) : 0,
|
||||
neutralCount: Number.isFinite(Number(row.neutral_count)) ? Number(row.neutral_count) : 0,
|
||||
highRelevanceCount: Number.isFinite(Number(row.high_relevance_count)) ? Number(row.high_relevance_count) : 0
|
||||
};
|
||||
}
|
||||
|
||||
export const EVENT_CATEGORY_META = {
|
||||
all: { label: '全部事件', color: '#111111' },
|
||||
discussion: { label: '讨论', color: '#555555' },
|
||||
signal: { label: '信号', color: '#0f766e' },
|
||||
technical: { label: '技术', color: '#2563eb' },
|
||||
fundamental: { label: '基本面', color: '#059669' },
|
||||
sentiment: { label: '情绪', color: '#7c3aed' },
|
||||
valuation: { label: '估值', color: '#d97706' },
|
||||
risk: { label: '风控', color: '#dc2626' },
|
||||
portfolio: { label: '组合', color: '#111827' },
|
||||
trade: { label: '成交', color: '#b91c1c' },
|
||||
other: { label: '其他', color: '#6b7280' }
|
||||
};
|
||||
664
frontend/src/components/explain/useExplainModel.js
Normal file
664
frontend/src/components/explain/useExplainModel.js
Normal file
@@ -0,0 +1,664 @@
|
||||
import { useMemo } from 'react';
|
||||
import { formatDateTime } from '../../utils/formatters';
|
||||
import {
|
||||
aggregatePriceSeriesToCandles,
|
||||
buildLinePath,
|
||||
eventDateKey,
|
||||
flattenFeedMessages,
|
||||
includesTicker,
|
||||
normalizeMentionRow,
|
||||
normalizeNewsRow,
|
||||
normalizeNewsTimelineRow,
|
||||
normalizeSignalDirection,
|
||||
normalizeSignalRow,
|
||||
normalizeTradeRow,
|
||||
parsePointTime,
|
||||
resolveEventCategory,
|
||||
snippetText
|
||||
} from './explainUtils';
|
||||
|
||||
function tradeSideLabel(value) {
|
||||
if (value === 'LONG') return '做多';
|
||||
if (value === 'SHORT') return '做空';
|
||||
return value || '交易';
|
||||
}
|
||||
|
||||
export default function useExplainModel({
|
||||
tickers,
|
||||
holdings,
|
||||
trades,
|
||||
leaderboard,
|
||||
feed,
|
||||
priceHistoryByTicker,
|
||||
ohlcHistoryByTicker,
|
||||
selectedSymbol,
|
||||
explainEventsSnapshot,
|
||||
newsSnapshot,
|
||||
selectedEventDate,
|
||||
activeEventCategory,
|
||||
activeNewsCategory,
|
||||
activeNewsSentiment = 'all'
|
||||
}) {
|
||||
const availableSymbols = useMemo(() => (
|
||||
Array.isArray(tickers)
|
||||
? tickers.map((ticker) => ticker?.symbol).filter((symbol) => typeof symbol === 'string' && symbol.trim())
|
||||
: []
|
||||
), [tickers]);
|
||||
|
||||
const selectedTicker = useMemo(
|
||||
() => tickers.find((ticker) => ticker.symbol === selectedSymbol) || null,
|
||||
[selectedSymbol, tickers]
|
||||
);
|
||||
|
||||
const holding = useMemo(
|
||||
() => holdings.find((item) => item.ticker === selectedSymbol) || null,
|
||||
[holdings, selectedSymbol]
|
||||
);
|
||||
|
||||
const fallbackTrades = useMemo(
|
||||
() => trades
|
||||
.filter((trade) => trade.ticker === selectedSymbol)
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()),
|
||||
[selectedSymbol, trades]
|
||||
);
|
||||
|
||||
const tickerSignals = useMemo(() => {
|
||||
const snapshotSignals = Array.isArray(explainEventsSnapshot?.signals)
|
||||
? explainEventsSnapshot.signals.map((signal, index) => normalizeSignalRow(signal, index)).filter(Boolean)
|
||||
: [];
|
||||
if (snapshotSignals.length > 0) {
|
||||
return snapshotSignals.sort((a, b) => new Date(b.timestamp || b.date).getTime() - new Date(a.timestamp || a.date).getTime());
|
||||
}
|
||||
if (!selectedSymbol) return [];
|
||||
return (Array.isArray(leaderboard) ? leaderboard : []).flatMap((agent) => {
|
||||
const signals = Array.isArray(agent.signals) ? agent.signals : [];
|
||||
return signals
|
||||
.filter((signal) => signal.ticker === selectedSymbol)
|
||||
.map((signal) => ({
|
||||
agentId: agent.agentId,
|
||||
agentName: agent.name,
|
||||
role: agent.role,
|
||||
...signal,
|
||||
normalizedDirection: normalizeSignalDirection(signal.signal)
|
||||
}));
|
||||
}).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
}, [explainEventsSnapshot, leaderboard, selectedSymbol]);
|
||||
|
||||
const signalSummary = useMemo(() => {
|
||||
const summary = { bullish: 0, bearish: 0, neutral: 0 };
|
||||
tickerSignals.forEach((signal) => {
|
||||
summary[signal.normalizedDirection] += 1;
|
||||
});
|
||||
return summary;
|
||||
}, [tickerSignals]);
|
||||
|
||||
const fallbackRecentMentions = useMemo(() => {
|
||||
const flattened = flattenFeedMessages(feed);
|
||||
return flattened
|
||||
.filter((message) => message.agent !== 'System' && includesTicker(message.content, selectedSymbol))
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
.slice(0, 8);
|
||||
}, [feed, selectedSymbol]);
|
||||
|
||||
const tickerTrades = useMemo(() => {
|
||||
const snapshotTrades = Array.isArray(explainEventsSnapshot?.trades)
|
||||
? explainEventsSnapshot.trades.map((trade, index) => normalizeTradeRow(trade, index)).filter(Boolean)
|
||||
: [];
|
||||
if (snapshotTrades.length > 0) {
|
||||
return snapshotTrades.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
}
|
||||
return fallbackTrades;
|
||||
}, [explainEventsSnapshot, fallbackTrades]);
|
||||
|
||||
const recentMentions = useMemo(() => {
|
||||
const snapshotMentions = Array.isArray(explainEventsSnapshot?.events)
|
||||
? explainEventsSnapshot.events
|
||||
.map((event, index) => normalizeMentionRow(event, index))
|
||||
.filter(Boolean)
|
||||
.slice(0, 8)
|
||||
: [];
|
||||
if (snapshotMentions.length > 0) {
|
||||
return snapshotMentions.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
}
|
||||
return fallbackRecentMentions;
|
||||
}, [explainEventsSnapshot, fallbackRecentMentions]);
|
||||
|
||||
const tickerNews = useMemo(() => {
|
||||
const items = Array.isArray(newsSnapshot?.items)
|
||||
? newsSnapshot.items.map((item, index) => normalizeNewsRow(item, index)).filter(Boolean)
|
||||
: [];
|
||||
return items.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
}, [newsSnapshot]);
|
||||
|
||||
const dateScopedNews = useMemo(() => {
|
||||
if (!selectedEventDate || !newsSnapshot?.byDate || typeof newsSnapshot.byDate !== 'object') {
|
||||
return [];
|
||||
}
|
||||
const rows = Array.isArray(newsSnapshot.byDate[selectedEventDate])
|
||||
? newsSnapshot.byDate[selectedEventDate]
|
||||
: [];
|
||||
return rows.map((item, index) => normalizeNewsRow(item, index)).filter(Boolean);
|
||||
}, [newsSnapshot, selectedEventDate]);
|
||||
|
||||
const visibleNews = useMemo(() => {
|
||||
if (!selectedEventDate) {
|
||||
return tickerNews;
|
||||
}
|
||||
if (dateScopedNews.length > 0) {
|
||||
return dateScopedNews.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
}
|
||||
const scoped = tickerNews.filter((item) => item.dateKey === selectedEventDate);
|
||||
return scoped.length > 0 ? scoped : tickerNews;
|
||||
}, [dateScopedNews, selectedEventDate, tickerNews]);
|
||||
|
||||
const tickerNewsTimeline = useMemo(() => {
|
||||
const items = Array.isArray(newsSnapshot?.timeline)
|
||||
? newsSnapshot.timeline.map((item, index) => normalizeNewsTimelineRow(item, index)).filter(Boolean)
|
||||
: [];
|
||||
return items.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
}, [newsSnapshot]);
|
||||
|
||||
const newsCategories = useMemo(() => (
|
||||
newsSnapshot?.categories && typeof newsSnapshot.categories === 'object'
|
||||
? newsSnapshot.categories
|
||||
: {}
|
||||
), [newsSnapshot]);
|
||||
|
||||
const visibleNewsByCategory = useMemo(() => {
|
||||
let scopedNews = visibleNews;
|
||||
if (activeNewsCategory !== 'all') {
|
||||
const categoryMeta = newsCategories?.[activeNewsCategory];
|
||||
const allowedIds = Array.isArray(categoryMeta?.article_ids)
|
||||
? new Set(categoryMeta.article_ids)
|
||||
: null;
|
||||
if (allowedIds && allowedIds.size > 0) {
|
||||
scopedNews = scopedNews.filter((item) => allowedIds.has(item.id));
|
||||
}
|
||||
}
|
||||
|
||||
if (activeNewsSentiment === 'all') {
|
||||
return scopedNews;
|
||||
}
|
||||
return scopedNews.filter((item) => {
|
||||
const sentiment = String(item.sentiment || '').trim().toLowerCase() || 'neutral';
|
||||
return sentiment === activeNewsSentiment;
|
||||
});
|
||||
}, [activeNewsCategory, activeNewsSentiment, newsCategories, visibleNews]);
|
||||
|
||||
const selectedRangeWindow = useMemo(() => {
|
||||
if (!selectedEventDate) return null;
|
||||
const endDate = new Date(`${selectedEventDate}T00:00:00`);
|
||||
if (Number.isNaN(endDate.getTime())) return null;
|
||||
const startDate = new Date(endDate);
|
||||
startDate.setDate(startDate.getDate() - 6);
|
||||
return {
|
||||
startDate: startDate.toISOString().slice(0, 10),
|
||||
endDate: selectedEventDate
|
||||
};
|
||||
}, [selectedEventDate]);
|
||||
|
||||
const selectedRangeExplain = useMemo(() => {
|
||||
if (!selectedRangeWindow) return null;
|
||||
const key = `${selectedRangeWindow.startDate}:${selectedRangeWindow.endDate}`;
|
||||
return newsSnapshot?.rangeExplainCache?.[key] || null;
|
||||
}, [newsSnapshot, selectedRangeWindow]);
|
||||
|
||||
const selectedStory = useMemo(() => {
|
||||
const storyCache = newsSnapshot?.storyCache;
|
||||
if (!storyCache || typeof storyCache !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const keys = Object.keys(storyCache).sort();
|
||||
if (!keys.length) {
|
||||
return null;
|
||||
}
|
||||
return storyCache[keys[keys.length - 1]] || null;
|
||||
}, [newsSnapshot]);
|
||||
|
||||
const selectedSimilarDays = useMemo(() => {
|
||||
if (!selectedEventDate) {
|
||||
return null;
|
||||
}
|
||||
const similarCache = newsSnapshot?.similarDaysCache;
|
||||
if (!similarCache || typeof similarCache !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return similarCache[selectedEventDate] || null;
|
||||
}, [newsSnapshot, selectedEventDate]);
|
||||
|
||||
const latestSignal = tickerSignals[0] || null;
|
||||
const priceColor = selectedTicker?.change > 0 ? '#00C853' : selectedTicker?.change < 0 ? '#FF1744' : '#000000';
|
||||
const exposureWeight = holding && Number.isFinite(Number(holding.weight)) ? Number(holding.weight) * 100 : null;
|
||||
const recentTrade = tickerTrades[0] || null;
|
||||
|
||||
const ohlcSeries = useMemo(() => {
|
||||
const raw = ohlcHistoryByTicker?.[selectedSymbol];
|
||||
return Array.isArray(raw) ? raw.filter((candle) => Number.isFinite(Number(candle.close))).slice(-60) : [];
|
||||
}, [ohlcHistoryByTicker, selectedSymbol]);
|
||||
|
||||
const priceSeries = useMemo(() => {
|
||||
const raw = priceHistoryByTicker?.[selectedSymbol];
|
||||
return Array.isArray(raw) ? raw.filter((point) => Number.isFinite(Number(point.price))).slice(-60) : [];
|
||||
}, [priceHistoryByTicker, selectedSymbol]);
|
||||
|
||||
const explainSummary = useMemo(() => {
|
||||
if (!selectedSymbol) return [];
|
||||
const lines = [];
|
||||
|
||||
if (latestSignal) {
|
||||
const directionText = latestSignal.normalizedDirection === 'bullish'
|
||||
? '偏多'
|
||||
: latestSignal.normalizedDirection === 'bearish'
|
||||
? '偏空'
|
||||
: '观望';
|
||||
lines.push(`最新分析师结论为${directionText},来自${latestSignal.agentName}。`);
|
||||
} else {
|
||||
lines.push('当前还没有形成结构化分析师信号,更多依赖讨论内容和持仓状态。');
|
||||
}
|
||||
|
||||
if (holding) {
|
||||
lines.push(`组合当前持有 ${selectedSymbol},权重约 ${exposureWeight != null ? `${exposureWeight.toFixed(2)}%` : '0.00%'}。`);
|
||||
} else {
|
||||
lines.push(`组合当前未持有 ${selectedSymbol},仍处于观察阶段。`);
|
||||
}
|
||||
|
||||
if (recentTrade) {
|
||||
lines.push(`最近一次相关交易为${tradeSideLabel(recentTrade.side)},时间是 ${formatDateTime(recentTrade.timestamp)}。`);
|
||||
}
|
||||
|
||||
if (recentMentions.length > 0) {
|
||||
lines.push(`最近讨论中共有 ${recentMentions.length} 条直接提及 ${selectedSymbol} 的观点。`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}, [exposureWeight, holding, latestSignal, recentMentions.length, recentTrade, selectedSymbol]);
|
||||
|
||||
const explainTimeline = useMemo(() => {
|
||||
const signalEvents = tickerSignals.slice(0, 12).map((signal, index) => ({
|
||||
id: `signal-${signal.agentId}-${signal.date}-${index}`,
|
||||
type: 'signal',
|
||||
timestamp: new Date(`${signal.date}T08:00:00`).toISOString(),
|
||||
title: `${signal.agentName} 给出${signal.normalizedDirection === 'bullish' ? '看涨' : signal.normalizedDirection === 'bearish' ? '看跌' : '中性'}信号`,
|
||||
meta: signal.role,
|
||||
body: typeof signal.real_return === 'number'
|
||||
? `后验收益 ${signal.real_return >= 0 ? '+' : ''}${(signal.real_return * 100).toFixed(2)}%`
|
||||
: '该信号暂未完成后验评估',
|
||||
tone: signal.normalizedDirection === 'bullish' ? 'positive' : signal.normalizedDirection === 'bearish' ? 'negative' : 'neutral'
|
||||
}));
|
||||
|
||||
const mentionEvents = recentMentions.slice(0, 12).map((message, index) => ({
|
||||
id: `mention-${message.feedId || message.id}-${index}`,
|
||||
type: 'mention',
|
||||
timestamp: message.timestamp,
|
||||
title: `${message.agent || '未知角色'}在${message.conferenceTitle || '讨论流'}中提及 ${selectedSymbol}`,
|
||||
meta: message.conferenceTitle || (message.feedType === 'conference' ? '投资讨论' : '即时消息'),
|
||||
body: snippetText(message.content, selectedSymbol),
|
||||
tone: 'neutral'
|
||||
}));
|
||||
|
||||
const tradeEvents = tickerTrades.slice(0, 12).map((trade, index) => ({
|
||||
id: `trade-${trade.id || `${trade.ticker}-${trade.timestamp}-${index}`}`,
|
||||
type: 'trade',
|
||||
timestamp: trade.timestamp,
|
||||
title: `${tradeSideLabel(trade.side)} ${trade.qty} 股`,
|
||||
meta: '交易执行',
|
||||
body: `成交价 $${Number(trade.price).toFixed(2)}`,
|
||||
tone: trade.side === 'LONG' ? 'positive' : trade.side === 'SHORT' ? 'negative' : 'neutral'
|
||||
}));
|
||||
|
||||
const fallbackTimeline = [...signalEvents, ...mentionEvents, ...tradeEvents]
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
.slice(0, 24)
|
||||
.map((event) => ({
|
||||
...event,
|
||||
dateKey: eventDateKey(event.timestamp),
|
||||
category: resolveEventCategory(event)
|
||||
}));
|
||||
if (!explainEventsSnapshot) {
|
||||
return fallbackTimeline;
|
||||
}
|
||||
|
||||
const dbSignalEvents = (Array.isArray(explainEventsSnapshot.signals) ? explainEventsSnapshot.signals : [])
|
||||
.map((signal, index) => {
|
||||
if (signal?.type === 'signal' && signal?.timestamp) {
|
||||
return signal;
|
||||
}
|
||||
const normalized = normalizeSignalRow(signal, index);
|
||||
if (!normalized) return null;
|
||||
return {
|
||||
id: normalized.id,
|
||||
type: 'signal',
|
||||
timestamp: normalized.timestamp || (normalized.date ? new Date(`${normalized.date}T08:00:00`).toISOString() : null),
|
||||
title: `${normalized.agentName} 给出${
|
||||
normalized.normalizedDirection === 'bullish'
|
||||
? '看涨'
|
||||
: normalized.normalizedDirection === 'bearish'
|
||||
? '看跌'
|
||||
: '中性'
|
||||
}信号`,
|
||||
meta: normalized.role,
|
||||
body: typeof normalized.real_return === 'number'
|
||||
? `后验收益 ${normalized.real_return >= 0 ? '+' : ''}${(normalized.real_return * 100).toFixed(2)}%`
|
||||
: '该信号暂未完成后验评估',
|
||||
tone: normalized.normalizedDirection === 'bullish'
|
||||
? 'positive'
|
||||
: normalized.normalizedDirection === 'bearish'
|
||||
? 'negative'
|
||||
: 'neutral'
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const dbMentionEvents = (Array.isArray(explainEventsSnapshot.events) ? explainEventsSnapshot.events : [])
|
||||
.map((event, index) => {
|
||||
if (event?.type === 'mention' && event?.timestamp) {
|
||||
return event;
|
||||
}
|
||||
const normalized = normalizeMentionRow(event, index);
|
||||
if (!normalized) return null;
|
||||
return {
|
||||
id: normalized.id,
|
||||
type: 'mention',
|
||||
timestamp: normalized.timestamp,
|
||||
title: `${normalized.agent || '未知角色'}在${normalized.conferenceTitle || '讨论流'}中提及 ${selectedSymbol}`,
|
||||
meta: normalized.conferenceTitle || (normalized.feedType === 'conference' ? '投资讨论' : '即时消息'),
|
||||
body: snippetText(normalized.content, selectedSymbol),
|
||||
tone: 'neutral'
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const dbTradeEvents = (Array.isArray(explainEventsSnapshot.trades) ? explainEventsSnapshot.trades : [])
|
||||
.map((trade, index) => {
|
||||
if (trade?.type === 'trade' && trade?.timestamp) {
|
||||
return trade;
|
||||
}
|
||||
const normalized = normalizeTradeRow(trade, index);
|
||||
if (!normalized) return null;
|
||||
return {
|
||||
id: normalized.id,
|
||||
type: 'trade',
|
||||
timestamp: normalized.timestamp,
|
||||
title: `${tradeSideLabel(normalized.side)} ${normalized.qty} 股`,
|
||||
meta: '交易执行',
|
||||
body: `成交价 $${Number(normalized.price).toFixed(2)}`,
|
||||
tone: normalized.side === 'LONG' ? 'positive' : normalized.side === 'SHORT' ? 'negative' : 'neutral'
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const dbEvents = [
|
||||
...dbSignalEvents,
|
||||
...dbMentionEvents,
|
||||
...dbTradeEvents
|
||||
]
|
||||
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
.slice(0, 24)
|
||||
.map((event) => ({
|
||||
...event,
|
||||
dateKey: eventDateKey(event.timestamp),
|
||||
category: resolveEventCategory(event)
|
||||
}));
|
||||
|
||||
return dbEvents.length > 0 ? dbEvents : fallbackTimeline;
|
||||
}, [explainEventsSnapshot, recentMentions, selectedSymbol, tickerSignals, tickerTrades]);
|
||||
|
||||
const availableEventDates = useMemo(
|
||||
() => Array.from(new Set(explainTimeline.map((event) => event.dateKey).filter(Boolean))),
|
||||
[explainTimeline]
|
||||
);
|
||||
|
||||
const eventCategoryCounts = useMemo(() => {
|
||||
const scopedEvents = selectedEventDate
|
||||
? explainTimeline.filter((event) => event.dateKey === selectedEventDate)
|
||||
: explainTimeline;
|
||||
const counts = { all: scopedEvents.length };
|
||||
scopedEvents.forEach((event) => {
|
||||
counts[event.category] = (counts[event.category] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
}, [explainTimeline, selectedEventDate]);
|
||||
|
||||
const visibleExplainEvents = useMemo(() => explainTimeline.filter((event) => {
|
||||
if (selectedEventDate && event.dateKey !== selectedEventDate) {
|
||||
return false;
|
||||
}
|
||||
if (activeEventCategory !== 'all' && event.category !== activeEventCategory) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}), [activeEventCategory, explainTimeline, selectedEventDate]);
|
||||
|
||||
const chartModel = useMemo(() => {
|
||||
const width = 720;
|
||||
const height = 220;
|
||||
const padding = 18;
|
||||
if (!ohlcSeries.length && !priceSeries.length) {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
padding,
|
||||
path: '',
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
markers: [],
|
||||
candles: [],
|
||||
linePoints: [],
|
||||
bucketCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
if (ohlcSeries.length > 1) {
|
||||
const prices = ohlcSeries.flatMap((candle) => [Number(candle.low), Number(candle.high)]);
|
||||
const minPrice = Math.min(...prices);
|
||||
const maxPrice = Math.max(...prices);
|
||||
const span = maxPrice - minPrice || 1;
|
||||
const innerWidth = width - padding * 2;
|
||||
const innerHeight = height - padding * 2;
|
||||
const candleWidth = Math.max(8, Math.min(18, (innerWidth / ohlcSeries.length) * 0.55));
|
||||
const startTime = parsePointTime({ timestamp: ohlcSeries[0]?.time });
|
||||
const endTime = parsePointTime({ timestamp: ohlcSeries[ohlcSeries.length - 1]?.time });
|
||||
const timeSpan = Math.max(endTime - startTime, 1);
|
||||
|
||||
const candles = ohlcSeries.map((candle, index) => {
|
||||
const centerX = padding + ((index + 0.5) * innerWidth) / Math.max(ohlcSeries.length, 1);
|
||||
const openY = height - padding - ((Number(candle.open) - minPrice) / span) * innerHeight;
|
||||
const closeY = height - padding - ((Number(candle.close) - minPrice) / span) * innerHeight;
|
||||
const highY = height - padding - ((Number(candle.high) - minPrice) / span) * innerHeight;
|
||||
const lowY = height - padding - ((Number(candle.low) - minPrice) / span) * innerHeight;
|
||||
return {
|
||||
...candle,
|
||||
id: `${candle.time || index}`,
|
||||
centerX,
|
||||
x: centerX - candleWidth / 2,
|
||||
width: candleWidth,
|
||||
openY,
|
||||
closeY,
|
||||
highY,
|
||||
lowY,
|
||||
bodyY: Math.min(openY, closeY),
|
||||
bodyHeight: Math.max(Math.abs(closeY - openY), 2)
|
||||
};
|
||||
});
|
||||
|
||||
const explainMarkers = explainTimeline.slice(0, 8).map((event) => {
|
||||
const timestamp = new Date(event.timestamp).getTime();
|
||||
if (!Number.isFinite(timestamp)) return null;
|
||||
const ratio = Math.min(1, Math.max(0, (timestamp - startTime) / timeSpan));
|
||||
const nearestCandleIndex = candles.length <= 1
|
||||
? 0
|
||||
: Math.min(candles.length - 1, Math.max(0, Math.round(ratio * Math.max(candles.length - 1, 1))));
|
||||
const nearestCandle = candles[nearestCandleIndex] || null;
|
||||
const x = nearestCandle ? nearestCandle.centerX : padding + ratio * innerWidth;
|
||||
const price = nearestCandle ? Number(nearestCandle.close) : Number(ohlcSeries[ohlcSeries.length - 1]?.close ?? maxPrice);
|
||||
const y = height - padding - ((price - minPrice) / span) * innerHeight;
|
||||
return { ...event, x, y, isSelected: event.dateKey === selectedEventDate, markerType: 'event' };
|
||||
}).filter(Boolean);
|
||||
|
||||
const newsMarkers = tickerNewsTimeline.slice(-20).map((item, index) => {
|
||||
const timestamp = new Date(`${item.date}T12:00:00`).getTime();
|
||||
if (!Number.isFinite(timestamp)) return null;
|
||||
const ratio = Math.min(1, Math.max(0, (timestamp - startTime) / timeSpan));
|
||||
const nearestCandleIndex = candles.length <= 1
|
||||
? 0
|
||||
: Math.min(candles.length - 1, Math.max(0, Math.round(ratio * Math.max(candles.length - 1, 1))));
|
||||
const nearestCandle = candles[nearestCandleIndex] || null;
|
||||
const x = nearestCandle ? nearestCandle.centerX : padding + ratio * innerWidth;
|
||||
const price = nearestCandle ? Number(nearestCandle.close) : Number(ohlcSeries[ohlcSeries.length - 1]?.close ?? maxPrice);
|
||||
const y = height - padding - ((price - minPrice) / span) * innerHeight;
|
||||
return {
|
||||
id: item.id || `news-marker-${index}`,
|
||||
title: item.topTitle || `当日 ${item.count} 条新闻`,
|
||||
dateKey: item.dateKey,
|
||||
tone: 'news',
|
||||
x,
|
||||
y,
|
||||
isSelected: item.dateKey === selectedEventDate,
|
||||
markerType: 'news',
|
||||
count: item.count
|
||||
};
|
||||
}).filter(Boolean);
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
padding,
|
||||
path: '',
|
||||
minPrice,
|
||||
maxPrice,
|
||||
markers: [...newsMarkers, ...explainMarkers],
|
||||
candles,
|
||||
linePoints: [],
|
||||
bucketCount: candles.length
|
||||
};
|
||||
}
|
||||
|
||||
const prices = priceSeries.map((point) => Number(point.price));
|
||||
const minPrice = Math.min(...prices);
|
||||
const maxPrice = Math.max(...prices);
|
||||
const span = maxPrice - minPrice || 1;
|
||||
const innerWidth = width - padding * 2;
|
||||
const innerHeight = height - padding * 2;
|
||||
const startTime = parsePointTime(priceSeries[0]);
|
||||
const endTime = parsePointTime(priceSeries[priceSeries.length - 1]);
|
||||
const timeSpan = Math.max(endTime - startTime, 1);
|
||||
const candles = aggregatePriceSeriesToCandles(priceSeries);
|
||||
|
||||
const linePoints = priceSeries.map((point, index) => {
|
||||
const x = padding + (innerWidth * index) / Math.max(priceSeries.length - 1, 1);
|
||||
const y = height - padding - ((Number(point.price) - minPrice) / span) * innerHeight;
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
const candleWidth = candles.length > 1
|
||||
? Math.max(8, Math.min(24, (innerWidth / candles.length) * 0.58))
|
||||
: 14;
|
||||
|
||||
const mappedCandles = candles.map((candle, index) => {
|
||||
const centerX = padding + ((index + 0.5) * innerWidth) / Math.max(candles.length, 1);
|
||||
const openY = height - padding - ((candle.open - minPrice) / span) * innerHeight;
|
||||
const closeY = height - padding - ((candle.close - minPrice) / span) * innerHeight;
|
||||
const highY = height - padding - ((candle.high - minPrice) / span) * innerHeight;
|
||||
const lowY = height - padding - ((candle.low - minPrice) / span) * innerHeight;
|
||||
return {
|
||||
...candle,
|
||||
centerX,
|
||||
x: centerX - candleWidth / 2,
|
||||
width: candleWidth,
|
||||
openY,
|
||||
closeY,
|
||||
highY,
|
||||
lowY,
|
||||
bodyY: Math.min(openY, closeY),
|
||||
bodyHeight: Math.max(Math.abs(closeY - openY), 2)
|
||||
};
|
||||
});
|
||||
|
||||
const explainMarkers = explainTimeline.slice(0, 8).map((event) => {
|
||||
const timestamp = new Date(event.timestamp).getTime();
|
||||
if (!Number.isFinite(timestamp)) return null;
|
||||
const ratio = Math.min(1, Math.max(0, (timestamp - startTime) / timeSpan));
|
||||
const nearestCandleIndex = mappedCandles.length <= 1
|
||||
? 0
|
||||
: Math.min(
|
||||
mappedCandles.length - 1,
|
||||
Math.max(0, Math.round(ratio * Math.max(mappedCandles.length - 1, 1)))
|
||||
);
|
||||
const nearestCandle = mappedCandles[nearestCandleIndex] || null;
|
||||
const x = nearestCandle ? nearestCandle.centerX : padding + ratio * innerWidth;
|
||||
const price = nearestCandle ? nearestCandle.close : Number(priceSeries[priceSeries.length - 1]?.price ?? prices[prices.length - 1]);
|
||||
const y = height - padding - ((price - minPrice) / span) * innerHeight;
|
||||
return { ...event, x, y, isSelected: event.dateKey === selectedEventDate, markerType: 'event' };
|
||||
}).filter(Boolean);
|
||||
|
||||
const newsMarkers = tickerNewsTimeline.slice(-20).map((item, index) => {
|
||||
const timestamp = new Date(`${item.date}T12:00:00`).getTime();
|
||||
if (!Number.isFinite(timestamp)) return null;
|
||||
const ratio = Math.min(1, Math.max(0, (timestamp - startTime) / timeSpan));
|
||||
const nearestCandleIndex = mappedCandles.length <= 1
|
||||
? 0
|
||||
: Math.min(
|
||||
mappedCandles.length - 1,
|
||||
Math.max(0, Math.round(ratio * Math.max(mappedCandles.length - 1, 1)))
|
||||
);
|
||||
const nearestCandle = mappedCandles[nearestCandleIndex] || null;
|
||||
const x = nearestCandle ? nearestCandle.centerX : padding + ratio * innerWidth;
|
||||
const price = nearestCandle ? nearestCandle.close : Number(priceSeries[priceSeries.length - 1]?.price ?? prices[prices.length - 1]);
|
||||
const y = height - padding - ((price - minPrice) / span) * innerHeight;
|
||||
return {
|
||||
id: item.id || `news-marker-${index}`,
|
||||
title: item.topTitle || `当日 ${item.count} 条新闻`,
|
||||
dateKey: item.dateKey,
|
||||
tone: 'news',
|
||||
x,
|
||||
y,
|
||||
isSelected: item.dateKey === selectedEventDate,
|
||||
markerType: 'news',
|
||||
count: item.count
|
||||
};
|
||||
}).filter(Boolean);
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
padding,
|
||||
path: buildLinePath(priceSeries, width, height, padding),
|
||||
minPrice,
|
||||
maxPrice,
|
||||
markers: [...newsMarkers, ...explainMarkers],
|
||||
candles: mappedCandles,
|
||||
linePoints,
|
||||
bucketCount: mappedCandles.length
|
||||
};
|
||||
}, [explainTimeline, ohlcSeries, priceSeries, selectedEventDate, tickerNewsTimeline]);
|
||||
|
||||
return {
|
||||
availableSymbols,
|
||||
selectedTicker,
|
||||
holding,
|
||||
tickerSignals,
|
||||
signalSummary,
|
||||
tickerTrades,
|
||||
recentMentions,
|
||||
tickerNews,
|
||||
visibleNews,
|
||||
newsCategories,
|
||||
visibleNewsByCategory,
|
||||
selectedRangeWindow,
|
||||
selectedRangeExplain,
|
||||
selectedStory,
|
||||
selectedSimilarDays,
|
||||
latestSignal,
|
||||
priceColor,
|
||||
exposureWeight,
|
||||
recentTrade,
|
||||
ohlcSeries,
|
||||
priceSeries,
|
||||
explainSummary,
|
||||
explainTimeline,
|
||||
availableEventDates,
|
||||
eventCategoryCounts,
|
||||
visibleExplainEvents,
|
||||
chartModel
|
||||
};
|
||||
}
|
||||
156
frontend/src/components/explain/useExplainModel.test.jsx
Normal file
156
frontend/src/components/explain/useExplainModel.test.jsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import useExplainModel from './useExplainModel';
|
||||
|
||||
function buildBaseProps() {
|
||||
return {
|
||||
tickers: [{ symbol: 'AAPL', price: 105.12, change: 1.34 }],
|
||||
holdings: [{ ticker: 'AAPL', quantity: 10, weight: 0.2, marketValue: 1051.2, currentPrice: 105.12 }],
|
||||
trades: [],
|
||||
leaderboard: [],
|
||||
feed: [],
|
||||
priceHistoryByTicker: {
|
||||
AAPL: [
|
||||
{ timestamp: '2026-03-08T10:00:00Z', price: 100 },
|
||||
{ timestamp: '2026-03-09T10:00:00Z', price: 103 },
|
||||
{ timestamp: '2026-03-10T10:00:00Z', price: 105 }
|
||||
]
|
||||
},
|
||||
ohlcHistoryByTicker: {},
|
||||
selectedSymbol: 'AAPL',
|
||||
explainEventsSnapshot: {
|
||||
signals: [
|
||||
{
|
||||
id: 'sig-1',
|
||||
ticker: 'AAPL',
|
||||
date: '2026-03-10',
|
||||
signal: 'bullish',
|
||||
confidence: 0.88,
|
||||
agent_id: 'agent-1',
|
||||
agent_name: 'Alpha',
|
||||
role: 'technical'
|
||||
}
|
||||
],
|
||||
events: [
|
||||
{
|
||||
id: 'mention-1',
|
||||
timestamp: '2026-03-10T12:00:00Z',
|
||||
agent: 'Research',
|
||||
body: 'AAPL momentum remains strong after earnings.',
|
||||
meta: 'morning note'
|
||||
}
|
||||
],
|
||||
trades: [
|
||||
{
|
||||
id: 'trade-1',
|
||||
timestamp: '2026-03-10T15:00:00Z',
|
||||
ticker: 'AAPL',
|
||||
side: 'LONG',
|
||||
qty: 5,
|
||||
price: 104.5
|
||||
}
|
||||
]
|
||||
},
|
||||
newsSnapshot: {
|
||||
items: [
|
||||
{
|
||||
id: 'news-1',
|
||||
ticker: 'AAPL',
|
||||
date: '2026-03-10T09:00:00Z',
|
||||
title: 'Apple earnings beat expectations',
|
||||
summary: 'Revenue topped consensus estimates.',
|
||||
source: 'Polygon',
|
||||
sentiment: 'positive'
|
||||
},
|
||||
{
|
||||
id: 'news-2',
|
||||
ticker: 'AAPL',
|
||||
date: '2026-03-09T09:00:00Z',
|
||||
title: 'Supplier update',
|
||||
summary: 'Supply chain improves.',
|
||||
source: 'Polygon',
|
||||
sentiment: 'negative'
|
||||
}
|
||||
],
|
||||
timeline: [
|
||||
{ id: 'timeline-1', date: '2026-03-09', count: 1, source_count: 1, top_title: 'Supplier update' },
|
||||
{ id: 'timeline-2', date: '2026-03-10', count: 1, source_count: 1, top_title: 'Apple earnings beat expectations' }
|
||||
],
|
||||
categories: {
|
||||
earnings: {
|
||||
count: 1,
|
||||
article_ids: ['news-1']
|
||||
}
|
||||
},
|
||||
rangeExplainCache: {
|
||||
'2026-03-03:2026-03-10': {
|
||||
summary: '区间内主要由财报催化推动。'
|
||||
}
|
||||
},
|
||||
similarDaysCache: {
|
||||
'2026-03-10': {
|
||||
target_features: {
|
||||
sentiment_score: 0.5,
|
||||
n_articles: 2
|
||||
},
|
||||
items: [
|
||||
{
|
||||
date: '2026-02-18',
|
||||
score: 0.92,
|
||||
n_articles: 2,
|
||||
sentiment_score: 0.4
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
selectedEventDate: '2026-03-10',
|
||||
activeEventCategory: 'all',
|
||||
activeNewsCategory: 'earnings',
|
||||
activeNewsSentiment: 'all'
|
||||
};
|
||||
}
|
||||
|
||||
describe('useExplainModel', () => {
|
||||
it('derives visible news and range explain data from snapshots', () => {
|
||||
const { result } = renderHook(() => useExplainModel(buildBaseProps()));
|
||||
|
||||
expect(result.current.availableSymbols).toEqual(['AAPL']);
|
||||
expect(result.current.visibleNews).toHaveLength(1);
|
||||
expect(result.current.visibleNewsByCategory).toHaveLength(1);
|
||||
expect(result.current.visibleNewsByCategory[0].id).toBe('news-1');
|
||||
expect(result.current.selectedRangeWindow).toEqual({
|
||||
startDate: '2026-03-03',
|
||||
endDate: '2026-03-10'
|
||||
});
|
||||
expect(result.current.selectedRangeExplain).toEqual({
|
||||
summary: '区间内主要由财报催化推动。'
|
||||
});
|
||||
expect(result.current.selectedSimilarDays?.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('builds timeline, counts, and chart markers from explain data', () => {
|
||||
const { result } = renderHook(() => useExplainModel(buildBaseProps()));
|
||||
|
||||
expect(result.current.availableEventDates).toContain('2026-03-10');
|
||||
expect(result.current.eventCategoryCounts.all).toBe(3);
|
||||
expect(result.current.eventCategoryCounts.technical).toBe(1);
|
||||
expect(result.current.eventCategoryCounts.discussion).toBe(1);
|
||||
expect(result.current.eventCategoryCounts.trade).toBe(1);
|
||||
expect(result.current.visibleExplainEvents).toHaveLength(3);
|
||||
expect(result.current.chartModel.markers.length).toBeGreaterThan(0);
|
||||
expect(result.current.chartModel.path).toMatch(/^M/);
|
||||
});
|
||||
|
||||
it('filters visible news by sentiment when requested', () => {
|
||||
const props = buildBaseProps();
|
||||
props.activeNewsCategory = 'all';
|
||||
props.activeNewsSentiment = 'positive';
|
||||
|
||||
const { result } = renderHook(() => useExplainModel(props));
|
||||
|
||||
expect(result.current.visibleNewsByCategory).toHaveLength(1);
|
||||
expect(result.current.visibleNewsByCategory[0].id).toBe('news-1');
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,11 @@ export default defineConfig({
|
||||
allowedHosts: ["localhost", "trading.evoagents.cn","www.evoagents.cn"]
|
||||
},
|
||||
plugins: [react(), tsconfigPaths(),tailwindcss()],
|
||||
test: {
|
||||
environment: "jsdom"
|
||||
},
|
||||
preview: {
|
||||
host: "0.0.0.0",
|
||||
port: 4173
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user