ticker-validate: add /api/ticker/historical with weekend-walkback

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-27 14:45:52 +02:00
parent ca953e5ea2
commit b7d6235fcb
2 changed files with 185 additions and 0 deletions

View file

@ -76,3 +76,81 @@ async def validate_ticker(
"currency": quote.currency,
"as_of": quote.as_of,
}
async def fetch_yahoo_historical(
client: httpx.AsyncClient,
symbol: str,
target_iso: str,
) -> tuple[float | None, str | None, str | None]:
"""Fetch the close on ``target_iso`` or the nearest preceding trading
day's close (within the available history window).
Returns ``(close, currency, actual_iso)`` or ``(None, None, None)``
when no usable data exists. Raises on provider-level HTTP errors
(the caller wraps these into a friendly ``ok:false`` response).
"""
range_param = _yahoo_range_covering(target_iso)
r = await client.get(
YAHOO_CHART.format(symbol=symbol),
params={"interval": "1d", "range": range_param,
"includePrePost": "false"},
headers=UA,
timeout=15,
)
r.raise_for_status()
result = r.json().get("chart", {}).get("result")
if not result:
return None, None, None
res = result[0]
currency = (res.get("meta") or {}).get("currency")
timestamps = res.get("timestamp") or []
closes = (res.get("indicators", {}).get("quote") or [{}])[0].get("close") or []
series = [(t, c) for t, c in zip(timestamps, closes) if c is not None]
if not series:
return None, None, None
target_dt = datetime.strptime(target_iso, "%Y-%m-%d").replace(tzinfo=timezone.utc)
# Add a 24h buffer so the target day itself is included (Yahoo
# timestamps are at market open, not midnight).
cutoff_ts = int(target_dt.timestamp()) + 86400
selected: tuple[int, float] | None = None
for t, c in series:
if t <= cutoff_ts:
selected = (t, c)
else:
break
if selected is None:
return None, None, None
actual_iso = datetime.fromtimestamp(selected[0], timezone.utc).strftime("%Y-%m-%d")
return selected[1], currency, actual_iso
@router.get(
"/ticker/historical",
dependencies=[Depends(require_paid)],
)
async def get_historical(symbol: str, date: str) -> dict:
"""Historical daily close. If ``date`` is a non-trading day we walk
back to the last preceding trading day and surface ``actual_date``
so the UI can show the user which date we actually used."""
symbol = symbol.strip().upper()[:32]
if not symbol:
return {"ok": False, "error": "symbol required"}
try:
target = datetime.strptime(date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="invalid date format (YYYY-MM-DD)")
if target > datetime.now(timezone.utc).date():
raise HTTPException(status_code=400, detail="date cannot be in the future")
async with httpx.AsyncClient(follow_redirects=True, timeout=15) as client:
try:
close, currency, actual = await fetch_yahoo_historical(client, symbol, date)
except Exception as e:
log.warning("ticker.historical.failed", symbol=symbol,
date=date, error=str(e)[:200])
return {"ok": False, "error": "Couldn't fetch historical price"}
if close is None:
return {"ok": False, "error": "No data for that date"}
return {"ok": True, "close": close, "currency": currency, "actual_date": actual}