diff --git a/app/routers/ticker_validate.py b/app/routers/ticker_validate.py new file mode 100644 index 0000000..757d4cd --- /dev/null +++ b/app/routers/ticker_validate.py @@ -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, + } diff --git a/tests/test_ticker_validate.py b/tests/test_ticker_validate.py new file mode 100644 index 0000000..77bb7bc --- /dev/null +++ b/tests/test_ticker_validate.py @@ -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"