GET /fundamentals/{symbol} now returns the additive v1 objects alongside the
unchanged legacy fields (no legacy growth mapped onto the SEC TTM metric).
- earnings: next (date/session/days_until) + recent (<=4, with surprise_pct)
from earnings_events.
- metrics: fixed key set (value + dated history + per-metric SIC-peer industry
object + source=sec); net_debt has no industry (size-dependent).
- valuation: P/E, FCF yield, market_cap_est computed at REQUEST TIME from the
derived TTM inputs x the latest ohlcv close (no stored valuation); guarded to
null on missing/invalid inputs; pe_industry / fcf_yield_industry peer stats.
- reads: deterministic outputs in a SEPARATE object (header + per-metric reads).
Peer queries are batched and CIK-deduplicated by 2-digit SIC; industry omitted
below 5 valid peers. Schema extended with optional typed sub-models; the router
merges legacy + v1 so every existing field is preserved.
Tests: 4 (full assembly incl. peer industry + valuation + additive-merge, no-cik
null metrics, <5-peers omitted, price-guarded valuation).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
304 lines
11 KiB
Python
304 lines
11 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
|
|
from typing import Any
|
|
|
|
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 date.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": {"header": "", "metrics": {}}}
|
|
|
|
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:
|
|
group = await _peer_group(db, two) # {cik: representative 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()
|
|
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
|
|
|
|
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 not _finite(ttm_eps) or ttm_eps <= 0:
|
|
return None
|
|
return price / ttm_eps
|
|
|
|
|
|
def _market_cap(price, shares):
|
|
if not _finite(price) 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 -------------------------------------------------------------------
|
|
|
|
def _build_reads(metrics: list[dict], valuation: dict | None) -> dict[str, Any]:
|
|
by_key = {m["key"]: m for m in metrics}
|
|
|
|
def hist(key):
|
|
return [_Pt(p["value"]) for p in by_key.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_key.get("share_count_change_yoy", {}).get("value"))
|
|
leverage = reads.peer_read("net_debt_to_ebitda", _pct(by_key.get("net_debt_to_ebitda", {}).get("industry")))
|
|
|
|
# valuation read: P/E peer read, fall back to FCF yield
|
|
val_read = None
|
|
if valuation:
|
|
val_read = reads.peer_read("pe", _pct(valuation.get("pe_industry")))
|
|
if val_read is None:
|
|
val_read = reads.peer_read("fcf_yield", _pct(valuation.get("fcf_yield_industry")))
|
|
|
|
header = reads.header_sentence(growth, op_margin, val_read)
|
|
metric_reads = {k: v for k, v in {
|
|
"revenue_growth_yoy": growth,
|
|
"operating_margin": op_margin,
|
|
"fcf_margin": fcf_margin,
|
|
"share_count_change_yoy": share,
|
|
"net_debt_to_ebitda": leverage,
|
|
"valuation": val_read,
|
|
}.items() if v is not None}
|
|
return {"header": header, "metrics": metric_reads}
|
|
|
|
|
|
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) -> dict[str, int]:
|
|
"""{cik: representative (min) ticker_id} for tracked issuers in the 2-digit SIC
|
|
group — CIK-deduplicated (multi-class tickers collapse to one issuer)."""
|
|
rows = (await db.execute(
|
|
select(Ticker.cik, func.min(Ticker.id))
|
|
.where(Ticker.cik.is_not(None), func.substr(Ticker.sic, 1, 2) == two)
|
|
.group_by(Ticker.cik)
|
|
)).all()
|
|
return {cik: tid for cik, tid in rows}
|
|
|
|
|
|
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
|