"""Refresh the fundamentals compat cache from local SEC/Dolt bulk data. ``fundamental_data`` is the table scoring reads. This is its only writer. """ from __future__ import annotations import json from datetime import date, datetime, timezone from typing import Any from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.database import insert_for_session from app.models.fundamental import FundamentalData from app.models.score import CompositeScore, DimensionScore from app.services import fundamentals_candidate_service _SCORE_FIELDS = ("pe_ratio", "revenue_growth", "earnings_surprise") async def refresh( db: AsyncSession, *, now: datetime | None = None, today: date | None = None, ) -> dict[str, Any]: """Replace every ticker's compat-cache row in one database transaction. Candidate values are assembled before the first write and use only local PostgreSQL tables. A failure rolls the whole refresh back. Only changes to the three scoring inputs invalidate cached scores; market cap and the next earnings date are display-only. """ refreshed_at = now or datetime.now(timezone.utc) candidates = await fundamentals_candidate_service.build_candidates( db, today=today ) ticker_ids = [candidate.ticker_id for candidate in candidates] existing = await _existing_by_ticker(db, ticker_ids) changed_ids = { candidate.ticker_id for candidate in candidates if _score_inputs_changed(existing.get(candidate.ticker_id), candidate) } for candidate in candidates: unavailable_json = json.dumps( candidate.unavailable_fields, sort_keys=True ) stmt = insert_for_session(db, FundamentalData).values( ticker_id=candidate.ticker_id, pe_ratio=candidate.pe_ratio, revenue_growth=candidate.revenue_growth, earnings_surprise=candidate.earnings_surprise, market_cap=candidate.market_cap, next_earnings_date=candidate.next_earnings_date, fetched_at=refreshed_at, unavailable_fields_json=unavailable_json, ) await db.execute( stmt.on_conflict_do_update( index_elements=["ticker_id"], set_={ "pe_ratio": stmt.excluded.pe_ratio, "revenue_growth": stmt.excluded.revenue_growth, "earnings_surprise": stmt.excluded.earnings_surprise, "market_cap": stmt.excluded.market_cap, "next_earnings_date": stmt.excluded.next_earnings_date, "fetched_at": stmt.excluded.fetched_at, "unavailable_fields_json": ( stmt.excluded.unavailable_fields_json ), }, ) ) dimension_ids = await _fundamental_dimension_ids(db, changed_ids) composite_ids = await _composite_ids(db, changed_ids) if dimension_ids: await db.execute( update(DimensionScore) .where(DimensionScore.ticker_id.in_(dimension_ids)) .values(is_stale=True) ) if composite_ids: await db.execute( update(CompositeScore) .where(CompositeScore.ticker_id.in_(composite_ids)) .values(is_stale=True) ) await db.commit() return { "refreshed": len(candidates), "score_inputs_changed": len(changed_ids), "dimension_scores_staled": len(dimension_ids), "composite_scores_staled": len(composite_ids), } async def _existing_by_ticker( db: AsyncSession, ticker_ids: list[int] ) -> dict[int, FundamentalData]: if not ticker_ids: return {} rows = ( await db.execute( select(FundamentalData).where( FundamentalData.ticker_id.in_(ticker_ids) ) ) ).scalars() return {row.ticker_id: row for row in rows} async def _fundamental_dimension_ids( db: AsyncSession, ticker_ids: set[int] ) -> set[int]: if not ticker_ids: return set() rows = await db.execute( select(DimensionScore.ticker_id).where( DimensionScore.ticker_id.in_(ticker_ids), DimensionScore.dimension == "fundamental", ) ) return set(rows.scalars()) async def _composite_ids( db: AsyncSession, ticker_ids: set[int] ) -> set[int]: if not ticker_ids: return set() rows = await db.execute( select(CompositeScore.ticker_id).where( CompositeScore.ticker_id.in_(ticker_ids) ) ) return set(rows.scalars()) def _score_inputs_changed( existing: FundamentalData | None, candidate: fundamentals_candidate_service.CandidateFundamentals, ) -> bool: if existing is None: return True return any( getattr(existing, field) != getattr(candidate, field) for field in _SCORE_FIELDS )