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