289 lines
9.1 KiB
Python
289 lines
9.1 KiB
Python
"""Local SEC/Dolt candidate values for the legacy fundamentals cache.
|
|
|
|
This is the single read path shared by the A5 parity report and the activated
|
|
``fundamental_data`` refresh. It never contacts SEC or Dolt: every input comes
|
|
from PostgreSQL, so price- and earnings-driven values can still refresh when an
|
|
upstream import is unchanged or unavailable.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass, field
|
|
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
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CandidateFundamentals:
|
|
ticker_id: int
|
|
symbol: str
|
|
cik: str | None
|
|
pe_ratio: float | None
|
|
revenue_growth: float | None
|
|
earnings_surprise: float | None
|
|
market_cap: float | None
|
|
next_earnings_date: date | None
|
|
price_date: date | None
|
|
unavailable_fields: dict[str, str] = field(default_factory=dict)
|
|
|
|
|
|
async def build_candidates(
|
|
db: AsyncSession,
|
|
*,
|
|
today: date | None = None,
|
|
) -> list[CandidateFundamentals]:
|
|
"""Derive current cache candidates using only already-stored data."""
|
|
today = today or datetime.now(ZoneInfo("America/New_York")).date()
|
|
tickers = list(
|
|
(await db.execute(select(Ticker).order_by(Ticker.symbol))).scalars()
|
|
)
|
|
if not tickers:
|
|
return []
|
|
|
|
ticker_ids = [ticker.id for ticker in tickers]
|
|
ciks = sorted({ticker.cik for ticker in tickers if ticker.cik})
|
|
derived_by_cik = await _derived_by_cik(db, ciks)
|
|
closes_by_ticker = await _latest_closes(db, ticker_ids)
|
|
surprise_by_ticker, next_by_ticker = await _earnings_values(
|
|
db, ticker_ids, today
|
|
)
|
|
|
|
out: list[CandidateFundamentals] = []
|
|
for ticker in tickers:
|
|
derived = derived_by_cik.get(ticker.cik) if ticker.cik else None
|
|
close = closes_by_ticker.get(ticker.id)
|
|
price = close[0] if close is not None else None
|
|
price_date = close[1] if close is not None else None
|
|
growth_series = (
|
|
derived.metrics.get("revenue_growth_yoy")
|
|
if derived is not None
|
|
else None
|
|
)
|
|
|
|
pe_ratio = (
|
|
_pe(price, derived.ttm_diluted_eps)
|
|
if derived is not None
|
|
else None
|
|
)
|
|
revenue_growth = (
|
|
float(growth_series.value)
|
|
if growth_series is not None and _finite(growth_series.value)
|
|
else None
|
|
)
|
|
earnings_surprise = surprise_by_ticker.get(ticker.id)
|
|
market_cap = (
|
|
_market_cap(price, derived.shares_outstanding)
|
|
if derived is not None
|
|
else None
|
|
)
|
|
next_earnings_date = next_by_ticker.get(ticker.id)
|
|
|
|
out.append(
|
|
CandidateFundamentals(
|
|
ticker_id=ticker.id,
|
|
symbol=ticker.symbol,
|
|
cik=ticker.cik,
|
|
pe_ratio=pe_ratio,
|
|
revenue_growth=revenue_growth,
|
|
earnings_surprise=earnings_surprise,
|
|
market_cap=market_cap,
|
|
next_earnings_date=next_earnings_date,
|
|
price_date=price_date,
|
|
unavailable_fields=_availability_metadata(
|
|
derived=derived,
|
|
price=price,
|
|
pe_ratio=pe_ratio,
|
|
revenue_growth=revenue_growth,
|
|
earnings_surprise=earnings_surprise,
|
|
market_cap=market_cap,
|
|
next_earnings_date=next_earnings_date,
|
|
),
|
|
)
|
|
)
|
|
return out
|
|
|
|
|
|
async def _derived_by_cik(
|
|
db: AsyncSession, ciks: list[str]
|
|
) -> dict[str, deriv.DerivedFundamentals]:
|
|
if not ciks:
|
|
return {}
|
|
grouped: dict[str, list[FundamentalSnapshot]] = defaultdict(list)
|
|
rows = (
|
|
await db.execute(
|
|
select(FundamentalSnapshot).where(FundamentalSnapshot.cik.in_(ciks))
|
|
)
|
|
).scalars()
|
|
for row in rows:
|
|
grouped[row.cik].append(row)
|
|
return {cik: deriv.derive(grouped.get(cik, [])) for cik in ciks}
|
|
|
|
|
|
async def _latest_closes(
|
|
db: AsyncSession, ticker_ids: list[int]
|
|
) -> dict[int, tuple[float, date]]:
|
|
latest = (
|
|
select(
|
|
OHLCVRecord.ticker_id,
|
|
func.max(OHLCVRecord.date).label("max_date"),
|
|
)
|
|
.where(OHLCVRecord.ticker_id.in_(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.max_date),
|
|
)
|
|
)
|
|
).all()
|
|
return {
|
|
ticker_id: (float(close), close_date)
|
|
for ticker_id, close, close_date in rows
|
|
if _finite(close)
|
|
}
|
|
|
|
|
|
async def _earnings_values(
|
|
db: AsyncSession,
|
|
ticker_ids: list[int],
|
|
today: date,
|
|
) -> tuple[dict[int, float], dict[int, date]]:
|
|
rows = (
|
|
await db.execute(
|
|
select(EarningsEvent)
|
|
.where(EarningsEvent.ticker_id.in_(ticker_ids))
|
|
.order_by(EarningsEvent.ticker_id, EarningsEvent.announce_date.desc())
|
|
)
|
|
).scalars()
|
|
surprises: dict[int, float] = {}
|
|
upcoming: dict[int, date] = {}
|
|
for row in rows:
|
|
if row.announce_date >= today:
|
|
current = upcoming.get(row.ticker_id)
|
|
if current is None or row.announce_date < current:
|
|
upcoming[row.ticker_id] = row.announce_date
|
|
continue
|
|
if row.ticker_id in surprises:
|
|
continue
|
|
surprise = _surprise(row.eps_estimate, row.eps_actual)
|
|
if surprise is not None:
|
|
surprises[row.ticker_id] = surprise
|
|
return surprises, upcoming
|
|
|
|
|
|
def _availability_metadata(
|
|
*,
|
|
derived: deriv.DerivedFundamentals | None,
|
|
price: float | None,
|
|
pe_ratio: float | None,
|
|
revenue_growth: float | None,
|
|
earnings_surprise: float | None,
|
|
market_cap: float | None,
|
|
next_earnings_date: date | None,
|
|
) -> dict[str, str]:
|
|
metadata: dict[str, str] = {}
|
|
|
|
if pe_ratio is not None:
|
|
metadata["source_pe_ratio"] = "sec_facts+ohlcv_records"
|
|
elif derived is None or derived.latest_period_end is None:
|
|
metadata["pe_ratio"] = "no SEC fundamental snapshots"
|
|
elif not _finite(price) or price <= 0:
|
|
metadata["pe_ratio"] = "no usable PostgreSQL close"
|
|
elif derived.ttm_diluted_eps_caveat:
|
|
metadata["pe_ratio"] = derived.ttm_diluted_eps_caveat
|
|
else:
|
|
metadata["pe_ratio"] = "no positive SEC-derived TTM diluted EPS"
|
|
|
|
if revenue_growth is not None:
|
|
metadata["source_revenue_growth"] = "sec_facts"
|
|
else:
|
|
metadata["revenue_growth"] = "SEC-derived TTM revenue growth unavailable"
|
|
|
|
if earnings_surprise is not None:
|
|
metadata["source_earnings_surprise"] = "dolt_earnings"
|
|
else:
|
|
metadata["earnings_surprise"] = (
|
|
"no completed earnings event with actual and nonzero estimate"
|
|
)
|
|
|
|
if market_cap is not None:
|
|
metadata["source_market_cap"] = "sec_facts+ohlcv_records"
|
|
if derived is not None and derived.shares_outstanding_estimated:
|
|
metadata["market_cap_estimated"] = (
|
|
"shares use the SEC weighted-average diluted fallback"
|
|
)
|
|
elif derived is None or derived.latest_period_end is None:
|
|
metadata["market_cap"] = "no SEC fundamental snapshots"
|
|
elif not _finite(price) or price <= 0:
|
|
metadata["market_cap"] = "no usable PostgreSQL close"
|
|
else:
|
|
metadata["market_cap"] = "SEC-derived shares outstanding unavailable"
|
|
|
|
if next_earnings_date is not None:
|
|
metadata["source_next_earnings_date"] = "dolt_earnings"
|
|
else:
|
|
metadata["next_earnings_date"] = "no upcoming earnings event"
|
|
return metadata
|
|
|
|
|
|
def _surprise(
|
|
estimate: float | None,
|
|
actual: float | None,
|
|
) -> float | None:
|
|
if not _finite(estimate) or not _finite(actual) or estimate == 0:
|
|
return None
|
|
return (float(actual) - float(estimate)) / abs(float(estimate)) * 100.0
|
|
|
|
|
|
def _pe(price: float | None, ttm_eps: float | None) -> float | None:
|
|
if (
|
|
not _finite(price)
|
|
or price <= 0
|
|
or not _finite(ttm_eps)
|
|
or ttm_eps <= 0
|
|
):
|
|
return None
|
|
return float(price) / float(ttm_eps)
|
|
|
|
|
|
def _market_cap(
|
|
price: float | None,
|
|
shares_outstanding: float | None,
|
|
) -> float | None:
|
|
if (
|
|
not _finite(price)
|
|
or price <= 0
|
|
or not _finite(shares_outstanding)
|
|
or shares_outstanding <= 0
|
|
):
|
|
return None
|
|
return float(price) * float(shares_outstanding)
|
|
|
|
|
|
def _finite(value: Any) -> bool:
|
|
return (
|
|
isinstance(value, (int, float))
|
|
and not isinstance(value, bool)
|
|
and math.isfinite(value)
|
|
)
|