ticker-validate: add /api/ticker/validate endpoint
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
ae3f104fa7
commit
3bb62763ea
2 changed files with 140 additions and 0 deletions
78
app/routers/ticker_validate.py
Normal file
78
app/routers/ticker_validate.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
"""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,
|
||||||
|
}
|
||||||
62
tests/test_ticker_validate.py
Normal file
62
tests/test_ticker_validate.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
"""Tests for /api/ticker/validate and /api/ticker/historical."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from io import BytesIO
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _build_session_factory(tmp_path):
|
||||||
|
"""Spin up a fresh in-memory schema and return (engine, factory, setup).
|
||||||
|
Mirrors tests/test_llm_csv_parser.py / tests/test_referral_conversion.py."""
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app import db as db_mod
|
||||||
|
from app.db import Base
|
||||||
|
import app.models # noqa: F401
|
||||||
|
|
||||||
|
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/tv.db")
|
||||||
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
db_mod._engine = engine
|
||||||
|
db_mod._session_factory = factory
|
||||||
|
|
||||||
|
async def _setup():
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
return engine, factory, _setup
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_happy_path(tmp_path, monkeypatch):
|
||||||
|
from app.routers.ticker_validate import validate_ticker
|
||||||
|
from app.services.market import Quote
|
||||||
|
import app.routers.ticker_validate as mod
|
||||||
|
|
||||||
|
_, factory, setup = _build_session_factory(tmp_path)
|
||||||
|
await setup()
|
||||||
|
|
||||||
|
# Mock fetch_yahoo to return a successful quote.
|
||||||
|
async def _fake_yahoo(client, symbol, label, note, anchor=None):
|
||||||
|
return Quote(
|
||||||
|
symbol=symbol, source="yahoo", label=label, note=note,
|
||||||
|
price=172.40, currency="USD", as_of="2026-05-27", changes={},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(mod, "fetch_yahoo", _fake_yahoo)
|
||||||
|
|
||||||
|
# Avoid the MySQL-only upsert on SQLite.
|
||||||
|
async def _fake_upsert(session, tickers):
|
||||||
|
return len(list(tickers))
|
||||||
|
monkeypatch.setattr(mod, "upsert_tickers", _fake_upsert)
|
||||||
|
|
||||||
|
async with factory() as session:
|
||||||
|
result = await validate_ticker(symbol="aapl", session=session)
|
||||||
|
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["symbol"] == "AAPL"
|
||||||
|
assert result["price"] == 172.40
|
||||||
|
assert result["currency"] == "USD"
|
||||||
|
assert result["as_of"] == "2026-05-27"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue