107 lines
3.2 KiB
Python
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()
|