diff --git a/backend/data/market_ingest.py b/backend/data/market_ingest.py index 6c6bb26..5af55c1 100644 --- a/backend/data/market_ingest.py +++ b/backend/data/market_ingest.py @@ -8,6 +8,7 @@ from typing import Iterable from backend.data.market_store import MarketStore from backend.data.news_alignment import align_news_for_symbol +from backend.data.provider_router import ProviderRouter from backend.data.polygon_client import ( fetch_news, fetch_ohlc, @@ -24,6 +25,35 @@ def _default_start(years: int = 2) -> str: return (datetime.now(timezone.utc).date() - timedelta(days=years * 366)).isoformat() +def _normalize_provider_news_rows(ticker: str, news_items: Iterable[Any]) -> list[dict]: + rows: list[dict] = [] + for item in news_items: + payload = item.model_dump() if hasattr(item, "model_dump") else dict(item or {}) + related = payload.get("related") + if isinstance(related, str): + related_list = [value.strip().upper() for value in related.split(",") if value.strip()] + elif isinstance(related, list): + related_list = [str(value).strip().upper() for value in related if str(value).strip()] + else: + related_list = [] + if ticker not in related_list: + related_list.append(ticker) + rows.append( + { + "title": payload.get("title"), + "description": payload.get("summary"), + "summary": payload.get("summary"), + "article_url": payload.get("url"), + "published_utc": payload.get("date"), + "publisher": payload.get("source"), + "tickers": related_list, + "category": payload.get("category"), + "raw_json": payload, + } + ) + return rows + + def ingest_ticker_history( symbol: str, *, @@ -114,6 +144,80 @@ def update_ticker_incremental( } +def refresh_news_incremental( + symbol: str, + *, + end_date: str | None = None, + store: MarketStore | None = None, +) -> dict: + """Incrementally fetch company news using the configured provider router.""" + ticker = normalize_symbol(symbol) + market_store = store or MarketStore() + watermarks = market_store.get_ticker_watermarks(ticker) + end = end_date or _today_utc() + start_news = ( + (datetime.fromisoformat(watermarks["last_news_fetch"]) + timedelta(days=1)).date().isoformat() + if watermarks.get("last_news_fetch") + else _default_start() + ) + + if start_news > end: + return { + "symbol": ticker, + "start_news_date": start_news, + "end_date": end, + "news": 0, + "aligned": 0, + } + + router = ProviderRouter() + news_items, source = router.get_company_news( + ticker=ticker, + start_date=start_news, + end_date=end, + limit=1000, + ) + news_rows = _normalize_provider_news_rows(ticker, news_items) + news_count = market_store.upsert_news(ticker, news_rows, source=source) if news_rows else 0 + aligned_count = align_news_for_symbol(market_store, ticker) + market_store.update_fetch_watermark( + symbol=ticker, + news_date=end if news_rows or watermarks.get("last_news_fetch") else None, + ) + + return { + "symbol": ticker, + "start_news_date": start_news, + "end_date": end, + "news": news_count, + "aligned": aligned_count, + "source": source, + } + + +def refresh_news_for_symbols( + symbols: Iterable[str], + *, + end_date: str | None = None, + store: MarketStore | None = None, +) -> list[dict]: + """Incrementally refresh company news for a list of tickers.""" + market_store = store or MarketStore() + results = [] + for symbol in symbols: + ticker = normalize_symbol(symbol) + if not ticker: + continue + results.append( + refresh_news_incremental( + ticker, + end_date=end_date, + store=market_store, + ) + ) + return results + + def ingest_symbols( symbols: Iterable[str], *, diff --git a/backend/services/gateway_cycle_support.py b/backend/services/gateway_cycle_support.py index 363e07c..a04cf36 100644 --- a/backend/services/gateway_cycle_support.py +++ b/backend/services/gateway_cycle_support.py @@ -7,7 +7,7 @@ import asyncio import logging from typing import Any -from backend.data.market_ingest import ingest_symbols +from backend.data.market_ingest import ingest_symbols, refresh_news_for_symbols from backend.domains import trading as trading_domain from backend.utils.msg_adapter import FrontendAdapter @@ -200,6 +200,23 @@ async def run_live_cycle(gateway: Any, date: str, tickers: list[str]) -> None: trading_date = gateway.market_service.get_live_trading_date() logger.info("Live cycle: triggered=%s, trading_date=%s", date, trading_date) + try: + news_refresh = await asyncio.to_thread( + refresh_news_for_symbols, + tickers, + end_date=trading_date, + store=gateway.storage.market_store, + ) + logger.info( + "News refresh complete: %s", + ", ".join( + f"{item['symbol']} news={item['news']}" + for item in news_refresh + ) or "no symbols", + ) + except Exception as exc: + logger.warning("Live cycle news refresh failed: %s", exc) + await gateway.state_sync.on_cycle_start(trading_date) gateway._dashboard.update(date=trading_date, status="Analyzing...") diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 97ad9b7..a4ce83f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -466,6 +466,7 @@ export default function LiveTradingApp() { currentDate={currentDate} stockRequests={stockRequests} agentRequests={agentRequests} + agentProfilesByAgent={agentProfilesByAgent} leftWidth={leftWidth} isResizing={isResizing} onMouseDown={() => useUIStore.getState().setIsResizing(true)} diff --git a/frontend/src/components/AgentFeed.jsx b/frontend/src/components/AgentFeed.jsx index c45bf09..36bef9e 100644 --- a/frontend/src/components/AgentFeed.jsx +++ b/frontend/src/components/AgentFeed.jsx @@ -35,14 +35,22 @@ const stripMarkdown = (text) => { .replace(/^[-=]+$/gm, ''); }; -const AgentFeed = forwardRef(({ feed, leaderboard }, ref) => { +const AgentFeed = forwardRef(({ feed, leaderboard, agentProfilesByAgent }, ref) => { const feedContentRef = useRef(null); const [highlightedId, setHighlightedId] = useState(null); const [selectedAgent, setSelectedAgent] = useState('all'); const [dropdownOpen, setDropdownOpen] = useState(false); const getAgentModelInfo = (agentId) => { - if (!leaderboard || !agentId) return { modelName: null, modelProvider: null }; + if (!agentId) return { modelName: null, modelProvider: null }; + const profile = agentProfilesByAgent?.[agentId]; + if (profile?.model_name) { + return { + modelName: profile.model_name, + modelProvider: profile.model_provider + }; + } + if (!leaderboard) return { modelName: null, modelProvider: null }; const agentData = leaderboard.find(lb => lb.id === agentId || lb.agentId === agentId); return { modelName: agentData?.modelName, @@ -52,7 +60,17 @@ const AgentFeed = forwardRef(({ feed, leaderboard }, ref) => { // Get agent info by name const getAgentInfoByName = (agentName) => { - if (!leaderboard || !agentName) return null; + if (!agentName) return null; + const agentConfig = AGENTS.find((agent) => agent.name === agentName); + const profile = agentConfig ? agentProfilesByAgent?.[agentConfig.id] : null; + if (agentConfig && profile?.model_name) { + return { + agentId: agentConfig.id, + modelName: profile.model_name, + modelProvider: profile.model_provider + }; + } + if (!leaderboard) return null; const agentData = leaderboard.find(lb => lb.name === agentName || lb.agentName === agentName); if (!agentData) return null; return { diff --git a/frontend/src/components/AppShell.jsx b/frontend/src/components/AppShell.jsx index 6d4b03b..86eca78 100644 --- a/frontend/src/components/AppShell.jsx +++ b/frontend/src/components/AppShell.jsx @@ -128,6 +128,7 @@ export default function AppShell({ stockRequests, // Agent request handlers agentRequests, + agentProfilesByAgent, // Layout leftWidth, isResizing, @@ -440,6 +441,7 @@ export default function AppShell({ bubbles={bubbles} bubbleFor={bubbleFor} leaderboard={leaderboard} + agentProfilesByAgent={agentProfilesByAgent} feed={feed} onJumpToMessage={handleJumpToMessage} onOpenLaunchConfig={() => setIsRuntimeSettingsOpen(true)} @@ -518,6 +520,7 @@ export default function AppShell({ trades={trades} holdings={holdings} stats={stats} + portfolioData={portfolioData} baseline_vw={portfolioData.baseline_vw} equity={portfolioData.equity} leaderboard={leaderboard} @@ -535,7 +538,7 @@ export default function AppShell({ {/* Right Panel: Agent Feed */}
}> - +
diff --git a/frontend/src/components/RoomView.jsx b/frontend/src/components/RoomView.jsx index 04ffafb..07acd41 100644 --- a/frontend/src/components/RoomView.jsx +++ b/frontend/src/components/RoomView.jsx @@ -47,7 +47,7 @@ function getRankMedal(rank) { * Supports click and hover (1.5s) to show agent performance cards * Supports replay mode - completely independent from live mode */ -export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJumpToMessage, onOpenLaunchConfig }) { +export default function RoomView({ bubbles, bubbleFor, leaderboard, agentProfilesByAgent, feed, onJumpToMessage, onOpenLaunchConfig }) { const canvasRef = useRef(null); const containerRef = useRef(null); @@ -162,11 +162,14 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJump const getAgentData = (agentId) => { const agent = AGENTS.find(a => a.id === agentId); if (!agent) return null; + const profile = agentProfilesByAgent?.[agentId] || null; // If no leaderboard data, return agent with default stats if (!leaderboard || !Array.isArray(leaderboard)) { return { ...agent, + modelName: profile?.model_name || null, + modelProvider: profile?.model_provider || null, bull: { n: 0, win: 0, unknown: 0 }, bear: { n: 0, win: 0, unknown: 0 }, winRate: null, @@ -181,6 +184,8 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJump if (!leaderboardData) { return { ...agent, + modelName: profile?.model_name || null, + modelProvider: profile?.model_provider || null, bull: { n: 0, win: 0, unknown: 0 }, bear: { n: 0, win: 0, unknown: 0 }, winRate: null, @@ -193,6 +198,8 @@ export default function RoomView({ bubbles, bubbleFor, leaderboard, feed, onJump return { ...agent, ...leaderboardData, + modelName: profile?.model_name || leaderboardData.modelName || null, + modelProvider: profile?.model_provider || leaderboardData.modelProvider || null, avatar: agent.avatar // Always use the frontend's avatar URL }; }; diff --git a/frontend/src/components/StatisticsView.jsx b/frontend/src/components/StatisticsView.jsx index 3af1577..e3f8b77 100644 --- a/frontend/src/components/StatisticsView.jsx +++ b/frontend/src/components/StatisticsView.jsx @@ -8,12 +8,36 @@ import { formatNumber, formatDateTime } from '../utils/formatters'; * Left: Performance Overview (35%) | Right: Holdings + Trades (65%) * No scrolling - content fits within viewport with pagination */ -export default function StatisticsView({ trades, holdings, stats, baseline_vw, equity, leaderboard }) { +export default function StatisticsView({ trades, holdings, stats, baseline_vw, equity, leaderboard, portfolioData }) { const [holdingsPage, setHoldingsPage] = useState(1); const [tradesPage, setTradesPage] = useState(1); const holdingsPerPage = 5; const tradesPerPage = 8; + const effectiveStats = React.useMemo(() => { + const base = stats && typeof stats === 'object' ? stats : {}; + const netValue = Number(portfolioData?.netValue ?? 0); + const pnl = Number(portfolioData?.pnl ?? 0); + const hasPortfolioValue = Number.isFinite(netValue) && netValue > 0; + const hasMeaningfulStats = Number(base?.totalAssetValue ?? 0) > 0; + + if (hasMeaningfulStats || !hasPortfolioValue) { + return base; + } + + const cashHolding = Array.isArray(holdings) + ? holdings.find((item) => String(item?.ticker || '').toUpperCase() === 'CASH') + : null; + + return { + ...base, + totalAssetValue: netValue, + totalReturn: pnl, + cashPosition: Number(cashHolding?.marketValue ?? cashHolding?.currentPrice ?? 0), + totalTrades: Array.isArray(trades) ? trades.length : 0, + }; + }, [holdings, portfolioData, stats, trades]); + // Calculate pagination for holdings const totalHoldingsPages = Math.ceil(holdings.length / holdingsPerPage); const holdingsStartIndex = (holdingsPage - 1) * holdingsPerPage; @@ -28,12 +52,12 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e // Calculate excess return (Evatraders return - benchmark value-weighted return) const calculateExcessReturn = () => { - if (!stats || !baseline_vw || baseline_vw.length === 0) { + if (!effectiveStats || !baseline_vw || baseline_vw.length === 0) { return null; } // Get Evatraders return from stats - const evatradersReturn = stats.totalReturn || 0; // Already in percentage + const evatradersReturn = effectiveStats.totalReturn || 0; // Already in percentage // Calculate benchmark return from baseline_vw // baseline_vw format: [{t: timestamp, v: value}, ...] or [value, ...] @@ -130,7 +154,7 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e borderRight: '2px solid #e0e0e0', overflow: 'hidden' }}> - {stats ? ( + {effectiveStats ? (
- ${formatNumber(stats.totalAssetValue || 0)} + ${formatNumber(effectiveStats.totalAssetValue || 0)}
@@ -272,10 +296,10 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e
= 0 ? '#00C853' : '#FF1744', + color: (effectiveStats.totalReturn || 0) >= 0 ? '#00C853' : '#FF1744', fontFamily: '"Courier New", monospace' }}> - {(stats.totalReturn || 0) >= 0 ? '+' : ''}{(stats.totalReturn || 0).toFixed(2)}% + {(effectiveStats.totalReturn || 0) >= 0 ? '+' : ''}{(effectiveStats.totalReturn || 0).toFixed(2)}%
@@ -304,7 +328,7 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e color: '#000000', fontFamily: '"Courier New", monospace' }}> - ${formatNumber(stats.cashPosition || 0)} + ${formatNumber(effectiveStats.cashPosition || 0)} @@ -330,7 +354,7 @@ export default function StatisticsView({ trades, holdings, stats, baseline_vw, e color: '#000000', fontFamily: '"Courier New", monospace' }}> - {stats.totalTrades || 0} + {effectiveStats.totalTrades || 0} diff --git a/frontend/src/components/StockExplainView.jsx b/frontend/src/components/StockExplainView.jsx index 7ab5d22..bb71e86 100644 --- a/frontend/src/components/StockExplainView.jsx +++ b/frontend/src/components/StockExplainView.jsx @@ -175,7 +175,7 @@ export default function StockExplainView({ if (!selectedSymbol || !selectedEventDate || !onRequestNewsForDate) { return; } - if (Array.isArray(newsSnapshot?.byDate?.[selectedEventDate]) && newsSnapshot.byDate[selectedEventDate].length > 0) { + if (Object.prototype.hasOwnProperty.call(newsSnapshot?.byDate || {}, selectedEventDate)) { return; } onRequestNewsForDate(selectedSymbol, selectedEventDate); @@ -185,21 +185,21 @@ export default function StockExplainView({ if (!selectedSymbol || !onRequestStory || !currentDate) { return; } - if (selectedStory?.story) { + if (Object.prototype.hasOwnProperty.call(newsSnapshot?.storyCache || {}, currentDate)) { return; } onRequestStory(selectedSymbol, currentDate); - }, [currentDate, onRequestStory, selectedStory, selectedSymbol]); + }, [currentDate, newsSnapshot, onRequestStory, selectedStory, selectedSymbol]); useEffect(() => { if (!selectedSymbol || !selectedEventDate || !onRequestSimilarDays) { return; } - if (selectedSimilarDays?.items?.length) { + if (Object.prototype.hasOwnProperty.call(newsSnapshot?.similarDaysCache || {}, selectedEventDate)) { return; } onRequestSimilarDays(selectedSymbol, selectedEventDate); - }, [onRequestSimilarDays, selectedEventDate, selectedSimilarDays, selectedSymbol]); + }, [newsSnapshot, onRequestSimilarDays, selectedEventDate, selectedSimilarDays, selectedSymbol]); useEffect(() => { if (!selectedSymbol || !onRequestTechnicalIndicators) { diff --git a/start-dev.sh b/start-dev.sh index c053527..ce99506 100755 --- a/start-dev.sh +++ b/start-dev.sh @@ -43,7 +43,8 @@ start_service() { --port "${port}" \ --reload \ --reload-dir backend \ - --log-level info & + --log-level warning \ + --no-access-log & PIDS+=($!) }