refactor: reconcile FundamentalsSnapshot to use canonical schema from trading.py

This commit is contained in:
Viktor Barzin 2026-02-23 21:45:18 +00:00
parent aa47e896dd
commit 6f512cf91f
No known key found for this signature in database
GPG key ID: 0EB088298288D958
6 changed files with 40 additions and 55 deletions

View file

@ -1,6 +1,6 @@
"""Fundamental data providers for stock financial metrics.""" """Fundamental data providers for stock financial metrics."""
from shared.fundamentals.base import FundamentalsProvider from shared.fundamentals.base import FundamentalsProvider, FundamentalsSnapshot
from shared.fundamentals.rotating import RotatingProvider from shared.fundamentals.rotating import RotatingProvider
__all__ = ["FundamentalsProvider", "RotatingProvider"] __all__ = ["FundamentalsProvider", "FundamentalsSnapshot", "RotatingProvider"]

View file

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import datetime, timezone
import httpx import httpx
@ -63,13 +64,14 @@ class AlphaVantageProvider(FundamentalsProvider):
return FundamentalsSnapshot( return FundamentalsSnapshot(
ticker=ticker, ticker=ticker,
eps=_safe_float(data.get("EPS")), eps_ttm=_safe_float(data.get("EPS")),
pe_ratio=_safe_float(data.get("PERatio")), pe_ratio=_safe_float(data.get("PERatio")),
peg_ratio=_safe_float(data.get("PEGRatio")), peg_ratio=_safe_float(data.get("PEGRatio")),
revenue_growth=_safe_float(data.get("QuarterlyRevenueGrowthYOY")), revenue_growth_yoy=_safe_float(data.get("QuarterlyRevenueGrowthYOY")),
profit_margin=_safe_float(data.get("ProfitMargin")), profit_margin=_safe_float(data.get("ProfitMargin")),
debt_to_equity=_safe_float_div100(data.get("DebtToEquity")), debt_to_equity=_safe_float_div100(data.get("DebtToEquity")),
market_cap=_safe_float(data.get("MarketCapitalization")), market_cap=_safe_float(data.get("MarketCapitalization")),
fetched_at=datetime.now(timezone.utc),
) )
except Exception: except Exception:
logger.exception("Alpha Vantage fetch failed for %s", ticker) logger.exception("Alpha Vantage fetch failed for %s", ticker)

View file

@ -3,34 +3,12 @@
from __future__ import annotations from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from datetime import UTC, datetime
from pydantic import BaseModel, Field from shared.schemas.trading import FundamentalsSnapshot # canonical definition
# Re-export so existing ``from shared.fundamentals.base import FundamentalsSnapshot``
# --------------------------------------------------------------------------- # continues to work throughout the codebase.
# FundamentalsSnapshot — canonical schema __all__ = ["FundamentalsSnapshot", "FundamentalsProvider"]
#
# NOTE: This is the authoritative definition until it is moved into
# ``shared.schemas.trading``. Once the parallel schema task lands, delete
# this class and update all imports to point at the schemas module.
# ---------------------------------------------------------------------------
class FundamentalsSnapshot(BaseModel):
"""Point-in-time snapshot of a stock's fundamental financial metrics."""
ticker: str
eps: float | None = None
pe_ratio: float | None = None
peg_ratio: float | None = None
revenue_growth: float | None = None
profit_margin: float | None = None
debt_to_equity: float | None = None
market_cap: float | None = None
fetched_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import datetime, timezone
import httpx import httpx
@ -48,13 +49,14 @@ class FMPProvider(FundamentalsProvider):
return FundamentalsSnapshot( return FundamentalsSnapshot(
ticker=ticker, ticker=ticker,
eps=_safe_float(item.get("netIncomePerShareTTM")), eps_ttm=_safe_float(item.get("netIncomePerShareTTM")),
pe_ratio=_safe_float(item.get("peRatioTTM")), pe_ratio=_safe_float(item.get("peRatioTTM")),
peg_ratio=_safe_float(item.get("pegRatioTTM")), peg_ratio=_safe_float(item.get("pegRatioTTM")),
revenue_growth=_safe_float(item.get("revenueGrowth")), revenue_growth_yoy=_safe_float(item.get("revenueGrowth")),
profit_margin=_safe_float(item.get("netProfitMarginTTM")), profit_margin=_safe_float(item.get("netProfitMarginTTM")),
debt_to_equity=_safe_float(item.get("debtToEquityTTM")), debt_to_equity=_safe_float(item.get("debtToEquityTTM")),
market_cap=_safe_float(item.get("marketCapTTM")), market_cap=_safe_float(item.get("marketCapTTM")),
fetched_at=datetime.now(timezone.utc),
) )
except Exception: except Exception:
logger.exception("FMP fetch failed for %s", ticker) logger.exception("FMP fetch failed for %s", ticker)

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from datetime import datetime, timezone
from shared.fundamentals.base import FundamentalsProvider, FundamentalsSnapshot from shared.fundamentals.base import FundamentalsProvider, FundamentalsSnapshot
@ -48,13 +49,14 @@ class YahooFinanceProvider(FundamentalsProvider):
return FundamentalsSnapshot( return FundamentalsSnapshot(
ticker=ticker, ticker=ticker,
eps=_safe_float(info.get("trailingEps")), eps_ttm=_safe_float(info.get("trailingEps")),
pe_ratio=_safe_float(info.get("trailingPE")), pe_ratio=_safe_float(info.get("trailingPE")),
peg_ratio=_safe_float(info.get("pegRatio")), peg_ratio=_safe_float(info.get("pegRatio")),
revenue_growth=_safe_float(info.get("revenueGrowth")), revenue_growth_yoy=_safe_float(info.get("revenueGrowth")),
profit_margin=_safe_float(info.get("profitMargins")), profit_margin=_safe_float(info.get("profitMargins")),
debt_to_equity=_safe_float_div100(info.get("debtToEquity")), debt_to_equity=_safe_float_div100(info.get("debtToEquity")),
market_cap=_safe_float(info.get("marketCap")), market_cap=_safe_float(info.get("marketCap")),
fetched_at=datetime.now(timezone.utc),
) )
except Exception: except Exception:
logger.exception("Yahoo Finance fetch failed for %s", ticker) logger.exception("Yahoo Finance fetch failed for %s", ticker)

View file

@ -28,41 +28,42 @@ class TestFundamentalsSnapshot:
def test_create_with_all_fields(self) -> None: def test_create_with_all_fields(self) -> None:
snap = FundamentalsSnapshot( snap = FundamentalsSnapshot(
ticker="AAPL", ticker="AAPL",
eps=6.57, eps_ttm=6.57,
pe_ratio=28.3, pe_ratio=28.3,
peg_ratio=2.1, peg_ratio=2.1,
revenue_growth=0.08, revenue_growth_yoy=0.08,
profit_margin=0.26, profit_margin=0.26,
debt_to_equity=1.87, debt_to_equity=1.87,
market_cap=2_800_000_000_000.0, market_cap=2_800_000_000_000.0,
fetched_at=datetime.now(timezone.utc),
) )
assert snap.ticker == "AAPL" assert snap.ticker == "AAPL"
assert snap.eps == 6.57 assert snap.eps_ttm == 6.57
assert snap.pe_ratio == 28.3 assert snap.pe_ratio == 28.3
assert snap.peg_ratio == 2.1 assert snap.peg_ratio == 2.1
assert snap.revenue_growth == 0.08 assert snap.revenue_growth_yoy == 0.08
assert snap.profit_margin == 0.26 assert snap.profit_margin == 0.26
assert snap.debt_to_equity == 1.87 assert snap.debt_to_equity == 1.87
assert snap.market_cap == 2_800_000_000_000.0 assert snap.market_cap == 2_800_000_000_000.0
assert isinstance(snap.fetched_at, datetime) assert isinstance(snap.fetched_at, datetime)
def test_create_with_optional_fields_none(self) -> None: def test_create_with_optional_fields_none(self) -> None:
snap = FundamentalsSnapshot(ticker="XYZ") snap = FundamentalsSnapshot(ticker="XYZ", fetched_at=datetime.now(timezone.utc))
assert snap.ticker == "XYZ" assert snap.ticker == "XYZ"
assert snap.eps is None assert snap.eps_ttm is None
assert snap.pe_ratio is None assert snap.pe_ratio is None
assert snap.peg_ratio is None assert snap.peg_ratio is None
assert snap.revenue_growth is None assert snap.revenue_growth_yoy is None
assert snap.profit_margin is None assert snap.profit_margin is None
assert snap.debt_to_equity is None assert snap.debt_to_equity is None
assert snap.market_cap is None assert snap.market_cap is None
def test_serialization_round_trip(self) -> None: def test_serialization_round_trip(self) -> None:
snap = FundamentalsSnapshot(ticker="MSFT", eps=11.0, pe_ratio=35.0) snap = FundamentalsSnapshot(ticker="MSFT", eps_ttm=11.0, pe_ratio=35.0, fetched_at=datetime.now(timezone.utc))
data = snap.model_dump() data = snap.model_dump()
rebuilt = FundamentalsSnapshot.model_validate(data) rebuilt = FundamentalsSnapshot.model_validate(data)
assert rebuilt.ticker == "MSFT" assert rebuilt.ticker == "MSFT"
assert rebuilt.eps == 11.0 assert rebuilt.eps_ttm == 11.0
assert rebuilt.pe_ratio == 35.0 assert rebuilt.pe_ratio == 35.0
@ -134,10 +135,10 @@ class TestAlphaVantageProvider:
assert result is not None assert result is not None
assert result.ticker == "AAPL" assert result.ticker == "AAPL"
assert result.eps == 6.57 assert result.eps_ttm == 6.57
assert result.pe_ratio == 28.3 assert result.pe_ratio == 28.3
assert result.peg_ratio == 2.1 assert result.peg_ratio == 2.1
assert result.revenue_growth == 0.08 assert result.revenue_growth_yoy == 0.08
assert result.profit_margin == 0.26 assert result.profit_margin == 0.26
assert result.debt_to_equity == pytest.approx(1.87) assert result.debt_to_equity == pytest.approx(1.87)
assert result.market_cap == 2_800_000_000_000.0 assert result.market_cap == 2_800_000_000_000.0
@ -198,7 +199,7 @@ class TestAlphaVantageProvider:
result = await provider.fetch("GOOG") result = await provider.fetch("GOOG")
assert result is not None assert result is not None
assert result.eps == 5.80 assert result.eps_ttm == 5.80
assert result.pe_ratio is None assert result.pe_ratio is None
assert result.peg_ratio is None assert result.peg_ratio is None
@ -240,7 +241,7 @@ class TestFMPProvider:
assert result is not None assert result is not None
assert result.ticker == "AAPL" assert result.ticker == "AAPL"
assert result.eps == 6.57 assert result.eps_ttm == 6.57
assert result.pe_ratio == 28.3 assert result.pe_ratio == 28.3
assert result.debt_to_equity == 1.87 assert result.debt_to_equity == 1.87
assert result.market_cap == 2_800_000_000_000.0 assert result.market_cap == 2_800_000_000_000.0
@ -296,10 +297,10 @@ class TestYahooFinanceProvider:
assert result is not None assert result is not None
assert result.ticker == "AAPL" assert result.ticker == "AAPL"
assert result.eps == 6.57 assert result.eps_ttm == 6.57
assert result.pe_ratio == 28.3 assert result.pe_ratio == 28.3
assert result.peg_ratio == 2.1 assert result.peg_ratio == 2.1
assert result.revenue_growth == 0.08 assert result.revenue_growth_yoy == 0.08
assert result.profit_margin == 0.26 assert result.profit_margin == 0.26
assert result.debt_to_equity == pytest.approx(1.87) assert result.debt_to_equity == pytest.approx(1.87)
assert result.market_cap == 2_800_000_000_000.0 assert result.market_cap == 2_800_000_000_000.0
@ -348,18 +349,18 @@ class TestRotatingProvider:
RotatingProvider(providers=[]) RotatingProvider(providers=[])
async def test_returns_first_success(self) -> None: async def test_returns_first_success(self) -> None:
snap = FundamentalsSnapshot(ticker="AAPL", eps=6.0) snap = FundamentalsSnapshot(ticker="AAPL", eps_ttm=6.0, fetched_at=datetime.now(timezone.utc))
p1 = _StubProvider(result=snap) p1 = _StubProvider(result=snap)
p2 = _StubProvider(result=FundamentalsSnapshot(ticker="AAPL", eps=99.0)) p2 = _StubProvider(result=FundamentalsSnapshot(ticker="AAPL", eps_ttm=99.0, fetched_at=datetime.now(timezone.utc)))
rp = RotatingProvider(providers=[p1, p2]) rp = RotatingProvider(providers=[p1, p2])
result = await rp.fetch("AAPL") result = await rp.fetch("AAPL")
assert result is not None assert result is not None
assert result.eps == 6.0 # from p1, not p2 assert result.eps_ttm == 6.0 # from p1, not p2
async def test_falls_back_on_none(self) -> None: async def test_falls_back_on_none(self) -> None:
snap = FundamentalsSnapshot(ticker="AAPL", pe_ratio=30.0) snap = FundamentalsSnapshot(ticker="AAPL", pe_ratio=30.0, fetched_at=datetime.now(timezone.utc))
p1 = _StubProvider(result=None) p1 = _StubProvider(result=None)
p2 = _StubProvider(result=snap) p2 = _StubProvider(result=snap)
@ -378,7 +379,7 @@ class TestRotatingProvider:
assert result is None assert result is None
async def test_handles_exceptions(self) -> None: async def test_handles_exceptions(self) -> None:
snap = FundamentalsSnapshot(ticker="AAPL", eps=5.0) snap = FundamentalsSnapshot(ticker="AAPL", eps_ttm=5.0, fetched_at=datetime.now(timezone.utc))
p1 = _StubProvider(error=RuntimeError("boom")) p1 = _StubProvider(error=RuntimeError("boom"))
p2 = _StubProvider(result=snap) p2 = _StubProvider(result=snap)
@ -386,7 +387,7 @@ class TestRotatingProvider:
result = await rp.fetch("AAPL") result = await rp.fetch("AAPL")
assert result is not None assert result is not None
assert result.eps == 5.0 # fell through after p1 raised assert result.eps_ttm == 5.0 # fell through after p1 raised
async def test_all_raise(self) -> None: async def test_all_raise(self) -> None:
p1 = _StubProvider(error=RuntimeError("boom1")) p1 = _StubProvider(error=RuntimeError("boom1"))