Files
signal-platform/app/services/fundamental_service.py
Dennis Thiessen 181cfe6588
Some checks failed
Deploy / lint (push) Failing after 8s
Deploy / test (push) Has been skipped
Deploy / deploy (push) Has been skipped
major update
2026-02-27 16:08:09 +01:00

107 lines
3.2 KiB
Python

"""Fundamental data service.
Stores fundamental data (P/E, revenue growth, earnings surprise, market cap)
and marks the fundamental dimension score as stale on new data.
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.exceptions import NotFoundError
from app.models.fundamental import FundamentalData
from app.models.score import DimensionScore
from app.models.ticker import Ticker
logger = logging.getLogger(__name__)
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
"""Look up a ticker by symbol."""
normalised = symbol.strip().upper()
result = await db.execute(select(Ticker).where(Ticker.symbol == normalised))
ticker = result.scalar_one_or_none()
if ticker is None:
raise NotFoundError(f"Ticker not found: {normalised}")
return ticker
async def store_fundamental(
db: AsyncSession,
symbol: str,
pe_ratio: float | None = None,
revenue_growth: float | None = None,
earnings_surprise: float | None = None,
market_cap: float | None = None,
unavailable_fields: dict[str, str] | None = None,
) -> FundamentalData:
"""Store or update fundamental data for a ticker.
Keeps a single latest snapshot per ticker. On new data, marks the
fundamental dimension score as stale (if one exists).
"""
ticker = await _get_ticker(db, symbol)
# Check for existing record
result = await db.execute(
select(FundamentalData).where(FundamentalData.ticker_id == ticker.id)
)
existing = result.scalar_one_or_none()
now = datetime.now(timezone.utc)
unavailable_fields_json = json.dumps(unavailable_fields or {})
if existing is not None:
existing.pe_ratio = pe_ratio
existing.revenue_growth = revenue_growth
existing.earnings_surprise = earnings_surprise
existing.market_cap = market_cap
existing.fetched_at = now
existing.unavailable_fields_json = unavailable_fields_json
record = existing
else:
record = FundamentalData(
ticker_id=ticker.id,
pe_ratio=pe_ratio,
revenue_growth=revenue_growth,
earnings_surprise=earnings_surprise,
market_cap=market_cap,
fetched_at=now,
unavailable_fields_json=unavailable_fields_json,
)
db.add(record)
# Mark fundamental dimension score as stale if it exists
# TODO: Use DimensionScore service when built
dim_result = await db.execute(
select(DimensionScore).where(
DimensionScore.ticker_id == ticker.id,
DimensionScore.dimension == "fundamental",
)
)
dim_score = dim_result.scalar_one_or_none()
if dim_score is not None:
dim_score.is_stale = True
await db.commit()
await db.refresh(record)
return record
async def get_fundamental(
db: AsyncSession,
symbol: str,
) -> FundamentalData | None:
"""Get the latest fundamental data for a ticker."""
ticker = await _get_ticker(db, symbol)
result = await db.execute(
select(FundamentalData).where(FundamentalData.ticker_id == ticker.id)
)
return result.scalar_one_or_none()