ticker-validate: add /api/ticker/historical with weekend-walkback
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
ca953e5ea2
commit
b7d6235fcb
2 changed files with 185 additions and 0 deletions
|
|
@ -76,3 +76,81 @@ async def validate_ticker(
|
||||||
"currency": quote.currency,
|
"currency": quote.currency,
|
||||||
"as_of": quote.as_of,
|
"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}
|
||||||
|
|
|
||||||
|
|
@ -135,3 +135,110 @@ async def test_validate_seeds_universe_and_quote(tmp_path, monkeypatch):
|
||||||
assert len(rows) == 1
|
assert len(rows) == 1
|
||||||
assert rows[0].price == 100.0
|
assert rows[0].price == 100.0
|
||||||
assert rows[0].currency == "USD"
|
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"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue