"""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, datetime from typing import Any from zoneinfo import ZoneInfo 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 _ny_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": _empty_reads()} 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: # Subject's representative is the REQUESTED ticker (so its price is used for # the subject in the peer set); other issuers pick a deterministic-by-symbol rep. group = await _peer_group(db, two, subject_cik, 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() # Same-day earnings are UPCOMING (days_until 0); recent is strictly earlier. 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 if not _finite(price) or price <= 0: return None # no usable price -> valuation null (approved contract) 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 price <= 0 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 price <= 0 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 ------------------------------------------------------------------- _READ_KEYS = METRIC_KEYS + ("pe", "fcf_yield") def _build_reads(metrics: list[dict], valuation: dict | None) -> dict[str, Any]: by_metric = {m["key"]: m for m in metrics} def hist(key): return [_Pt(p["value"]) for p in by_metric.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_metric.get("share_count_change_yoy", {}).get("value")) leverage = reads.peer_read("net_debt_to_ebitda", _pct(by_metric.get("net_debt_to_ebitda", {}).get("industry"))) pe_read = reads.peer_read("pe", _pct(valuation.get("pe_industry"))) if valuation else None fcf_yield_read = reads.peer_read("fcf_yield", _pct(valuation.get("fcf_yield_industry"))) if valuation else None # Fixed by_key map over every metric + pe + fcf_yield (null where unavailable). by_key: dict[str, str | None] = {k: None for k in _READ_KEYS} by_key.update({ "revenue_growth_yoy": growth, "operating_margin": op_margin, "fcf_margin": fcf_margin, "share_count_change_yoy": share, "net_debt_to_ebitda": leverage, "pe": pe_read, "fcf_yield": fcf_yield_read, }) header = reads.header_sentence(growth, op_margin, pe_read or fcf_yield_read) or None return {"header": header, "by_key": by_key} def _empty_reads() -> dict[str, Any]: return {"header": None, "by_key": {k: None for k in _READ_KEYS}} 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, subject_cik: str, subject_tid: int) -> dict[str, int]: """{cik: representative ticker_id} for tracked issuers in the 2-digit SIC group, CIK-deduplicated. Each issuer's representative is its lexicographically-smallest symbol (deterministic), EXCEPT the subject issuer, which uses the requested ticker — so a multi-class subject (GOOGL) is priced by the requested class, not an arbitrary sibling (GOOG).""" rows = (await db.execute( select(Ticker.cik, Ticker.id, Ticker.symbol) .where(Ticker.cik.is_not(None), func.substr(Ticker.sic, 1, 2) == two) )).all() rep: dict[str, tuple[int, str]] = {} for cik, tid, sym in rows: key = sym or "" if cik not in rep or key < rep[cik][1]: rep[cik] = (tid, key) group = {cik: tid for cik, (tid, _) in rep.items()} if subject_cik in group: group[subject_cik] = subject_tid # requested ticker prices the subject return group 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 def _ny_today() -> date: """Today's New York calendar date — the market's day, not the server's.""" return datetime.now(ZoneInfo("America/New_York")).date()