156 lines
5.7 KiB
Python
156 lines
5.7 KiB
Python
"""Per-ticker validation + historical-price endpoints.
|
|
|
|
These power the dashboard's "Add a position" form. Neither endpoint
|
|
persists holdings — they wrap the existing Yahoo chart fetcher and
|
|
optionally seed anonymous ticker_universe (validate only).
|
|
|
|
Both endpoints are gated behind ``require_paid`` so they match the rest
|
|
of the import surface.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.auth import require_auth
|
|
from app.db import get_session, utcnow
|
|
from app.logging import get_logger
|
|
from app.models import Quote as QuoteModel
|
|
from app.services.access import require_paid
|
|
from app.services.market import (
|
|
UA, YAHOO_CHART, Quote, _yahoo_range_covering, fetch_yahoo,
|
|
)
|
|
from app.services.ticker_universe import upsert_tickers
|
|
|
|
|
|
log = get_logger("ticker_validate")
|
|
|
|
router = APIRouter(dependencies=[Depends(require_auth)])
|
|
|
|
|
|
@router.get(
|
|
"/ticker/validate",
|
|
dependencies=[Depends(require_paid)],
|
|
)
|
|
async def validate_ticker(
|
|
symbol: str,
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> dict:
|
|
"""Live quote for one ticker.
|
|
|
|
Returns ``{ok: true, symbol, price, currency, as_of}`` on success
|
|
or ``{ok: false, error}`` when the symbol isn't recognised. Seeds
|
|
ticker_universe + writes a Quote row as a side-effect on success
|
|
so the dashboard's /api/universe call picks it up on the next
|
|
refresh."""
|
|
symbol = symbol.strip().upper()[:32]
|
|
if not symbol:
|
|
return {"ok": False, "error": "symbol required"}
|
|
|
|
async with httpx.AsyncClient(follow_redirects=True, timeout=15) as client:
|
|
quote = await fetch_yahoo(client, symbol, symbol, "")
|
|
|
|
if quote.error or quote.price is None:
|
|
log.info("ticker.validate.miss", symbol=symbol,
|
|
error=(quote.error or "no price")[:120])
|
|
return {"ok": False, "error": "Symbol not recognised"}
|
|
|
|
# Side-effect: seed the universe + write the quote so /api/universe
|
|
# has data on the next minute-cycle refresh.
|
|
await upsert_tickers(session, [symbol])
|
|
session.add(QuoteModel(
|
|
symbol=quote.symbol, source=quote.source, label=quote.label,
|
|
group_name="universe", price=quote.price, currency=quote.currency,
|
|
as_of=quote.as_of, changes=quote.changes or None, error=None,
|
|
fetched_at=utcnow(),
|
|
))
|
|
await session.commit()
|
|
|
|
return {
|
|
"ok": True,
|
|
"symbol": quote.symbol,
|
|
"price": quote.price,
|
|
"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}
|