From 6f512cf91f53f42ec3742250dcb2482005e70b37 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Mon, 23 Feb 2026 21:45:18 +0000 Subject: [PATCH] refactor: reconcile FundamentalsSnapshot to use canonical schema from trading.py --- shared/fundamentals/__init__.py | 4 +-- shared/fundamentals/alpha_vantage.py | 6 ++-- shared/fundamentals/base.py | 30 +++---------------- shared/fundamentals/fmp.py | 6 ++-- shared/fundamentals/yahoo.py | 6 ++-- tests/test_fundamentals.py | 43 ++++++++++++++-------------- 6 files changed, 40 insertions(+), 55 deletions(-) diff --git a/shared/fundamentals/__init__.py b/shared/fundamentals/__init__.py index 330f56c..7097677 100644 --- a/shared/fundamentals/__init__.py +++ b/shared/fundamentals/__init__.py @@ -1,6 +1,6 @@ """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 -__all__ = ["FundamentalsProvider", "RotatingProvider"] +__all__ = ["FundamentalsProvider", "FundamentalsSnapshot", "RotatingProvider"] diff --git a/shared/fundamentals/alpha_vantage.py b/shared/fundamentals/alpha_vantage.py index 8a3f5c0..585cd93 100644 --- a/shared/fundamentals/alpha_vantage.py +++ b/shared/fundamentals/alpha_vantage.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from datetime import datetime, timezone import httpx @@ -63,13 +64,14 @@ class AlphaVantageProvider(FundamentalsProvider): return FundamentalsSnapshot( ticker=ticker, - eps=_safe_float(data.get("EPS")), + eps_ttm=_safe_float(data.get("EPS")), pe_ratio=_safe_float(data.get("PERatio")), 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")), debt_to_equity=_safe_float_div100(data.get("DebtToEquity")), market_cap=_safe_float(data.get("MarketCapitalization")), + fetched_at=datetime.now(timezone.utc), ) except Exception: logger.exception("Alpha Vantage fetch failed for %s", ticker) diff --git a/shared/fundamentals/base.py b/shared/fundamentals/base.py index 9f93fdc..b0172c0 100644 --- a/shared/fundamentals/base.py +++ b/shared/fundamentals/base.py @@ -3,34 +3,12 @@ from __future__ import annotations from abc import ABC, abstractmethod -from datetime import UTC, datetime -from pydantic import BaseModel, Field +from shared.schemas.trading import FundamentalsSnapshot # canonical definition - -# --------------------------------------------------------------------------- -# FundamentalsSnapshot — canonical schema -# -# 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} +# Re-export so existing ``from shared.fundamentals.base import FundamentalsSnapshot`` +# continues to work throughout the codebase. +__all__ = ["FundamentalsSnapshot", "FundamentalsProvider"] # --------------------------------------------------------------------------- diff --git a/shared/fundamentals/fmp.py b/shared/fundamentals/fmp.py index 3eb4df2..81aa4a2 100644 --- a/shared/fundamentals/fmp.py +++ b/shared/fundamentals/fmp.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from datetime import datetime, timezone import httpx @@ -48,13 +49,14 @@ class FMPProvider(FundamentalsProvider): return FundamentalsSnapshot( ticker=ticker, - eps=_safe_float(item.get("netIncomePerShareTTM")), + eps_ttm=_safe_float(item.get("netIncomePerShareTTM")), pe_ratio=_safe_float(item.get("peRatioTTM")), 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")), debt_to_equity=_safe_float(item.get("debtToEquityTTM")), market_cap=_safe_float(item.get("marketCapTTM")), + fetched_at=datetime.now(timezone.utc), ) except Exception: logger.exception("FMP fetch failed for %s", ticker) diff --git a/shared/fundamentals/yahoo.py b/shared/fundamentals/yahoo.py index ad9a1c3..34bdd2d 100644 --- a/shared/fundamentals/yahoo.py +++ b/shared/fundamentals/yahoo.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import logging +from datetime import datetime, timezone from shared.fundamentals.base import FundamentalsProvider, FundamentalsSnapshot @@ -48,13 +49,14 @@ class YahooFinanceProvider(FundamentalsProvider): return FundamentalsSnapshot( ticker=ticker, - eps=_safe_float(info.get("trailingEps")), + eps_ttm=_safe_float(info.get("trailingEps")), pe_ratio=_safe_float(info.get("trailingPE")), 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")), debt_to_equity=_safe_float_div100(info.get("debtToEquity")), market_cap=_safe_float(info.get("marketCap")), + fetched_at=datetime.now(timezone.utc), ) except Exception: logger.exception("Yahoo Finance fetch failed for %s", ticker) diff --git a/tests/test_fundamentals.py b/tests/test_fundamentals.py index 2f684ad..26335eb 100644 --- a/tests/test_fundamentals.py +++ b/tests/test_fundamentals.py @@ -28,41 +28,42 @@ class TestFundamentalsSnapshot: def test_create_with_all_fields(self) -> None: snap = FundamentalsSnapshot( ticker="AAPL", - eps=6.57, + eps_ttm=6.57, pe_ratio=28.3, peg_ratio=2.1, - revenue_growth=0.08, + revenue_growth_yoy=0.08, profit_margin=0.26, debt_to_equity=1.87, market_cap=2_800_000_000_000.0, + fetched_at=datetime.now(timezone.utc), ) assert snap.ticker == "AAPL" - assert snap.eps == 6.57 + assert snap.eps_ttm == 6.57 assert snap.pe_ratio == 28.3 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.debt_to_equity == 1.87 assert snap.market_cap == 2_800_000_000_000.0 assert isinstance(snap.fetched_at, datetime) 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.eps is None + assert snap.eps_ttm is None assert snap.pe_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.debt_to_equity is None assert snap.market_cap is 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() rebuilt = FundamentalsSnapshot.model_validate(data) assert rebuilt.ticker == "MSFT" - assert rebuilt.eps == 11.0 + assert rebuilt.eps_ttm == 11.0 assert rebuilt.pe_ratio == 35.0 @@ -134,10 +135,10 @@ class TestAlphaVantageProvider: assert result is not None assert result.ticker == "AAPL" - assert result.eps == 6.57 + assert result.eps_ttm == 6.57 assert result.pe_ratio == 28.3 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.debt_to_equity == pytest.approx(1.87) assert result.market_cap == 2_800_000_000_000.0 @@ -198,7 +199,7 @@ class TestAlphaVantageProvider: result = await provider.fetch("GOOG") assert result is not None - assert result.eps == 5.80 + assert result.eps_ttm == 5.80 assert result.pe_ratio is None assert result.peg_ratio is None @@ -240,7 +241,7 @@ class TestFMPProvider: assert result is not None assert result.ticker == "AAPL" - assert result.eps == 6.57 + assert result.eps_ttm == 6.57 assert result.pe_ratio == 28.3 assert result.debt_to_equity == 1.87 assert result.market_cap == 2_800_000_000_000.0 @@ -296,10 +297,10 @@ class TestYahooFinanceProvider: assert result is not None assert result.ticker == "AAPL" - assert result.eps == 6.57 + assert result.eps_ttm == 6.57 assert result.pe_ratio == 28.3 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.debt_to_equity == pytest.approx(1.87) assert result.market_cap == 2_800_000_000_000.0 @@ -348,18 +349,18 @@ class TestRotatingProvider: RotatingProvider(providers=[]) 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) - 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]) result = await rp.fetch("AAPL") 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: - 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) p2 = _StubProvider(result=snap) @@ -378,7 +379,7 @@ class TestRotatingProvider: assert result is 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")) p2 = _StubProvider(result=snap) @@ -386,7 +387,7 @@ class TestRotatingProvider: result = await rp.fetch("AAPL") 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: p1 = _StubProvider(error=RuntimeError("boom1"))