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>
345 lines
14 KiB
Python
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()
|