Files
signal-platform/app/services/fundamentals_api_service.py
T
dennisthiessenandClaude Opus 4.8 921f3d06fb fix(sec): correct fundamentals derivation from SEC company facts
The A5 parity report surfaced coverage gaps and wrong values that all traced
to the SEC facts parser and read-time derivation rather than to bad source
data. Fixes, each validated by replaying the production parser + derivation
against live company facts:

- Period identity is derived from period_end against the issuer's fiscal
  calendar, not SEC's fy/fp fields, which collide (two period ends on one key,
  one silently discarded) and invert (a period sorting before one that precedes
  it) often enough to break the quarter chain. Recovers BXP, CRM, CRWD, FRT,
  MTD, NTAP, PPL, STX, WDAY. Fixed labels are internal ordering keys only (not
  in any API schema), so a filer whose year ends in early January shifting by
  one is harmless.
- Revenue concept list gains RevenuesNetOfInterestExpense (banks) and the
  IncludingAssessedTax variant (REITs/consumer); EPS gains the continuing-ops
  variant (REG/FCX) and, last, basic EPS for a period tagging no diluted
  variant at all (PPL). All appended, so any issuer that already resolved keeps
  its concept.
- YTD span tolerance 20 -> 25 days, covering 4-4-5 retail calendars whose
  36-week YTD-Q3 (251-252d) previously missed by ~2 (COST, PEP, DPZ).
- Amendment resolution is per field: a partial 10-K/A (Part III only, no
  financial facts) no longer blanks the period (DVN).
- TTM diluted EPS is suppressed when a split contaminates the trailing window
  (BKNG's mixed-unit sum produced a P/E of 1.10 that clamped to a perfect
  fundamental sub-score). A post-filing split with no share-count evidence
  (KLAC) remains undetectable from this data.
- Multi-class share fallback: weighted_avg_diluted_shares is captured and used
  for market cap when the cover-page count is absent (dimensional, so missing
  from company facts for META/CMCSA/CHTR/FOXA/NWSA/LEN). Within ~0.6% of the
  true count on controls; flagged shares_estimated in the API. BRK-B has no
  weighted-average fact either and stays unavailable.

820 unit tests pass; new tests confirmed to fail against the pre-fix code.
Effect is inert until existing rows are reparsed (see reparse path).

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

341 lines
13 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
),
"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()