feat: add fundamentals DB model and cached provider
This commit is contained in:
parent
6c909d12c3
commit
0530f496ca
5 changed files with 217 additions and 0 deletions
43
alembic/versions/b2c3d4e5f6a7_add_fundamentals_table.py
Normal file
43
alembic/versions/b2c3d4e5f6a7_add_fundamentals_table.py
Normal 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")
|
||||||
99
shared/fundamentals/cache.py
Normal file
99
shared/fundamentals/cache.py
Normal 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)
|
||||||
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
27
shared/models/fundamentals.py
Normal file
27
shared/models/fundamentals.py
Normal 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)
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue