feat: add fundamentals DB model and cached provider

This commit is contained in:
Viktor Barzin 2026-02-23 21:49:31 +00:00
parent 6c909d12c3
commit 0530f496ca
No known key found for this signature in database
GPG key ID: 0EB088298288D958
5 changed files with 217 additions and 0 deletions

View file

@ -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")

View file

@ -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)

View file

@ -15,6 +15,7 @@ from shared.models.news import Article, ArticleSentiment
from shared.models.learning import LearningAdjustment, TradeOutcome from shared.models.learning import LearningAdjustment, TradeOutcome
from shared.models.auth import User, UserCredential from shared.models.auth import User, UserCredential
from shared.models.timeseries import MarketData, PortfolioSnapshot, StrategyMetric from shared.models.timeseries import MarketData, PortfolioSnapshot, StrategyMetric
from shared.models.fundamentals import Fundamentals
__all__ = [ __all__ = [
"Base", "Base",
@ -41,4 +42,6 @@ __all__ = [
"MarketData", "MarketData",
"PortfolioSnapshot", "PortfolioSnapshot",
"StrategyMetric", "StrategyMetric",
# Fundamentals
"Fundamentals",
] ]

View file

@ -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)

View file

@ -30,6 +30,8 @@ from shared.models import (
MarketData, MarketData,
PortfolioSnapshot, PortfolioSnapshot,
StrategyMetric, StrategyMetric,
# Fundamentals
Fundamentals,
) )
from shared.db import create_db from shared.db import create_db
from shared.config import BaseConfig from shared.config import BaseConfig
@ -284,6 +286,48 @@ class TestStrategyMetric:
assert sm.sharpe_ratio == 1.8 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 # Metadata / Base tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -306,6 +350,7 @@ class TestMetadata:
"market_data", "market_data",
"portfolio_snapshots", "portfolio_snapshots",
"strategy_metrics", "strategy_metrics",
"fundamentals",
} }
assert expected.issubset(table_names) assert expected.issubset(table_names)