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>
341 lines
13 KiB
Python
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()
|