From b7d6235fcb682f90acf5ea990dd5148463c939bf Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Wed, 27 May 2026 14:45:52 +0200 Subject: [PATCH] ticker-validate: add /api/ticker/historical with weekend-walkback Co-Authored-By: Claude Opus 4.7 --- app/routers/ticker_validate.py | 78 ++++++++++++++++++++++++ tests/test_ticker_validate.py | 107 +++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+) diff --git a/app/routers/ticker_validate.py b/app/routers/ticker_validate.py index 757d4cd..53bc783 100644 --- a/app/routers/ticker_validate.py +++ b/app/routers/ticker_validate.py @@ -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} diff --git a/tests/test_ticker_validate.py b/tests/test_ticker_validate.py index 6ccfbb2..8e640b3 100644 --- a/tests/test_ticker_validate.py +++ b/tests/test_ticker_validate.py @@ -135,3 +135,110 @@ async def test_validate_seeds_universe_and_quote(tmp_path, monkeypatch): assert len(rows) == 1 assert rows[0].price == 100.0 assert rows[0].currency == "USD" + + +@pytest.mark.asyncio +async def test_historical_happy_path(monkeypatch): + from app.routers.ticker_validate import get_historical + import app.routers.ticker_validate as mod + + async def _fake_hist(client, symbol, target_iso): + # close, currency, actual_iso + return 185.92, "USD", "2024-01-12" + monkeypatch.setattr(mod, "fetch_yahoo_historical", _fake_hist) + + result = await get_historical(symbol="aapl", date="2024-01-15") + + assert result["ok"] is True + assert result["close"] == 185.92 + assert result["currency"] == "USD" + # 2024-01-15 was a Monday — but our fake says it walked back to Jan 12 (Fri). + assert result["actual_date"] == "2024-01-12" + + +@pytest.mark.asyncio +async def test_historical_future_date_rejected(): + from fastapi import HTTPException + from app.routers.ticker_validate import get_historical + + future = "2099-01-01" + with pytest.raises(HTTPException) as exc: + await get_historical(symbol="AAPL", date=future) + assert exc.value.status_code == 400 + assert "future" in str(exc.value.detail).lower() + + +@pytest.mark.asyncio +async def test_historical_bad_date_format_rejected(): + from fastapi import HTTPException + from app.routers.ticker_validate import get_historical + + with pytest.raises(HTTPException) as exc: + await get_historical(symbol="AAPL", date="not-a-date") + assert exc.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_historical_no_data(monkeypatch): + from app.routers.ticker_validate import get_historical + import app.routers.ticker_validate as mod + + async def _fake_hist(client, symbol, target_iso): + return None, None, None + monkeypatch.setattr(mod, "fetch_yahoo_historical", _fake_hist) + + result = await get_historical(symbol="ZZZNEW", date="2020-01-15") + assert result["ok"] is False + assert "no data" in result["error"].lower() + + +@pytest.mark.asyncio +async def test_historical_provider_failure(monkeypatch): + import httpx + + from app.routers.ticker_validate import get_historical + import app.routers.ticker_validate as mod + + async def _fake_hist(client, symbol, target_iso): + raise httpx.RequestError("connection failed") + monkeypatch.setattr(mod, "fetch_yahoo_historical", _fake_hist) + + result = await get_historical(symbol="AAPL", date="2024-01-15") + assert result["ok"] is False + assert "couldn" in result["error"].lower() or "fetch" in result["error"].lower() + + +@pytest.mark.asyncio +async def test_fetch_yahoo_historical_walks_back_to_preceding_trading_day(monkeypatch): + """Unit test for the helper itself: feed a hand-crafted series with a + weekend gap, ask for the Saturday close, expect Friday's close.""" + from app.routers.ticker_validate import fetch_yahoo_historical + + # Build a fake httpx client that returns a chart payload with a + # Thu-Fri-Mon-Tue series; we ask for Saturday and expect Friday. + thu_ts = int(datetime(2024, 1, 11, tzinfo=timezone.utc).timestamp()) + fri_ts = int(datetime(2024, 1, 12, tzinfo=timezone.utc).timestamp()) + mon_ts = int(datetime(2024, 1, 15, tzinfo=timezone.utc).timestamp()) + payload = { + "chart": {"result": [{ + "meta": {"currency": "USD"}, + "timestamp": [thu_ts, fri_ts, mon_ts], + "indicators": {"quote": [{"close": [184.0, 185.92, 186.10]}]}, + }]} + } + + class _FakeResponse: + def __init__(self, data): self._data = data + def json(self): return self._data + def raise_for_status(self): pass + + class _FakeClient: + async def get(self, *args, **kwargs): + return _FakeResponse(payload) + + close, currency, actual = await fetch_yahoo_historical( + _FakeClient(), "AAPL", "2024-01-13", # a Saturday + ) + assert close == 185.92 + assert currency == "USD" + assert actual == "2024-01-12"