refactor: reconcile FundamentalsSnapshot to use canonical schema from trading.py
This commit is contained in:
parent
aa47e896dd
commit
6f512cf91f
6 changed files with 40 additions and 55 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue