"""Assemble the additive fundamentals API v1 objects (earnings, metrics, valuation, reads) from SEC snapshots + Dolt earnings + the latest price. Strictly additive: the router merges these into the existing FundamentalResponse without touching legacy fields. Valuation ratios are computed at REQUEST TIME from the stored snapshots + the latest ohlcv close (no stored valuation). Peer stats are batched and CIK-deduplicated; invalid valuation inputs are guarded to null. """ from __future__ import annotations import math from collections import defaultdict from datetime import date from typing import Any from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession 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.services import fundamentals_derivation as deriv from app.services import fundamentals_peers as peers from app.services import fundamentals_reads as reads # The fixed metric row set — every key always present, value null when unavailable. METRIC_KEYS = ( "revenue_growth_yoy", "eps_growth_yoy", "operating_margin", "fcf_margin", "net_debt", "net_debt_to_ebitda", "share_count_change_yoy", ) async def build_fundamentals_v1(db: AsyncSession, symbol: str, *, today: date | None = None) -> dict[str, Any]: today = today or date.today() ticker = await _ticker_by_symbol(db, symbol) earnings = await _build_earnings(db, ticker.id, today) if ticker else _empty_earnings() if ticker is None or not ticker.cik: # No SEC identity: metrics present but null, valuation null, empty reads. return {"earnings": earnings, "metrics": _empty_metrics(), "valuation": None, "reads": {"header": "", "metrics": {}}} subject_cik = ticker.cik derived = deriv.derive((await _snapshots_for(db, [subject_cik])).get(subject_cik, [])) two = peers.two_digit_sic(ticker.sic) peer_derived: dict[str, deriv.DerivedFundamentals] = {} peer_price_by_cik: dict[str, tuple[float, date] | None] = {} if two: group = await _peer_group(db, two) # {cik: representative ticker_id} peer_snaps = await _snapshots_for(db, list(group)) peer_derived = {cik: deriv.derive(rows) for cik, rows in peer_snaps.items()} closes = await _latest_closes(db, set(group.values())) peer_price_by_cik = {cik: closes.get(tid) for cik, tid in group.items()} subject_price = await _latest_close(db, ticker.id) metrics = _build_metrics(derived, peer_derived, two) valuation = _build_valuation(derived, subject_price, peer_derived, peer_price_by_cik, two) reads_obj = _build_reads(metrics, valuation) return {"earnings": earnings, "metrics": metrics, "valuation": valuation, "reads": reads_obj} # -- earnings ---------------------------------------------------------------- async def _build_earnings(db, ticker_id: int, today: date) -> dict[str, Any]: rows = (await db.execute( select(EarningsEvent).where(EarningsEvent.ticker_id == ticker_id) )).scalars().all() upcoming = sorted((e for e in rows if e.announce_date > today), key=lambda e: e.announce_date) past = sorted((e for e in rows if e.announce_date <= today), key=lambda e: e.announce_date, reverse=True) nxt = None if upcoming: e = upcoming[0] nxt = {"date": e.announce_date.isoformat(), "session": e.session, "days_until": (e.announce_date - today).days} recent = [{ "announce_date": e.announce_date.isoformat(), "period_end": _iso(e.period_end), "eps_estimate": e.eps_estimate, "eps_actual": e.eps_actual, "surprise_pct": _surprise_pct(e.eps_estimate, e.eps_actual), } for e in past[:4]] return {"next": nxt, "recent": recent} def _surprise_pct(estimate, actual): if estimate is None or actual is None or estimate == 0: return None return round((actual - estimate) / abs(estimate) * 100.0, 2) # -- metrics ----------------------------------------------------------------- def _build_metrics(derived, peer_derived, two: str | None) -> list[dict[str, Any]]: out = [] for key in METRIC_KEYS: series = derived.metrics.get(key) value = series.value if series else None history = [{"period_end": _iso(p.period_end), "value": p.value} for p in (series.history if series else [])] industry = None if two and peer_derived and key in peers.HIGHER_IS_BETTER: group_values = [ (pd.metrics.get(key).value if pd.metrics.get(key) else None) for pd in peer_derived.values() ] stat = peers.peer_stat_for(key, value, group_values) if stat: industry = {"label": f"SIC {two} peers", "median": round(stat.median, 4), "favorable_percentile": stat.favorable_percentile, "peer_count": stat.peer_count} out.append({ "key": key, "value": value, "history": history, "industry": industry, "period_end": _iso(series.period_end) if series else None, "filed_date": _iso(series.filed_date) if series else None, "source": "sec", }) return out # -- valuation (request-time) ------------------------------------------------ def _build_valuation(derived, subject_price, peer_derived, peer_price_by_cik, two) -> dict[str, Any] | None: if derived.latest_period_end is None: return None # no snapshots yet price = subject_price[0] if subject_price else None price_date = subject_price[1] if subject_price else None pe = _pe(price, derived.ttm_diluted_eps) market_cap = _market_cap(price, derived.shares_outstanding) fcf_yield = _fcf_yield(derived.ttm_fcf, market_cap) pe_industry = fcf_yield_industry = None if two and peer_derived: pe_values = [_pe(_p(peer_price_by_cik.get(cik)), pd.ttm_diluted_eps) for cik, pd in peer_derived.items()] fy_values = [ _fcf_yield(pd.ttm_fcf, _market_cap(_p(peer_price_by_cik.get(cik)), pd.shares_outstanding)) for cik, pd in peer_derived.items() ] pe_industry = _industry("pe", pe, pe_values, two) fcf_yield_industry = _industry("fcf_yield", fcf_yield, fy_values, two) return { "pe": _round(pe, 2), "fcf_yield": _round(fcf_yield, 2), "market_cap_est": _round(market_cap, 0), "pe_industry": pe_industry, "fcf_yield_industry": fcf_yield_industry, "price_date": _iso(price_date), } def _pe(price, ttm_eps): if not _finite(price) or not _finite(ttm_eps) or ttm_eps <= 0: return None return price / ttm_eps def _market_cap(price, shares): if not _finite(price) or not _finite(shares) or shares <= 0: return None return price * shares def _fcf_yield(ttm_fcf, market_cap): if not _finite(ttm_fcf) or not _finite(market_cap) or market_cap <= 0: return None return ttm_fcf / market_cap * 100.0 def _industry(key, subject, group_values, two): stat = peers.peer_stat_for(key, subject, group_values) if stat is None: return None return {"label": f"SIC {two} peers", "median": round(stat.median, 4), "favorable_percentile": stat.favorable_percentile, "peer_count": stat.peer_count} # -- reads ------------------------------------------------------------------- def _build_reads(metrics: list[dict], valuation: dict | None) -> dict[str, Any]: by_key = {m["key"]: m for m in metrics} def hist(key): return [_Pt(p["value"]) for p in by_key.get(key, {}).get("history", [])] growth = reads.growth_read(hist("revenue_growth_yoy")) op_margin = reads.margin_read(hist("operating_margin")) fcf_margin = reads.margin_read(hist("fcf_margin")) share = reads.share_count_read(by_key.get("share_count_change_yoy", {}).get("value")) leverage = reads.peer_read("net_debt_to_ebitda", _pct(by_key.get("net_debt_to_ebitda", {}).get("industry"))) # valuation read: P/E peer read, fall back to FCF yield val_read = None if valuation: val_read = reads.peer_read("pe", _pct(valuation.get("pe_industry"))) if val_read is None: val_read = reads.peer_read("fcf_yield", _pct(valuation.get("fcf_yield_industry"))) header = reads.header_sentence(growth, op_margin, val_read) metric_reads = {k: v for k, v in { "revenue_growth_yoy": growth, "operating_margin": op_margin, "fcf_margin": fcf_margin, "share_count_change_yoy": share, "net_debt_to_ebitda": leverage, "valuation": val_read, }.items() if v is not None} return {"header": header, "metrics": metric_reads} class _Pt: __slots__ = ("value",) def __init__(self, value): self.value = value def _pct(industry: dict | None): return industry.get("favorable_percentile") if industry else None # -- queries ----------------------------------------------------------------- async def _ticker_by_symbol(db, symbol: str) -> Ticker | None: return (await db.execute( select(Ticker).where(Ticker.symbol == symbol.strip().upper()) )).scalar_one_or_none() async def _snapshots_for(db, ciks) -> dict[str, list]: out: dict[str, list] = defaultdict(list) if not ciks: return out rows = (await db.execute( select(FundamentalSnapshot).where(FundamentalSnapshot.cik.in_(list(ciks))) )).scalars().all() for r in rows: out[r.cik].append(r) return out async def _peer_group(db, two: str) -> dict[str, int]: """{cik: representative (min) ticker_id} for tracked issuers in the 2-digit SIC group — CIK-deduplicated (multi-class tickers collapse to one issuer).""" rows = (await db.execute( select(Ticker.cik, func.min(Ticker.id)) .where(Ticker.cik.is_not(None), func.substr(Ticker.sic, 1, 2) == two) .group_by(Ticker.cik) )).all() return {cik: tid for cik, tid in rows} async def _latest_closes(db, ticker_ids: set[int]) -> dict[int, tuple[float, date]]: if not ticker_ids: return {} latest = ( select(OHLCVRecord.ticker_id, func.max(OHLCVRecord.date).label("d")) .where(OHLCVRecord.ticker_id.in_(list(ticker_ids))) .group_by(OHLCVRecord.ticker_id) .subquery() ) rows = (await db.execute( select(OHLCVRecord.ticker_id, OHLCVRecord.close, OHLCVRecord.date).join( latest, (OHLCVRecord.ticker_id == latest.c.ticker_id) & (OHLCVRecord.date == latest.c.d) ) )).all() return {tid: (close, d) for tid, close, d in rows} async def _latest_close(db, ticker_id: int) -> tuple[float, date] | None: return (await _latest_closes(db, {ticker_id})).get(ticker_id) # -- helpers ----------------------------------------------------------------- def _empty_metrics() -> list[dict[str, Any]]: return [{"key": k, "value": None, "history": [], "industry": None, "period_end": None, "filed_date": None, "source": "sec"} for k in METRIC_KEYS] def _empty_earnings() -> dict[str, Any]: return {"next": None, "recent": []} def _p(price_tuple): return price_tuple[0] if price_tuple else None def _finite(v) -> bool: return isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v) def _round(v, ndigits): return round(v, ndigits) if _finite(v) else None def _iso(d) -> str | None: return d.isoformat() if d else None