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
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:
+121
-3
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user