feat: 90-day price range Y-axis scaling with breakthrough annotations
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/build-2 Pipeline was successful
ci/woodpecker/push/build-3 Pipeline was successful
ci/woodpecker/push/build-1 Pipeline was successful
ci/woodpecker/push/finalize Pipeline was successful
Build and Push / lint-and-test (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.adapters.broker_adapter name:broker-adapter]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.aggregation.worker name:aggregation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.extractor.worker name:extractor]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.ingestion.worker name:ingestion]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.lake_publisher.worker name:lake-publisher]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.parser.worker name:parser]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.recommendation.worker name:recommendation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.scheduler.app name:scheduler]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.api.app:app --host 0.0.0.0 --port 8000 name:query-api]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.risk.app:app --host 0.0.0.0 --port 8000 name:risk]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.symbol_registry.app:app --host 0.0.0.0 --port 8000 name:symbol-registry]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.trading.app:app --host 0.0.0.0 --port 8000 name:trading-engine]) (push) Has been cancelled
Build and Push / build-dashboard (push) Has been cancelled
Build and Push / build-superset (push) Has been cancelled
Build and Push / integration-test (push) Has been cancelled
Build and Push / beta-gate (push) Has been cancelled

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
This commit is contained in:
Celes Renata
2026-04-30 06:09:05 +00:00
parent 5209cc522e
commit bddaf44ffc
3 changed files with 170 additions and 10 deletions
+19 -2
View File
@@ -256,8 +256,13 @@ export interface MarketPrice {
captured_at: string;
}
export function useMarketPrices(ticker: string | undefined, limit = 30) {
return useGet<MarketPrice[]>(
export interface MarketPriceResponse {
bars: MarketPrice[];
range_90d: { low: number | null; high: number | null };
}
export function useMarketPrices(ticker: string | undefined, limit = 200) {
return useGet<MarketPriceResponse>(
['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<TrendSummary>(['trend', id], 'query', `/api/trends/${id}`, !!id);
}
+30 -5
View File
@@ -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<Source>[] = [
@@ -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' && (
<div className="space-y-4">
<PositionCard positions={positions ?? []} ticker={company.ticker} />
<TrendHistoryChart trends={trendHistory ?? []} latestTrends={trends ?? []} ticker={company.ticker} marketPrices={marketPrices ?? []} selectedWindow={selectedWindow} onWindowChange={setSelectedWindow} />
<TrendHistoryChart trends={trendHistory ?? []} latestTrends={trends ?? []} ticker={company.ticker} marketPrices={marketPrices} range90d={range90d} selectedWindow={selectedWindow} onWindowChange={setSelectedWindow} />
</div>
)}
@@ -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<string, number> = {
@@ -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',
]}
/>
)}
<Tooltip content={TrendTooltip} />
<Legend wrapperStyle={{ color: '#94a3b8', fontSize: 12 }} />
{hasPrice && range90d.high != null && (
<ReferenceLine
yAxisId="right"
y={range90d.high}
stroke="#22c55e"
strokeDasharray="6 3"
strokeWidth={1.5}
label={{ value: `90d High $${range90d.high.toFixed(2)}`, position: 'insideTopRight', fill: '#22c55e', fontSize: 10 }}
/>
)}
{hasPrice && range90d.low != null && (
<ReferenceLine
yAxisId="right"
y={range90d.low}
stroke="#ef4444"
strokeDasharray="6 3"
strokeWidth={1.5}
label={{ value: `90d Low $${range90d.low.toFixed(2)}`, position: 'insideBottomRight', fill: '#ef4444', fontSize: 10 }}
/>
)}
<Line
yAxisId="left"
type="monotone"
+121 -3
View File
@@ -471,12 +471,13 @@ async def list_trend_history(
@app.get("/api/market/prices/{ticker}")
async def get_market_prices(
ticker: str,
limit: int = Query(default=30, le=200),
limit: int = Query(default=200, le=500),
):
"""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.
Ordered oldest-first for chart rendering. Also returns 90-day high/low
computed from all bars in the last 90 days.
"""
ticker = ticker.upper()
rows = await pool.fetch(
@@ -515,7 +516,124 @@ async def get_market_prices(
"bar_timestamp": bar_ts,
"captured_at": r["captured_at"].isoformat() if r["captured_at"] else None,
})
return results
# Compute 90-day high/low from all bars in the window
cutoff_90d = datetime.now(timezone.utc) - timedelta(days=90)
range_row = await pool.fetchrow(
"""SELECT
MIN((data->>'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}")