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>
52 lines
1.8 KiB
Python
52 lines
1.8 KiB
Python
"""Fundamentals router — fundamental data endpoints."""
|
|
|
|
import json
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.dependencies import get_db, require_access
|
|
from app.schemas.common import APIEnvelope
|
|
from app.schemas.fundamental import FundamentalResponse
|
|
from app.services.fundamental_service import get_fundamental
|
|
from app.services.fundamentals_api_service import build_fundamentals_v1
|
|
|
|
router = APIRouter(tags=["fundamentals"])
|
|
|
|
|
|
def _parse_unavailable_fields(raw_json: str) -> dict[str, str]:
|
|
"""Deserialize unavailable_fields_json, defaulting to {} on invalid JSON."""
|
|
try:
|
|
parsed = json.loads(raw_json)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return {}
|
|
if not isinstance(parsed, dict):
|
|
return {}
|
|
return {k: v for k, v in parsed.items() if isinstance(k, str) and isinstance(v, str)}
|
|
|
|
|
|
@router.get("/fundamentals/{symbol}", response_model=APIEnvelope)
|
|
async def read_fundamentals(
|
|
symbol: str,
|
|
_user=Depends(require_access),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> APIEnvelope:
|
|
"""Get latest fundamental data for a symbol (legacy fields + additive v1)."""
|
|
record = await get_fundamental(db, symbol)
|
|
v1 = await build_fundamentals_v1(db, symbol)
|
|
|
|
legacy: dict = {}
|
|
if record is not None:
|
|
legacy = dict(
|
|
pe_ratio=record.pe_ratio,
|
|
revenue_growth=record.revenue_growth,
|
|
earnings_surprise=record.earnings_surprise,
|
|
market_cap=record.market_cap,
|
|
next_earnings_date=record.next_earnings_date,
|
|
fetched_at=record.fetched_at,
|
|
unavailable_fields=_parse_unavailable_fields(record.unavailable_fields_json),
|
|
)
|
|
|
|
data = FundamentalResponse(symbol=symbol.strip().upper(), **legacy, **v1)
|
|
return APIEnvelope(status="success", data=data.model_dump())
|