feat(fundamentals): A4 — additive API v1 (earnings, metrics, valuation, reads)
GET /fundamentals/{symbol} now returns the additive v1 objects alongside the
unchanged legacy fields (no legacy growth mapped onto the SEC TTM metric).
- earnings: next (date/session/days_until) + recent (<=4, with surprise_pct)
from earnings_events.
- metrics: fixed key set (value + dated history + per-metric SIC-peer industry
object + source=sec); net_debt has no industry (size-dependent).
- valuation: P/E, FCF yield, market_cap_est computed at REQUEST TIME from the
derived TTM inputs x the latest ohlcv close (no stored valuation); guarded to
null on missing/invalid inputs; pe_industry / fcf_yield_industry peer stats.
- reads: deterministic outputs in a SEPARATE object (header + per-metric reads).
Peer queries are batched and CIK-deduplicated by 2-digit SIC; industry omitted
below 5 valid peers. Schema extended with optional typed sub-models; the router
merges legacy + v1 so every existing field is preserved.
Tests: 4 (full assembly incl. peer industry + valuation + additive-merge, no-cik
null metrics, <5-peers omitted, price-guarded valuation).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
"""Integration tests for the additive fundamentals API v1 assembly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.database import Base
|
||||
import app.models # noqa: F401
|
||||
from app.models.earnings_event import EarningsEvent
|
||||
from app.models.fundamental_snapshot import FundamentalSnapshot
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.ticker import Ticker
|
||||
from app.schemas.fundamental import FundamentalResponse
|
||||
from app.services.fundamentals_api_service import METRIC_KEYS, build_fundamentals_v1
|
||||
|
||||
UTC = timezone.utc
|
||||
TODAY = date(2026, 10, 15)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def factory():
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
eng = create_async_engine(f"sqlite+aiosqlite:///{path}")
|
||||
async with eng.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
try:
|
||||
yield async_sessionmaker(eng, class_=AsyncSession, expire_on_commit=False)
|
||||
finally:
|
||||
await eng.dispose()
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
_MONTHS = [3, 6, 9, 12]
|
||||
_FP = ["Q1", "Q2", "Q3", "FY"]
|
||||
|
||||
|
||||
async def _seed_issuer(s, symbol, cik, sic, rev_base, price, *, eps_base=1.0, snapshots=True):
|
||||
t = Ticker(symbol=symbol, cik=cik, sic=sic)
|
||||
s.add(t)
|
||||
await s.flush()
|
||||
if snapshots:
|
||||
for fy, mult in [(2025, 1.0), (2026, 1.1)]:
|
||||
shares = 1000 if fy == 2025 else 950 # buyback
|
||||
rev = [rev_base * mult * x for x in (1.0, 1.05, 1.1, 1.15)]
|
||||
eps = [eps_base * mult * x for x in (1.0, 1.05, 1.1, 1.15)]
|
||||
for i, fp in enumerate(_FP):
|
||||
pe = date(fy, _MONTHS[i], 28)
|
||||
s.add(FundamentalSnapshot(
|
||||
cik=cik, accession=f"{cik}-{fy}-{fp}", form="10-K" if fp == "FY" else "10-Q",
|
||||
filed_date=pe, accepted_at=datetime(fy, _MONTHS[i], 28, tzinfo=UTC),
|
||||
period_end=pe, fiscal_year=fy, fiscal_period=fp,
|
||||
revenue=sum(rev[: i + 1]), operating_income=sum(rev[: i + 1]) * 0.2,
|
||||
diluted_eps=sum(eps[: i + 1]), cfo=sum(rev[: i + 1]) * 0.25,
|
||||
capex=sum(rev[: i + 1]) * 0.05, depreciation_amortization=sum(rev[: i + 1]) * 0.05,
|
||||
cash_and_st_investments=40, total_debt=100, shares_outstanding=shares))
|
||||
s.add(OHLCVRecord(ticker_id=t.id, date=date(2026, 10, 1), open=price, high=price, low=price, close=price, volume=1000))
|
||||
return t.id
|
||||
|
||||
|
||||
async def _seed_group(factory):
|
||||
async with factory() as s:
|
||||
aapl = await _seed_issuer(s, "AAPL", "0000000001", "3571", rev_base=1000, price=200, eps_base=2.0)
|
||||
for i in range(5): # 5 peers in SIC 35xx so the group has >= 5 valid issuers
|
||||
await _seed_issuer(s, f"PEER{i}", f"000000010{i}", "3572", rev_base=500 + i * 100, price=50 + i * 10)
|
||||
# AAPL earnings: one upcoming, one past with a surprise
|
||||
s.add(EarningsEvent(ticker_id=aapl, announce_date=date(2026, 11, 1), session="amc", source="dolt_earnings"))
|
||||
s.add(EarningsEvent(ticker_id=aapl, announce_date=date(2026, 8, 1), session="amc",
|
||||
period_end=date(2026, 6, 30), eps_estimate=2.0, eps_actual=2.2, source="dolt_earnings"))
|
||||
await s.commit()
|
||||
return aapl
|
||||
|
||||
|
||||
async def test_full_assembly(factory):
|
||||
await _seed_group(factory)
|
||||
async with factory() as s:
|
||||
v1 = await build_fundamentals_v1(s, "AAPL", today=TODAY)
|
||||
|
||||
# earnings
|
||||
assert v1["earnings"]["next"] == {"date": "2026-11-01", "session": "amc", "days_until": 17}
|
||||
recent = v1["earnings"]["recent"]
|
||||
assert recent and recent[0]["surprise_pct"] == pytest.approx(10.0)
|
||||
|
||||
# metrics — fixed key set, all present
|
||||
assert [m["key"] for m in v1["metrics"]] == list(METRIC_KEYS)
|
||||
by_key = {m["key"]: m for m in v1["metrics"]}
|
||||
assert by_key["revenue_growth_yoy"]["value"] is not None
|
||||
assert by_key["revenue_growth_yoy"]["source"] == "sec"
|
||||
assert len(by_key["operating_margin"]["history"]) >= 3
|
||||
# peer industry present for eligible metric (6 issuers), absent for size-dependent net_debt
|
||||
assert by_key["operating_margin"]["industry"] is not None
|
||||
assert by_key["operating_margin"]["industry"]["peer_count"] == 6
|
||||
assert by_key["operating_margin"]["industry"]["label"] == "SIC 35 peers"
|
||||
assert by_key["net_debt"]["industry"] is None
|
||||
|
||||
# valuation computed at request time
|
||||
val = v1["valuation"]
|
||||
assert val["pe"] is not None and val["market_cap_est"] is not None
|
||||
assert val["price_date"] == "2026-10-01"
|
||||
assert val["pe_industry"] is not None
|
||||
|
||||
# reads present + additive merge validates against the schema
|
||||
assert v1["reads"]["header"]
|
||||
data = FundamentalResponse(symbol="AAPL", pe_ratio=12.3, **v1) # legacy + v1 additive
|
||||
dumped = data.model_dump()
|
||||
assert dumped["pe_ratio"] == 12.3 # legacy preserved untouched
|
||||
assert dumped["metrics"][0]["key"] == "revenue_growth_yoy"
|
||||
|
||||
|
||||
async def test_no_cik_ticker_yields_null_metrics(factory):
|
||||
async with factory() as s:
|
||||
s.add(Ticker(symbol="ADR", cik=None)) # no SEC identity
|
||||
await s.commit()
|
||||
async with factory() as s:
|
||||
v1 = await build_fundamentals_v1(s, "ADR", today=TODAY)
|
||||
assert v1["valuation"] is None
|
||||
assert all(m["value"] is None and m["industry"] is None for m in v1["metrics"])
|
||||
assert v1["reads"] == {"header": "", "metrics": {}}
|
||||
|
||||
|
||||
async def test_industry_omitted_below_five_peers(factory):
|
||||
async with factory() as s:
|
||||
await _seed_issuer(s, "SOLO", "0000000009", "9999", rev_base=1000, price=100, eps_base=2.0)
|
||||
await s.commit()
|
||||
async with factory() as s:
|
||||
v1 = await build_fundamentals_v1(s, "SOLO", today=TODAY)
|
||||
# only 1 issuer in the group -> below MIN_PEERS -> every industry omitted
|
||||
assert all(m["industry"] is None for m in v1["metrics"])
|
||||
assert v1["valuation"]["pe_industry"] is None
|
||||
# but the subject's own valuation still computes
|
||||
assert v1["valuation"]["pe"] is not None
|
||||
|
||||
|
||||
async def test_valuation_guarded_without_price(factory):
|
||||
async with factory() as s:
|
||||
t = Ticker(symbol="NOPX", cik="0000000077", sic="3571")
|
||||
s.add(t)
|
||||
await s.flush()
|
||||
# snapshots but NO ohlcv close
|
||||
s.add(FundamentalSnapshot(cik="0000000077", accession="a", form="10-K", filed_date=date(2026, 1, 1),
|
||||
accepted_at=datetime(2026, 1, 1, tzinfo=UTC), period_end=date(2025, 12, 31),
|
||||
fiscal_year=2025, fiscal_period="FY", diluted_eps=5.0, shares_outstanding=1000))
|
||||
await s.commit()
|
||||
async with factory() as s:
|
||||
v1 = await build_fundamentals_v1(s, "NOPX", today=TODAY)
|
||||
val = v1["valuation"]
|
||||
assert val is not None # snapshots exist -> object present
|
||||
assert val["pe"] is None and val["market_cap_est"] is None and val["price_date"] is None
|
||||
Reference in New Issue
Block a user