Files
signal-platform/app/services/fundamental_service.py
T
dennisthiessen f0b92a9718
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 36s
Deploy / deploy (push) Successful in 25s
add earnings-date guard — warn when a report falls in the target horizon
Finnhub's earnings calendar now supplies next_earnings_date through the
fundamentals chain; persisted on fundamental_data (migration 006) and exposed in
the fundamentals API. The recommendation panel warns when earnings fall within
the ~30-day target horizon (a report can gap price through stop/target) and
otherwise shows the next date. Informational only.

Deploy: run alembic upgrade (new fundamental_data.next_earnings_date column).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 12:44:08 +02:00

110 lines
3.4 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,
next_earnings_date=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.next_earnings_date = next_earnings_date
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,
next_earnings_date=next_earnings_date,
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()