The A5 cutover has been on and observed in production, so SEC Company Facts + DoltHub earnings are already the live source for `fundamental_data`. This removes everything the legacy path still occupied. Gone: the three providers and their config/env keys; the weekly `fundamental_collector` job; the cutover toggle (SEC + Dolt is now the unconditional path, so `off` can no longer silently freeze scoring inputs); the A5 parity report, whose deltas became structurally zero once the candidate builder started writing the table it compared against; and the FMP tier of universe bootstrap. Two behavioral notes: - Disabling **SEC Fundamentals Import** now stops the SEC network fetch only. The local cache refresh moved outside the job-enable check, because candidates also derive from daily closes and earnings events — freezing those on an ingestion pause would stale scoring with no fallback left to recover from. - `/ingestion/fetch?sources=fundamentals` still accepts the key and reports `skipped`; there is no per-ticker fetch any more. Migration 029 does not blanket-delete the leftover settings rows. Migrations run before the service restart, and pre-A6 code reads an absent `job_*_enabled` row as *enabled* — so the two behavior-bearing keys become tombstones pinned to safe values (hidden in Admin) and only the inert three are deleted. Removing the provider keys from the production `.env` is the matching rollout step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
155 lines
4.9 KiB
Python
155 lines
4.9 KiB
Python
"""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
|
|
)
|