From 0530f496ca7bc869bc6b9d92881867b8388fddf0 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Mon, 23 Feb 2026 21:49:31 +0000 Subject: [PATCH] feat: add fundamentals DB model and cached provider --- .../b2c3d4e5f6a7_add_fundamentals_table.py | 43 ++++++++ shared/fundamentals/cache.py | 99 +++++++++++++++++++ shared/models/__init__.py | 3 + shared/models/fundamentals.py | 27 +++++ tests/test_models.py | 45 +++++++++ 5 files changed, 217 insertions(+) create mode 100644 alembic/versions/b2c3d4e5f6a7_add_fundamentals_table.py create mode 100644 shared/fundamentals/cache.py create mode 100644 shared/models/fundamentals.py diff --git a/alembic/versions/b2c3d4e5f6a7_add_fundamentals_table.py b/alembic/versions/b2c3d4e5f6a7_add_fundamentals_table.py new file mode 100644 index 0000000..30dd7d7 --- /dev/null +++ b/alembic/versions/b2c3d4e5f6a7_add_fundamentals_table.py @@ -0,0 +1,43 @@ +"""add fundamentals table + +Revision ID: b2c3d4e5f6a7 +Revises: a1b2c3d4e5f6 +Create Date: 2026-02-23 10:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "b2c3d4e5f6a7" +down_revision: Union[str, Sequence[str], None] = "a1b2c3d4e5f6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Create the fundamentals table for caching fundamental data.""" + + op.create_table( + "fundamentals", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("ticker", sa.String(20), unique=True, nullable=False, index=True), + sa.Column("eps_ttm", sa.Float, nullable=True), + sa.Column("pe_ratio", sa.Float, nullable=True), + sa.Column("peg_ratio", sa.Float, nullable=True), + sa.Column("revenue_growth_yoy", sa.Float, nullable=True), + sa.Column("profit_margin", sa.Float, nullable=True), + sa.Column("debt_to_equity", sa.Float, nullable=True), + sa.Column("market_cap", sa.Float, nullable=True), + sa.Column("fetched_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + +def downgrade() -> None: + """Drop the fundamentals table.""" + op.drop_table("fundamentals") diff --git a/shared/fundamentals/cache.py b/shared/fundamentals/cache.py new file mode 100644 index 0000000..0f72ba1 --- /dev/null +++ b/shared/fundamentals/cache.py @@ -0,0 +1,99 @@ +"""DB-backed cache for fundamental data.""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone, timedelta + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import async_sessionmaker + +from shared.fundamentals.base import FundamentalsProvider +from shared.schemas.trading import FundamentalsSnapshot +from shared.models.fundamentals import Fundamentals + +logger = logging.getLogger(__name__) + + +class CachedFundamentalsProvider: + """Wraps a FundamentalsProvider with DB-backed caching.""" + + def __init__( + self, + provider: FundamentalsProvider, + session_factory: async_sessionmaker, + cache_ttl_hours: int = 24, + ) -> None: + self._provider = provider + self._session_factory = session_factory + self._cache_ttl = timedelta(hours=cache_ttl_hours) + + async def fetch(self, ticker: str) -> FundamentalsSnapshot | None: + cached = await self._load_from_db(ticker) + if cached is not None: + age = datetime.now(timezone.utc) - cached.fetched_at.replace(tzinfo=timezone.utc) + if age < self._cache_ttl: + logger.debug("Cache hit for %s (age=%s)", ticker, age) + return cached + logger.debug("Cache stale for %s (age=%s), refreshing", ticker, age) + + result = await self._provider.fetch(ticker) + if result is not None: + await self._save_to_db(result) + return result + + async def _load_from_db(self, ticker: str) -> FundamentalsSnapshot | None: + try: + async with self._session_factory() as session: + stmt = select(Fundamentals).where(Fundamentals.ticker == ticker) + row = (await session.execute(stmt)).scalar_one_or_none() + if row is None: + return None + return FundamentalsSnapshot( + ticker=row.ticker, + eps_ttm=row.eps_ttm, + pe_ratio=row.pe_ratio, + peg_ratio=row.peg_ratio, + revenue_growth_yoy=row.revenue_growth_yoy, + profit_margin=row.profit_margin, + debt_to_equity=row.debt_to_equity, + market_cap=row.market_cap, + fetched_at=row.fetched_at, + ) + except Exception: + logger.exception("Failed to load fundamentals from DB for %s", ticker) + return None + + async def _save_to_db(self, snapshot: FundamentalsSnapshot) -> None: + try: + async with self._session_factory() as session: + stmt = select(Fundamentals).where(Fundamentals.ticker == snapshot.ticker) + existing = (await session.execute(stmt)).scalar_one_or_none() + + if existing: + existing.eps_ttm = snapshot.eps_ttm + existing.pe_ratio = snapshot.pe_ratio + existing.peg_ratio = snapshot.peg_ratio + existing.revenue_growth_yoy = snapshot.revenue_growth_yoy + existing.profit_margin = snapshot.profit_margin + existing.debt_to_equity = snapshot.debt_to_equity + existing.market_cap = snapshot.market_cap + existing.fetched_at = snapshot.fetched_at + else: + row = Fundamentals( + ticker=snapshot.ticker, + eps_ttm=snapshot.eps_ttm, + pe_ratio=snapshot.pe_ratio, + peg_ratio=snapshot.peg_ratio, + revenue_growth_yoy=snapshot.revenue_growth_yoy, + profit_margin=snapshot.profit_margin, + debt_to_equity=snapshot.debt_to_equity, + market_cap=snapshot.market_cap, + fetched_at=snapshot.fetched_at, + ) + session.add(row) + + await session.commit() + logger.debug("Saved fundamentals for %s to DB", snapshot.ticker) + except Exception: + logger.exception("Failed to save fundamentals for %s to DB", snapshot.ticker) diff --git a/shared/models/__init__.py b/shared/models/__init__.py index d461e61..dda1c6a 100644 --- a/shared/models/__init__.py +++ b/shared/models/__init__.py @@ -15,6 +15,7 @@ from shared.models.news import Article, ArticleSentiment from shared.models.learning import LearningAdjustment, TradeOutcome from shared.models.auth import User, UserCredential from shared.models.timeseries import MarketData, PortfolioSnapshot, StrategyMetric +from shared.models.fundamentals import Fundamentals __all__ = [ "Base", @@ -41,4 +42,6 @@ __all__ = [ "MarketData", "PortfolioSnapshot", "StrategyMetric", + # Fundamentals + "Fundamentals", ] diff --git a/shared/models/fundamentals.py b/shared/models/fundamentals.py new file mode 100644 index 0000000..7c238ed --- /dev/null +++ b/shared/models/fundamentals.py @@ -0,0 +1,27 @@ +"""Fundamentals database model for caching fundamental data.""" + +import uuid +from datetime import datetime + +from sqlalchemy import Float, String, DateTime +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from shared.models.base import Base, TimestampMixin + + +class Fundamentals(TimestampMixin, Base): + __tablename__ = "fundamentals" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + ticker: Mapped[str] = mapped_column(String(20), unique=True, nullable=False, index=True) + eps_ttm: Mapped[float | None] = mapped_column(Float, nullable=True) + pe_ratio: Mapped[float | None] = mapped_column(Float, nullable=True) + peg_ratio: Mapped[float | None] = mapped_column(Float, nullable=True) + revenue_growth_yoy: Mapped[float | None] = mapped_column(Float, nullable=True) + profit_margin: Mapped[float | None] = mapped_column(Float, nullable=True) + debt_to_equity: Mapped[float | None] = mapped_column(Float, nullable=True) + market_cap: Mapped[float | None] = mapped_column(Float, nullable=True) + fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) diff --git a/tests/test_models.py b/tests/test_models.py index 1f426a2..97e8fb4 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -30,6 +30,8 @@ from shared.models import ( MarketData, PortfolioSnapshot, StrategyMetric, + # Fundamentals + Fundamentals, ) from shared.db import create_db from shared.config import BaseConfig @@ -284,6 +286,48 @@ class TestStrategyMetric: assert sm.sharpe_ratio == 1.8 +class TestFundamentals: + def test_create_fundamentals(self) -> None: + now = datetime.now(timezone.utc) + f = Fundamentals( + id=uuid.uuid4(), + ticker="AAPL", + eps_ttm=6.57, + pe_ratio=28.3, + peg_ratio=2.1, + revenue_growth_yoy=0.08, + profit_margin=0.26, + debt_to_equity=1.87, + market_cap=2_800_000_000_000.0, + fetched_at=now, + ) + assert f.ticker == "AAPL" + assert f.eps_ttm == 6.57 + assert f.pe_ratio == 28.3 + assert f.peg_ratio == 2.1 + assert f.revenue_growth_yoy == 0.08 + assert f.profit_margin == 0.26 + assert f.debt_to_equity == 1.87 + assert f.market_cap == 2_800_000_000_000.0 + assert f.fetched_at == now + + def test_create_with_optional_fields_none(self) -> None: + now = datetime.now(timezone.utc) + f = Fundamentals( + id=uuid.uuid4(), + ticker="XYZ", + fetched_at=now, + ) + assert f.ticker == "XYZ" + assert f.eps_ttm is None + assert f.pe_ratio is None + assert f.peg_ratio is None + assert f.revenue_growth_yoy is None + assert f.profit_margin is None + assert f.debt_to_equity is None + assert f.market_cap is None + + # --------------------------------------------------------------------------- # Metadata / Base tests # --------------------------------------------------------------------------- @@ -306,6 +350,7 @@ class TestMetadata: "market_data", "portfolio_snapshots", "strategy_metrics", + "fundamentals", } assert expected.issubset(table_names)