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
+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}")