diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 880d8c9..1b5d91d 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -245,6 +245,26 @@ export function useTrendHistory(params?: { ticker?: string; window?: string; lim return useGet(['trend-history', params], 'query', path); } +export interface MarketPrice { + ticker: string; + close: number; + open: number; + high: number; + low: number; + volume: number; + bar_timestamp: number; + captured_at: string; +} + +export function useMarketPrices(ticker: string | undefined, limit = 30) { + return useGet( + ['market-prices', ticker, limit], + 'query', + `/api/market/prices/${ticker}?limit=${limit}`, + !!ticker, + ); +} + export function useTrend(id: string | undefined) { return useGet(['trend', id], 'query', `/api/trends/${id}`, !!id); } diff --git a/frontend/src/pages/CompanyDetail.tsx b/frontend/src/pages/CompanyDetail.tsx index 1d341cd..8b64bde 100644 --- a/frontend/src/pages/CompanyDetail.tsx +++ b/frontend/src/pages/CompanyDetail.tsx @@ -13,10 +13,11 @@ import { useCorporateDecisions, useTrends, useTrendHistory, + useMarketPrices, } from '../api/hooks'; import { StatusBadge, ConfidenceBar, LoadingSpinner, Card } from '../components/ui'; import { DataTable, type Column } from '../components/DataTable'; -import type { Source, Alias, MacroImpactRecord, CompetitorRelationship, HistoricalPattern, CompetitiveSignal, CorporateDecision, TrendSummary } from '../api/hooks'; +import type { Source, Alias, MacroImpactRecord, CompetitorRelationship, HistoricalPattern, CompetitiveSignal, CorporateDecision, TrendSummary, MarketPrice } from '../api/hooks'; import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend, @@ -42,6 +43,7 @@ export function CompanyDetailPage() { const { data: decisions } = useCorporateDecisions(company?.ticker); const { data: trends } = useTrends({ ticker: company?.ticker, limit: 200 }); const { data: trendHistory } = useTrendHistory({ ticker: company?.ticker, limit: 500 }); + const { data: marketPrices } = useMarketPrices(company?.ticker); const [tab, setTab] = useState<'trends' | 'sources' | 'aliases' | 'macro' | 'competitors' | 'patterns' | 'signals' | 'decisions'>('trends'); if (isLoading || !company) return ; @@ -80,7 +82,7 @@ export function CompanyDetailPage() { {tab === 'trends' && ( - + )} {tab === 'sources' && ( @@ -563,11 +565,12 @@ interface ChartPoint { direction: number; directionLabel: string; window: string; + price?: number; } function TrendTooltip({ active, payload, label }: Record) { if (!active) return null; - const items = payload as Array<{ name: string; value: number; color: string }> | undefined; + const items = payload as Array<{ name: string; value: number; color: string; dataKey: string }> | undefined; if (!items?.length) return null; return (
@@ -575,14 +578,16 @@ function TrendTooltip({ active, payload, label }: Record) { {items.map((item, i) => (
{item.name}: - {item.value}% + + {item.dataKey === 'price' ? `$${item.value.toFixed(2)}` : `${item.value}%`} +
))}
); } -function TrendHistoryChart({ trends, latestTrends, ticker }: { trends: TrendSummary[]; latestTrends: TrendSummary[]; ticker: string }) { +function TrendHistoryChart({ trends, latestTrends, ticker, marketPrices }: { trends: TrendSummary[]; latestTrends: TrendSummary[]; ticker: string; marketPrices: MarketPrice[] }) { const [selectedWindow, setSelectedWindow] = useState('7d'); // Use history data for charts @@ -590,16 +595,32 @@ function TrendHistoryChart({ trends, latestTrends, ticker }: { trends: TrendSumm .filter((t) => t.entity_id === ticker && t.window === selectedWindow) .sort((a, b) => new Date(a.generated_at).getTime() - new Date(b.generated_at).getTime()); - const chartData: ChartPoint[] = filtered.map((t) => ({ - time: new Date(t.generated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }), - timestamp: new Date(t.generated_at).getTime(), - strength: +(t.trend_strength * 100).toFixed(1), - confidence: +(t.confidence * 100).toFixed(1), - contradiction: +(t.contradiction_score * 100).toFixed(1), - direction: DIRECTION_VALUE[t.trend_direction] ?? 0, - directionLabel: t.trend_direction, - window: t.window, - })); + // Build a price lookup by date (closest price per day) + const priceByDay = new Map(); + for (const p of marketPrices ?? []) { + if (p.bar_timestamp && p.close != null) { + const d = new Date(p.bar_timestamp).toISOString().slice(0, 10); + priceByDay.set(d, p.close); + } + } + + const chartData: ChartPoint[] = filtered.map((t) => { + const trendDate = new Date(t.generated_at).toISOString().slice(0, 10); + const price = priceByDay.get(trendDate); + return { + time: new Date(t.generated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }), + timestamp: new Date(t.generated_at).getTime(), + strength: +(t.trend_strength * 100).toFixed(1), + confidence: +(t.confidence * 100).toFixed(1), + contradiction: +(t.contradiction_score * 100).toFixed(1), + direction: DIRECTION_VALUE[t.trend_direction] ?? 0, + directionLabel: t.trend_direction, + window: t.window, + price, + }; + }); + + const hasPrice = chartData.some((pt) => pt.price != null); // Available windows from the data (check both history and latest) const allTrends = [...(trends ?? []), ...(latestTrends ?? [])]; @@ -652,14 +673,26 @@ function TrendHistoryChart({ trends, latestTrends, ticker }: { trends: TrendSumm tickLine={{ stroke: '#475569' }} /> `${v}%`} /> + {hasPrice && ( + `$${v}`} + domain={['dataMin - 2', 'dataMax + 2']} + /> + )} + {hasPrice && ( + + )} diff --git a/services/api/app.py b/services/api/app.py index 328e844..6f3a1e8 100644 --- a/services/api/app.py +++ b/services/api/app.py @@ -465,6 +465,56 @@ async def list_trend_history( return results +@app.get("/api/market/prices/{ticker}") +async def get_market_prices( + ticker: str, + limit: int = Query(default=30, le=200), +): + """Return historical close prices for a ticker from market_snapshots. + + Each row has a bar_date (from the Polygon bar timestamp) and OHLCV data. + Ordered oldest-first for chart rendering. + """ + ticker = ticker.upper() + rows = await pool.fetch( + """SELECT + captured_at, + (data->>'c')::float AS close, + (data->>'o')::float AS open, + (data->>'h')::float AS high, + (data->>'l')::float AS low, + (data->>'v')::float AS volume, + (data->>'t')::bigint AS bar_timestamp + FROM market_snapshots + WHERE ticker = $1 AND snapshot_type = 'bar' + ORDER BY captured_at ASC + LIMIT $2""", + ticker, limit, + ) + results = [] + seen_dates: set[str] = set() + for r in rows: + # Deduplicate by bar_timestamp (same day bar captured multiple times) + bar_ts = r["bar_timestamp"] + if bar_ts is None: + continue + date_key = str(bar_ts) + if date_key in seen_dates: + continue + seen_dates.add(date_key) + results.append({ + "ticker": ticker, + "close": r["close"], + "open": r["open"], + "high": r["high"], + "low": r["low"], + "volume": r["volume"], + "bar_timestamp": bar_ts, + "captured_at": r["captured_at"].isoformat() if r["captured_at"] else None, + }) + return results + + @app.get("/api/trends/{trend_id}") async def get_trend(trend_id: str): """Get a single trend summary by ID."""