From bddaf44ffc20e83d4908a3c3a516f64546059209 Mon Sep 17 00:00:00 2001 From: Celes Renata Date: Thu, 30 Apr 2026 06:09:05 +0000 Subject: [PATCH] feat: 90-day price range Y-axis scaling with breakthrough annotations Backend: - GET /api/market/prices/{ticker} now returns { bars, range_90d } with 90-day low/high computed from market_snapshots - POST /api/market/backfill/{ticker} fetches 90 days of daily bars from Polygon and inserts missing bars into market_snapshots - POST /api/market/backfill-all does the same for all active tickers Frontend: - Right Y-axis domain scaled to 90-day min/max (with 3% padding) - Green dashed reference line at 90-day high - Red dashed reference line at 90-day low - Labels show exact price on each reference line - Default limit bumped to 200 bars --- frontend/src/api/hooks.ts | 21 ++++- frontend/src/pages/CompanyDetail.tsx | 35 ++++++-- services/api/app.py | 124 ++++++++++++++++++++++++++- 3 files changed, 170 insertions(+), 10 deletions(-) diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 8ff1ac6..aaddd99 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -256,8 +256,13 @@ export interface MarketPrice { captured_at: string; } -export function useMarketPrices(ticker: string | undefined, limit = 30) { - return useGet( +export interface MarketPriceResponse { + bars: MarketPrice[]; + range_90d: { low: number | null; high: number | null }; +} + +export function useMarketPrices(ticker: string | undefined, limit = 200) { + return useGet( ['market-prices', ticker, limit], 'query', `/api/market/prices/${ticker}?limit=${limit}`, @@ -265,6 +270,18 @@ export function useMarketPrices(ticker: string | undefined, limit = 30) { ); } +/** Backfill 90 days of daily bars from Polygon for a single ticker. */ +export function useBackfillMarketPrices() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (ticker: string) => + apiPost<{ ticker: string; inserted: number; total_bars: number }>('query', `/api/market/backfill/${ticker}`, {}), + onSuccess: (_data, ticker) => { + qc.invalidateQueries({ queryKey: ['market-prices', 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 375883a..ec54a30 100644 --- a/frontend/src/pages/CompanyDetail.tsx +++ b/frontend/src/pages/CompanyDetail.tsx @@ -22,7 +22,7 @@ import { DataTable, type Column } from '../components/DataTable'; import type { Source, Alias, MacroImpactRecord, CompetitorRelationship, HistoricalPattern, CompetitiveSignal, CorporateDecision, TrendSummary, MarketPrice } from '../api/hooks'; import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, - CartesianGrid, Legend, + CartesianGrid, Legend, ReferenceLine, } from 'recharts'; const sourceCols: Column[] = [ @@ -46,7 +46,9 @@ export function CompanyDetailPage() { const { data: trends } = useTrends({ ticker: company?.ticker, limit: 200 }); const [selectedWindow, setSelectedWindow] = useState('7d'); const { data: trendHistory } = useTrendHistory({ ticker: company?.ticker, window: selectedWindow, limit: 500 }); - const { data: marketPrices } = useMarketPrices(company?.ticker, 200); + const { data: marketPriceData } = useMarketPrices(company?.ticker, 200); + const marketPrices = marketPriceData?.bars ?? []; + const range90d = marketPriceData?.range_90d ?? { low: null, high: null }; const { data: positions } = usePositions(company?.ticker); const [tab, setTab] = useState<'trends' | 'sources' | 'aliases' | 'macro' | 'competitors' | 'patterns' | 'signals' | 'decisions'>('trends'); @@ -88,7 +90,7 @@ export function CompanyDetailPage() { {tab === 'trends' && (
- +
)} @@ -680,7 +682,7 @@ function PositionCard({ positions, ticker }: { positions: import('../api/hooks') ); } -function TrendHistoryChart({ trends, latestTrends, ticker, marketPrices, selectedWindow, onWindowChange }: { trends: TrendSummary[]; latestTrends: TrendSummary[]; ticker: string; marketPrices: MarketPrice[]; selectedWindow: string; onWindowChange: (w: string) => void }) { +function TrendHistoryChart({ trends, latestTrends, ticker, marketPrices, range90d, selectedWindow, onWindowChange }: { trends: TrendSummary[]; latestTrends: TrendSummary[]; ticker: string; marketPrices: MarketPrice[]; range90d: { low: number | null; high: number | null }; selectedWindow: string; onWindowChange: (w: string) => void }) { // Determine the time range for the selected window to filter data const windowHours: Record = { @@ -816,11 +818,34 @@ function TrendHistoryChart({ trends, latestTrends, ticker, marketPrices, selecte tick={{ fill: '#e879f9', fontSize: 11 }} tickLine={{ stroke: '#475569' }} tickFormatter={(v) => `$${v}`} - domain={['dataMin - 2', 'dataMax + 2']} + domain={[ + range90d.low != null ? Math.floor(range90d.low * 0.97) : 'dataMin - 2', + range90d.high != null ? Math.ceil(range90d.high * 1.03) : 'dataMax + 2', + ]} /> )} + {hasPrice && range90d.high != null && ( + + )} + {hasPrice && range90d.low != null && ( + + )} >'l')::float) AS low_90d, + MAX((data->>'h')::float) AS high_90d + FROM market_snapshots + WHERE ticker = $1 AND snapshot_type = 'bar' + AND captured_at >= $2""", + ticker, cutoff_90d, + ) + low_90d = range_row["low_90d"] if range_row else None + high_90d = range_row["high_90d"] if range_row else None + + return { + "bars": results, + "range_90d": {"low": low_90d, "high": high_90d}, + } + + +@app.post("/api/market/backfill/{ticker}") +async def backfill_market_prices(ticker: str, days: int = Query(default=90, le=365)): + """Backfill daily OHLCV bars from Polygon for the last N days. + + Fetches daily aggregate bars from Polygon's range endpoint and inserts + any missing bars into market_snapshots (deduped by bar timestamp). + Returns the number of bars inserted. + """ + ticker = ticker.upper() + api_key = config.market_data.api_key + if not api_key: + raise HTTPException(503, "No market data API key configured") + + import hashlib + from datetime import date, timedelta + + import httpx + + to_date = date.today().isoformat() + from_date = (date.today() - timedelta(days=days)).isoformat() + + url = ( + f"{config.market_data.base_url}/v2/aggs/ticker/{ticker}" + f"/range/1/day/{from_date}/{to_date}" + ) + params = {"apiKey": api_key, "adjusted": "true", "sort": "asc", "limit": "500"} + + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(url, params=params) + resp.raise_for_status() + data = resp.json() + + bars = data.get("results", []) + if not bars: + return {"ticker": ticker, "inserted": 0, "total_bars": 0} + + # Find existing bar timestamps to avoid duplicates + existing = await pool.fetch( + """SELECT DISTINCT (data->>'t')::bigint AS bar_ts + FROM market_snapshots + WHERE ticker = $1 AND snapshot_type = 'bar'""", + ticker, + ) + existing_ts = {r["bar_ts"] for r in existing if r["bar_ts"] is not None} + + # Look up company_id (nullable) + company_row = await pool.fetchrow( + "SELECT id FROM companies WHERE ticker = $1", ticker, + ) + company_id = company_row["id"] if company_row else None + + inserted = 0 + for bar in bars: + bar_ts = bar.get("t") + if bar_ts is None or bar_ts in existing_ts: + continue + bar_json = json.dumps(bar) + content_hash = hashlib.sha256(bar_json.encode()).hexdigest() + captured_at = datetime.fromtimestamp(bar_ts / 1000, tz=timezone.utc) + await pool.execute( + """INSERT INTO market_snapshots + (company_id, ticker, snapshot_type, data, source_provider, captured_at, content_hash) + VALUES ($1, $2, 'bar', $3::jsonb, 'polygon_backfill', $4, $5)""", + company_id, ticker, bar_json, captured_at, content_hash, + ) + existing_ts.add(bar_ts) + inserted += 1 + + return {"ticker": ticker, "inserted": inserted, "total_bars": len(bars), "days": days} + + +@app.post("/api/market/backfill-all") +async def backfill_all_market_prices(days: int = Query(default=90, le=365)): + """Backfill daily bars for ALL active companies from Polygon. + + Iterates through all active tickers and calls the per-ticker backfill. + Returns a summary of results per ticker. + """ + api_key = config.market_data.api_key + if not api_key: + raise HTTPException(503, "No market data API key configured") + + rows = await pool.fetch( + "SELECT ticker FROM companies WHERE active = TRUE ORDER BY ticker", + ) + results = [] + for row in rows: + ticker = row["ticker"] + try: + result = await backfill_market_prices(ticker, days) + results.append(result) + except Exception as e: + logger.warning("Backfill failed for %s: %s", ticker, e) + results.append({"ticker": ticker, "inserted": 0, "error": str(e)}) + + total_inserted = sum(r.get("inserted", 0) for r in results) + return {"total_inserted": total_inserted, "tickers": len(results), "details": results} @app.get("/api/trends/{trend_id}")