确认PokieTicker新闻库数据源

This commit is contained in:
2026-03-16 02:19:25 +08:00
parent 78f133617f
commit 564c92c0c8
182 changed files with 6436 additions and 1050 deletions

View File

@@ -19,9 +19,9 @@ import AgentFeed from './components/AgentFeed';
import StockLogo from './components/StockLogo';
import StatisticsView from './components/StatisticsView';
import PerformanceView from './components/PerformanceView';
import AboutModal from './components/AboutModal';
import RulesView from './components/RulesView';
import StockExplainView from './components/StockExplainView.jsx';
import Header from './components/Header.jsx';
import WatchlistPanel from './components/WatchlistPanel.jsx';
// Utils
import { formatNumber, formatTickerPrice } from './utils/formatters';
@@ -39,9 +39,8 @@ export default function LiveTradingApp() {
const [currentDate, setCurrentDate] = useState(null);
const [progress, setProgress] = useState({ current: 0, total: 0 });
const [now, setNow] = useState(() => new Date());
const [showAboutModal, setShowAboutModal] = useState(false);
// View toggle: 'rules' | 'room' | 'chart' | 'statistics'
// View toggle: 'room' | 'explain' | 'chart' | 'statistics'
const [currentView, setCurrentView] = useState('chart'); // Start with chart, then animate to room
const [isInitialAnimating, setIsInitialAnimating] = useState(true);
const [lastUpdate, setLastUpdate] = useState(new Date());
@@ -71,6 +70,11 @@ export default function LiveTradingApp() {
// Ticker prices (now from real-time data)
const [tickers, setTickers] = useState(INITIAL_TICKERS);
const [rollingTickers, setRollingTickers] = useState({});
const [priceHistoryByTicker, setPriceHistoryByTicker] = useState({});
const [ohlcHistoryByTicker, setOhlcHistoryByTicker] = useState({});
const [explainEventsByTicker, setExplainEventsByTicker] = useState({});
const [selectedExplainSymbol, setSelectedExplainSymbol] = useState('');
const [historySourceByTicker, setHistorySourceByTicker] = useState({});
// Room bubbles
const [bubbles, setBubbles] = useState({});
@@ -84,10 +88,18 @@ export default function LiveTradingApp() {
const [marketStatus, setMarketStatus] = useState(null); // { status, status_text, ... }
const [virtualTime, setVirtualTime] = useState(null); // Virtual time from server (for mock mode)
const [dataSources, setDataSources] = useState(null);
const [runtimeConfig, setRuntimeConfig] = useState(null);
const [isWatchlistPanelOpen, setIsWatchlistPanelOpen] = useState(false);
const [watchlistDraftSymbols, setWatchlistDraftSymbols] = useState([]);
const [watchlistInputValue, setWatchlistInputValue] = useState('');
const [watchlistFeedback, setWatchlistFeedback] = useState(null);
const [isWatchlistSaving, setIsWatchlistSaving] = useState(false);
const clientRef = useRef(null);
const containerRef = useRef(null);
const agentFeedRef = useRef(null);
const isWatchlistSavingRef = useRef(false);
const requestedStockHistoryRef = useRef(new Set());
// Track last virtual time update to calculate increment
const lastVirtualTimeRef = useRef(null);
@@ -96,12 +108,311 @@ export default function LiveTradingApp() {
// Last day history for replay
const [lastDayHistory, setLastDayHistory] = useState([]);
const buildTickersFromSymbols = useCallback((symbols, previousTickers = []) => {
if (!Array.isArray(symbols) || symbols.length === 0) {
return previousTickers;
}
return symbols
.filter((symbol) => typeof symbol === 'string' && symbol.trim())
.map((symbol) => {
const normalized = symbol.trim().toUpperCase();
const existing = previousTickers.find((ticker) => ticker.symbol === normalized);
return existing || {
symbol: normalized,
price: null,
change: null
};
});
}, []);
const normalizePriceHistory = useCallback((payload) => {
if (!payload || typeof payload !== 'object') {
return {};
}
const normalized = {};
Object.entries(payload).forEach(([symbol, points]) => {
const ticker = String(symbol || '').trim().toUpperCase();
if (!ticker || !Array.isArray(points)) {
return;
}
normalized[ticker] = points
.map((point) => {
if (Array.isArray(point) && point.length >= 2) {
const [label, value] = point;
const price = Number(value);
if (!label || !Number.isFinite(price)) return null;
return {
timestamp: String(label),
label: String(label),
price
};
}
if (point && typeof point === 'object') {
const rawTimestamp = point.timestamp ?? point.t ?? point.date ?? point.label;
const price = Number(point.price ?? point.v ?? point.value ?? point.close);
if (!rawTimestamp || !Number.isFinite(price)) return null;
return {
timestamp: String(rawTimestamp),
label: String(rawTimestamp),
price
};
}
return null;
})
.filter(Boolean)
.slice(-120);
});
return normalized;
}, []);
// Determine if LIVE tab should be enabled
const isLiveEnabled = useMemo(() => {
if (!marketStatus) return false;
return marketStatus.status === 'open';
}, [marketStatus]);
const displayTickers = useMemo(() => {
const symbols = runtimeConfig?.tickers;
if (Array.isArray(symbols) && symbols.length > 0) {
return buildTickersFromSymbols(symbols, tickers);
}
return tickers;
}, [buildTickersFromSymbols, runtimeConfig, tickers]);
const runtimeWatchlistSymbols = useMemo(() => {
const symbols = runtimeConfig?.tickers;
if (Array.isArray(symbols) && symbols.length > 0) {
return symbols
.filter((symbol) => typeof symbol === 'string' && symbol.trim())
.map((symbol) => symbol.trim().toUpperCase());
}
return displayTickers
.map((ticker) => ticker.symbol)
.filter((symbol) => typeof symbol === 'string' && symbol.trim());
}, [displayTickers, runtimeConfig]);
useEffect(() => {
const symbols = displayTickers
.map((ticker) => ticker.symbol)
.filter((symbol) => typeof symbol === 'string' && symbol.trim());
if (!symbols.length) {
setSelectedExplainSymbol('');
return;
}
if (!selectedExplainSymbol || !symbols.includes(selectedExplainSymbol)) {
setSelectedExplainSymbol(symbols[0]);
}
}, [displayTickers, selectedExplainSymbol]);
const watchlistSuggestions = useMemo(
() => INITIAL_TICKERS.map((ticker) => ticker.symbol).filter((symbol, index, list) => list.indexOf(symbol) === index),
[]
);
const isWatchlistDraftDirty = useMemo(() => {
if (watchlistInputValue.trim()) {
return true;
}
if (watchlistDraftSymbols.length !== runtimeWatchlistSymbols.length) {
return true;
}
return watchlistDraftSymbols.some((symbol, index) => symbol !== runtimeWatchlistSymbols[index]);
}, [runtimeWatchlistSymbols, watchlistDraftSymbols, watchlistInputValue]);
const marketStatusLabel = useMemo(() => {
if (!marketStatus) {
return null;
}
const raw = typeof marketStatus.status_text === 'string' ? marketStatus.status_text.trim() : '';
const normalized = raw.toLowerCase();
if (normalized === 'market closed (non-trading day)') {
return '休市';
}
if (normalized === 'market open') {
return '开盘';
}
if (normalized === 'market closed') {
return '收盘';
}
return raw || (marketStatus.status === 'open' ? '开盘' : '收盘');
}, [marketStatus]);
const priceSourceLabel = 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]);
const parseWatchlistInput = useCallback((value) => {
if (typeof value !== 'string') {
return [];
}
return Array.from(
new Set(
value
.split(/[\s,]+/)
.map((symbol) => symbol.trim().toUpperCase())
.filter(Boolean)
)
);
}, []);
const commitWatchlistInput = useCallback((value) => {
const parsed = parseWatchlistInput(value);
if (parsed.length === 0) {
return [];
}
setWatchlistDraftSymbols((prev) => Array.from(new Set([...prev, ...parsed])));
setWatchlistInputValue('');
if (watchlistFeedback) {
setWatchlistFeedback(null);
}
return parsed;
}, [parseWatchlistInput, watchlistFeedback]);
const handleWatchlistRemove = useCallback((symbolToRemove) => {
setWatchlistDraftSymbols((prev) => prev.filter((symbol) => symbol !== symbolToRemove));
if (watchlistFeedback) {
setWatchlistFeedback(null);
}
}, [watchlistFeedback]);
const handleWatchlistPanelToggle = useCallback(() => {
setIsWatchlistPanelOpen((open) => {
const nextOpen = !open;
if (nextOpen) {
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
setWatchlistInputValue('');
setWatchlistFeedback(null);
}
return nextOpen;
});
}, [runtimeWatchlistSymbols]);
const handleWatchlistInputChange = useCallback((value) => {
setWatchlistInputValue(value);
if (watchlistFeedback) {
setWatchlistFeedback(null);
}
}, [watchlistFeedback]);
const handleWatchlistInputKeyDown = useCallback((e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
commitWatchlistInput(watchlistInputValue);
}
}, [commitWatchlistInput, watchlistInputValue]);
const handleWatchlistSuggestionClick = useCallback((symbol) => {
if (watchlistDraftSymbols.includes(symbol)) {
return;
}
setWatchlistDraftSymbols((prev) => [...prev, symbol]);
if (watchlistFeedback) {
setWatchlistFeedback(null);
}
}, [watchlistDraftSymbols, watchlistFeedback]);
const handleWatchlistRestoreCurrent = useCallback(() => {
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
setWatchlistInputValue('');
setWatchlistFeedback(null);
}, [runtimeWatchlistSymbols]);
const handleWatchlistRestoreDefault = useCallback(() => {
setWatchlistDraftSymbols(watchlistSuggestions);
setWatchlistInputValue('');
setWatchlistFeedback(null);
}, [watchlistSuggestions]);
const handleWatchlistSave = useCallback(() => {
const pendingTickers = parseWatchlistInput(watchlistInputValue);
const nextTickers = Array.from(new Set([...watchlistDraftSymbols, ...pendingTickers]));
if (nextTickers.length === 0) {
setWatchlistFeedback({ type: 'error', text: '至少输入 1 个有效股票代码' });
return;
}
if (!clientRef.current) {
setWatchlistFeedback({ type: 'error', text: '连接未就绪,稍后重试' });
return;
}
setIsWatchlistSaving(true);
setWatchlistFeedback(null);
setWatchlistDraftSymbols(nextTickers);
setWatchlistInputValue('');
const success = clientRef.current.send({
type: 'update_watchlist',
tickers: nextTickers
});
if (!success) {
setIsWatchlistSaving(false);
setWatchlistFeedback({ type: 'error', text: '发送失败,请检查连接状态' });
}
}, [parseWatchlistInput, watchlistDraftSymbols, watchlistInputValue]);
const requestStockHistory = useCallback((symbol, { force = false } = {}) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) {
return false;
}
if (!force && requestedStockHistoryRef.current.has(normalized)) {
return false;
}
const success = clientRef.current.send({
type: 'get_stock_history',
ticker: normalized,
lookback_days: 120
});
if (success) {
requestedStockHistoryRef.current.add(normalized);
}
return success;
}, []);
const requestStockExplainEvents = useCallback((symbol) => {
const normalized = typeof symbol === 'string' ? symbol.trim().toUpperCase() : '';
if (!normalized || !clientRef.current) {
return false;
}
return clientRef.current.send({
type: 'get_stock_explain_events',
ticker: normalized
});
}, []);
// Switch away from LIVE tab when market closes
useEffect(() => {
if (!isLiveEnabled && chartTab === 'live') {
@@ -109,6 +420,27 @@ export default function LiveTradingApp() {
}
}, [isLiveEnabled, chartTab]);
useEffect(() => {
if (!isWatchlistPanelOpen || !isWatchlistDraftDirty) {
setWatchlistDraftSymbols(runtimeWatchlistSymbols);
if (!isWatchlistPanelOpen) {
setWatchlistInputValue('');
}
}
}, [isWatchlistDraftDirty, isWatchlistPanelOpen, runtimeWatchlistSymbols]);
useEffect(() => {
isWatchlistSavingRef.current = isWatchlistSaving;
}, [isWatchlistSaving]);
useEffect(() => {
if (currentView !== 'explain' || !selectedExplainSymbol) {
return;
}
requestStockHistory(selectedExplainSymbol);
requestStockExplainEvents(selectedExplainSymbol);
}, [currentView, requestStockExplainEvents, requestStockHistory, selectedExplainSymbol]);
// Clock - use virtual time if available (for mock mode)
useEffect(() => {
if (virtualTime) {
@@ -253,6 +585,10 @@ export default function LiveTradingApp() {
// Error response (for fast forward errors)
error: (e) => {
console.error('[Error]', e.message);
if (isWatchlistSavingRef.current) {
setIsWatchlistSaving(false);
setWatchlistFeedback({ type: 'error', text: e.message || '更新 watchlist 失败' });
}
// Handle fast forward errors
if (e.message && e.message.includes('fast forward')) {
@@ -307,6 +643,12 @@ export default function LiveTradingApp() {
if (state.data_sources) {
setDataSources(state.data_sources);
}
if (state.runtime_config) {
setRuntimeConfig(state.runtime_config);
}
if (Array.isArray(state.tickers) && state.tickers.length > 0) {
setTickers(prevTickers => buildTickersFromSymbols(state.tickers, prevTickers));
}
// 检查是否是mock模式
const isMockMode = state.is_mock_mode === true;
if (state.market_status) {
@@ -356,6 +698,9 @@ export default function LiveTradingApp() {
if (state.dashboard.leaderboard) setLeaderboard(state.dashboard.leaderboard);
}
if (state.realtime_prices) updateTickersFromPrices(state.realtime_prices);
if (state.price_history) {
setPriceHistoryByTicker(normalizePriceHistory(state.price_history));
}
// Load and process historical feed data
if (state.feed_history && Array.isArray(state.feed_history)) {
@@ -388,6 +733,75 @@ export default function LiveTradingApp() {
}
},
runtime_assets_reloaded: (e) => {
if (e.runtime_config_applied) {
setRuntimeConfig(e.runtime_config_applied);
}
if (Array.isArray(e.runtime_config_applied?.tickers)) {
setTickers(prevTickers => buildTickersFromSymbols(e.runtime_config_applied.tickers, prevTickers));
setWatchlistDraftSymbols(e.runtime_config_applied.tickers.map((symbol) => String(symbol).trim().toUpperCase()));
setWatchlistInputValue('');
}
if (isWatchlistSavingRef.current) {
setIsWatchlistSaving(false);
}
addSystemMessage('运行时配置已热更新');
},
watchlist_updated: (e) => {
if (Array.isArray(e.tickers)) {
const normalizedTickers = e.tickers.map((symbol) => String(symbol).trim().toUpperCase());
requestedStockHistoryRef.current = new Set(
Array.from(requestedStockHistoryRef.current).filter((symbol) => normalizedTickers.includes(symbol))
);
setRuntimeConfig((prev) => ({
...(prev || {}),
tickers: normalizedTickers
}));
setTickers((prevTickers) => buildTickersFromSymbols(normalizedTickers, prevTickers));
setWatchlistDraftSymbols(normalizedTickers);
setWatchlistInputValue('');
}
setIsWatchlistSaving(false);
setWatchlistFeedback({
type: 'success',
text: `已更新为 ${Array.isArray(e.tickers) ? e.tickers.join(', ') : '最新列表'}`
});
},
stock_history_loaded: (e) => {
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
if (!symbol) {
return;
}
if (Array.isArray(e.prices)) {
setOhlcHistoryByTicker((prev) => ({
...prev,
[symbol]: e.prices
}));
setHistorySourceByTicker((prev) => ({
...prev,
[symbol]: e.source || null
}));
}
},
stock_explain_events_loaded: (e) => {
const symbol = typeof e.ticker === 'string' ? e.ticker.trim().toUpperCase() : '';
if (!symbol) {
return;
}
setExplainEventsByTicker((prev) => ({
...prev,
[symbol]: {
events: Array.isArray(e.events) ? e.events : [],
signals: Array.isArray(e.signals) ? e.signals : [],
trades: Array.isArray(e.trades) ? e.trades : []
}
}));
},
// Real-time price updates
price_update: (e) => {
try {
@@ -402,6 +816,24 @@ export default function LiveTradingApp() {
setIsConnected(true);
console.log(`[Price Update] ${symbol}: $${price} (ret: ${ret !== undefined ? ret.toFixed(2) : 'N/A'}%)`);
setPriceHistoryByTicker((prev) => {
const ticker = String(symbol).trim().toUpperCase();
const nextPoint = {
timestamp: new Date().toISOString(),
label: now.toISOString(),
price: Number(price)
};
const existing = Array.isArray(prev[ticker]) ? prev[ticker] : [];
const lastPoint = existing[existing.length - 1];
if (lastPoint && Number(lastPoint.price) === Number(nextPoint.price)) {
return prev;
}
return {
...prev,
[ticker]: [...existing, nextPoint].slice(-120)
};
});
// Update ticker price with animation
setTickers(prevTickers => {
return prevTickers.map(ticker => {
@@ -714,7 +1146,7 @@ export default function LiveTradingApp() {
clientRef.current.disconnect();
}
};
}, []); // Empty dependency array - only run once on mount
}, [addSystemMessage, buildTickersFromSymbols, processFeedEvent, processHistoricalFeed]); // Only reconnect if handlers change
// Resizing handlers
const handleMouseDown = (e) => {
@@ -755,10 +1187,7 @@ export default function LiveTradingApp() {
{/* Header */}
<div className="header">
<Header
onEvoTradersClick={() => setShowAboutModal(true)}
evoTradersLinkStyle="default"
/>
<Header />
<div className="header-right" style={{ display: 'flex', alignItems: 'center', gap: 24, marginLeft: 'auto', flexWrap: 'wrap', minWidth: 0 }}>
{/* Mock Mode Indicator */}
@@ -885,21 +1314,41 @@ export default function LiveTradingApp() {
<>
<span className="status-sep">·</span>
<span className={`market-text ${serverMode === 'backtest' ? 'backtest' : (marketStatus.status === 'open' ? 'open' : 'closed')}`}>
{marketStatus.status_text || (marketStatus.status === 'open' ? '开盘' : '收盘')}
{marketStatusLabel}
</span>
</>
)}
{dataSources?.last_success?.prices && (
{priceSourceLabel && (
<>
<span className="status-sep">·</span>
<span className="market-text backtest">
DATA {String(dataSources.last_success.prices).toUpperCase()}
{priceSourceLabel}
</span>
</>
)}
<span className="status-sep">·</span>
<span className="time-text">{lastUpdate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}</span>
</div>
<WatchlistPanel
isOpen={isWatchlistPanelOpen}
isConnected={isConnected}
isSaving={isWatchlistSaving}
draftSymbols={watchlistDraftSymbols}
inputValue={watchlistInputValue}
feedback={watchlistFeedback}
suggestions={watchlistSuggestions}
onToggle={handleWatchlistPanelToggle}
onClose={() => setIsWatchlistPanelOpen(false)}
onInputChange={handleWatchlistInputChange}
onInputKeyDown={handleWatchlistInputKeyDown}
onAdd={() => commitWatchlistInput(watchlistInputValue)}
onRemove={handleWatchlistRemove}
onRestoreCurrent={handleWatchlistRestoreCurrent}
onRestoreDefault={handleWatchlistRestoreDefault}
onSuggestionClick={handleWatchlistSuggestionClick}
onSave={handleWatchlistSave}
/>
</div>
</div>
@@ -910,7 +1359,7 @@ export default function LiveTradingApp() {
<div className="ticker-track">
{[0, 1].map((groupIdx) => (
<div key={groupIdx} className="ticker-group">
{tickers.map(ticker => (
{displayTickers.map(ticker => (
<div key={`${ticker.symbol}-${groupIdx}`} className="ticker-item">
<StockLogo ticker={ticker.symbol} size={16} />
<span className="ticker-symbol">{ticker.symbol}</span>
@@ -947,13 +1396,6 @@ export default function LiveTradingApp() {
<div className="chart-section">
<div className="view-container">
<div className="view-nav-bar">
<button
className={`view-nav-btn ${currentView === 'rules' ? 'active' : ''}`}
onClick={() => setCurrentView('rules')}
>
规则
</button>
<button
className={`view-nav-btn ${currentView === 'room' ? 'active' : ''}`}
onClick={() => setCurrentView('room')}
@@ -961,6 +1403,13 @@ export default function LiveTradingApp() {
交易室
</button>
<button
className={`view-nav-btn ${currentView === 'explain' ? 'active' : ''}`}
onClick={() => setCurrentView('explain')}
>
个股解释
</button>
<button
className={`view-nav-btn ${currentView === 'chart' ? 'active' : ''}`}
onClick={() => setCurrentView('chart')}
@@ -977,12 +1426,15 @@ export default function LiveTradingApp() {
</div>
{/* Slider container with four views */}
<div className={`view-slider-four ${currentView === 'rules' ? 'show-rules' : currentView === 'room' ? 'show-room' : currentView === 'statistics' ? 'show-statistics' : 'show-chart'} ${!isInitialAnimating ? 'normal-speed' : ''}`}>
{/* Rules View Panel */}
<div className="view-panel">
<RulesView />
</div>
<div className={`view-slider-four ${
currentView === 'room'
? 'show-room'
: currentView === 'explain'
? 'show-explain'
: currentView === 'statistics'
? 'show-statistics'
: 'show-chart'
} ${!isInitialAnimating ? 'normal-speed' : ''}`}>
{/* Room View Panel */}
<div className="view-panel">
<RoomView
@@ -994,6 +1446,23 @@ export default function LiveTradingApp() {
/>
</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}
/>
</div>
{/* Chart View Panel */}
<div className="view-panel">
<div className="chart-container">
@@ -1059,9 +1528,6 @@ export default function LiveTradingApp() {
</div>
</div>
</>
{/* About Modal */}
{showAboutModal && <AboutModal onClose={() => setShowAboutModal(false)} />}
</div>
);
}