Files
signal-platform/app/services/fundamentals_api_service.py
dennisthiessenandClaude Opus 4.8 0e556d8a43 fix(sec): keep the market-cap fallback through an amendment merge
Found in review. _merge_amendments rebuilds a period from _MERGED_FIELDS +
_CARRIED_FIELDS alone, so a column in neither list is absent from the merged
row, not just stale — and callers read it with getattr(..., None), which
silently yields None. weighted_avg_diluted_shares was never added when the
market-cap fallback landed (_SNAPSHOT_COLS in the importer was updated, its
counterpart in the derivation was not).

The failure needed both of this branch's fixes at once: a multi-class issuer
with a partial amendment on its latest period (META with a Part-III-only
10-K/A) would silently lose market cap and FCF yield again.

Adds the field, a regression test for that case, and a guard test asserting
the merge/carry lists cover every SnapshotRow field, so the next column added
fails loudly rather than losing data quietly. Confirmed the guard catches the
original bug.

Also from review:
- Expose pe_caveat in the valuation payload, so a P/E suppressed by split
  contamination says why instead of looking like missing data (the caveat was
  set but never read).
- no_xbrl_filings now names both causes; the old text advised pinning a CIK
  override, which is wrong for a genuine new registrant that simply has not
  filed yet and clears itself.
- Document that fiscalYearEnd is the issuer's current calendar, so a fiscal-
  year-end change degrades old periods (fallback/newest-wins), not current ones.
- Parser-level tests for _select_weighted_avg_shares (shortest-span-wins and
  concept priority), which only had derivation-level coverage.

823 unit tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 10:50:17 +02:00

345 lines
14 KiB
Python

"""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,
"caveat": series.caveat 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),
# market_cap_est and fcf_yield both rest on the share count. When it came
# from the weighted-average diluted fallback (multi-class issuers, whose
# per-class cover-page count is absent from companyfacts), say so rather
# than presenting a period average as a point-in-time count.
"shares_estimated": bool(
market_cap is not None and derived.shares_outstanding_estimated
),
# A null P/E is ambiguous: no earnings data, or earnings we deliberately
# suppressed. Only the latter carries a caveat, so a split-contaminated
# TTM says why instead of looking like missing data.
"pe_caveat": derived.ttm_diluted_eps_caveat if pe is None else None,
"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"))
eps_growth = reads.growth_read(hist("eps_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,
"eps_growth_yoy": eps_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, "caveat": 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()