"""Production importer for the DoltHub post-no-preference/earnings calendar. A ``SourceImporter`` (see ``app/services/data_import.py``) that pulls the local Dolt clone, aligns the announcement calendar to the EPS history with the pure DP in ``earnings_alignment`` (reused from the research script, not extending it), and writes ``earnings_events`` for the tracked universe. Shadow by construction: nothing reads ``earnings_events`` until the API/panel lands (A4), so writing it does not touch production behavior. **Promotion is destructive** — future-dated rows for this source are deleted and re-inserted every run so reschedules/cancellations never linger. The forward calendar is the project's acceptance gate, so ``validate`` is fail-closed: it blocks promotion when the staged future set is empty or has collapsed relative to what's already loaded. Attribution: the earnings data is CC BY-SA 4.0 from post-no-preference/earnings. See the repo ``NOTICE``. Internal use only — no redistribution. """ from __future__ import annotations import logging from dataclasses import dataclass, field from datetime import date, datetime, timezone from pathlib import Path from typing import Any from sqlalchemy import case, delete, func, select from app.config import settings from app.database import insert_for_session from app.models.earnings_event import EarningsEvent from app.models.ticker import Ticker from app.services import dolt_client, earnings_alignment from app.services.data_import import ValidationResult logger = logging.getLogger(__name__) SOURCE = "dolt_earnings" # Earliest announcement date to import (matches the research backfill window). WINDOW_START = date(2020, 1, 22) # Alignment tolerances (research defaults): an announcement may lead its period # end by up to 14 days or lag it by up to 90. MAX_LAG_DAYS = 90 MAX_LEAD_DAYS = 14 # Fail promotion if the staged forward calendar drops below this fraction of the # currently-loaded forward calendar (guards the destructive re-insert against a # partial parse / symbol-mapping regression). MIN_FUTURE_RATIO = 0.5 _CAL_SQL = ( "SELECT act_symbol, `date`, `when` FROM earnings_calendar " f"WHERE `date` >= '{WINDOW_START.isoformat()}'" ) _HIST_SQL = ( "SELECT act_symbol, period_end_date, reported, estimate FROM eps_history " f"WHERE period_end_date >= '{(WINDOW_START.replace(year=WINDOW_START.year - 1)).isoformat()}'" ) @dataclass class StagedEarnings: rows: list[dict[str, Any]] stats: dict[str, Any] = field(default_factory=dict) future_count: int = 0 max_announce_date: date | None = None def _now() -> datetime: return datetime.now(timezone.utc) class DoltEarningsImporter: source = SOURCE def __init__( self, *, repo_dir: Path | str | None = None, binary: str | None = None, today: date | None = None, do_pull: bool = True, dolt: Any = dolt_client, ) -> None: self.repo_dir = Path( repo_dir or (Path(settings.dolt_data_dir) / settings.dolt_earnings_subdir) ) self.binary = binary or settings.dolt_binary self.today = today or _now().date() self.do_pull = do_pull self._dolt = dolt # injectable for tests # -- SourceImporter protocol ------------------------------------------- async def detect_revision(self, db) -> str | None: if self.do_pull: dolt_client.ensure_free_disk(self.repo_dir, settings.dolt_min_free_disk_gb) await self._dolt.pull(self.repo_dir, binary=self.binary) return await self._dolt.current_commit(self.repo_dir, binary=self.binary) async def stage(self, db) -> StagedEarnings: universe = await self._load_universe(db) # {normalised symbol: ticker_id} cal_raw = await self._dolt.query_csv(self.repo_dir, _CAL_SQL, binary=self.binary) hist_raw = await self._dolt.query_csv(self.repo_dir, _HIST_SQL, binary=self.binary) _require_columns(cal_raw, {"act_symbol", "date", "when"}, "earnings_calendar") _require_columns( hist_raw, {"act_symbol", "period_end_date", "reported", "estimate"}, "eps_history" ) cal_parsed = _parse_calendar(cal_raw, universe) hist_parsed = _parse_history(hist_raw, universe) calendar, cal_stats = earnings_alignment.dedup_calendar(cal_parsed) history, hist_stats = earnings_alignment.dedup_history(hist_parsed) period_lower = WINDOW_START.replace(year=WINDOW_START.year - 1) rows: list[dict[str, Any]] = [] matched = unmatched = 0 for symbol, events in calendar.items(): ticker_id = universe[symbol] periods = [ p for p in history.get(symbol, []) if p["period_end_date"] >= period_lower ] matches, unmatched_events, _ = earnings_alignment.align_symbol( events, periods, max_lag_days=MAX_LAG_DAYS, max_lead_days=MAX_LEAD_DAYS ) matched += len(matches) unmatched += len(unmatched_events) matched_by_event = {e: p for e, p in matches} for e_idx, event in enumerate(events): p_idx = matched_by_event.get(e_idx) period = periods[p_idx] if p_idx is not None else None rows.append( { "ticker_id": ticker_id, "symbol": symbol, "announce_date": event["announce_date"], "session": event["session"], "period_end": period["period_end_date"] if period else None, "eps_estimate": period["eps_estimate"] if period else None, "eps_actual": period["eps_actual"] if period else None, } ) future_rows = [r for r in rows if r["announce_date"] > self.today] tickers_with_future = {r["ticker_id"] for r in future_rows} stats = { "calendar": cal_stats, "eps_history": hist_stats, "universe_size": len(universe), "symbols_with_calendar": len(calendar), "matched_events": matched, "unmatched_events": unmatched, "tracked_tickers_with_future_date": len(tickers_with_future), } return StagedEarnings( rows=rows, stats=stats, future_count=len(future_rows), max_announce_date=max((r["announce_date"] for r in rows), default=None), ) async def validate(self, db, staged: StagedEarnings) -> ValidationResult: messages: list[str] = [] # Fail-closed forward-calendar protection (promote deletes+reinserts it). if staged.future_count == 0: messages.append("no future-dated earnings rows staged") current_future = await self._current_future_count(db) if current_future > 0 and staged.future_count < current_future * MIN_FUTURE_RATIO: messages.append( f"forward calendar collapsed: staged {staged.future_count} future rows " f"< {MIN_FUTURE_RATIO:.0%} of current {current_future}" ) keys = [(r["ticker_id"], r["announce_date"]) for r in staged.rows] if len(keys) != len(set(keys)): messages.append("duplicate (ticker_id, announce_date) in staged set") summary = { **staged.stats, "staged_rows": len(staged.rows), "future_rows": staged.future_count, "current_future_rows": current_future, } return ValidationResult( ok=not messages, summary=summary, source_max_date=staged.max_announce_date, messages=messages, ) async def promote(self, db, staged: StagedEarnings, run_id: int) -> dict[str, int]: # Rescheduling: drop this source's future rows, then upsert the staged # set. Past rows (results) are never deleted; moved/cancelled future # dates simply don't reappear. deleted = ( await db.execute( delete(EarningsEvent).where( EarningsEvent.source == SOURCE, EarningsEvent.announce_date > self.today, ) ) ).rowcount or 0 now = _now() for r in staged.rows: stmt = insert_for_session(db, EarningsEvent).values( ticker_id=r["ticker_id"], announce_date=r["announce_date"], session=r["session"], period_end=r["period_end"], eps_estimate=r["eps_estimate"], eps_actual=r["eps_actual"], source=SOURCE, import_run_id=run_id, created_at=now, ) # Preserve a non-null prior EPS/period-end if a re-pairing comes back # null; prefer a known session over 'unknown'. stmt = stmt.on_conflict_do_update( index_elements=["ticker_id", "announce_date"], set_={ "session": case( (stmt.excluded.session != "unknown", stmt.excluded.session), else_=EarningsEvent.session, ), "period_end": func.coalesce( stmt.excluded.period_end, EarningsEvent.period_end ), "eps_estimate": func.coalesce( stmt.excluded.eps_estimate, EarningsEvent.eps_estimate ), "eps_actual": func.coalesce( stmt.excluded.eps_actual, EarningsEvent.eps_actual ), "source": stmt.excluded.source, "import_run_id": stmt.excluded.import_run_id, }, ) await db.execute(stmt) return {"deleted_future": int(deleted), "upserted": len(staged.rows)} # -- helpers ----------------------------------------------------------- async def _load_universe(self, db) -> dict[str, int]: rows = (await db.execute(select(Ticker.id, Ticker.symbol))).all() return { earnings_alignment.normalise_symbol(symbol): tid for tid, symbol in rows if symbol } async def _current_future_count(self, db) -> int: return ( await db.execute( select(func.count()) .select_from(EarningsEvent) .where( EarningsEvent.source == SOURCE, EarningsEvent.announce_date > self.today, ) ) ).scalar_one() def _require_columns(rows: list[dict[str, str]], required: set[str], table: str) -> None: """Upstream schema-change gate: a missing column stops the run (→ failed).""" if not rows: return present = set(rows[0].keys()) missing = required - present if missing: raise ValueError(f"{table}: upstream schema change, missing columns {sorted(missing)}") def _parse_calendar(raw: list[dict[str, str]], universe: dict[str, int]) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] for row in raw: symbol = earnings_alignment.normalise_symbol(row.get("act_symbol")) raw_date = str(row.get("date") or "")[:10] if symbol not in universe or not raw_date: continue announce_date = date.fromisoformat(raw_date) if announce_date < WINDOW_START: continue out.append( { "symbol": symbol, "announce_date": announce_date, "session": earnings_alignment.normalise_session(row.get("when")), } ) return out def _parse_history(raw: list[dict[str, str]], universe: dict[str, int]) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] for row in raw: symbol = earnings_alignment.normalise_symbol(row.get("act_symbol")) raw_date = str(row.get("period_end_date") or "")[:10] if symbol not in universe or not raw_date: continue out.append( { "symbol": symbol, "period_end_date": date.fromisoformat(raw_date), "eps_actual": earnings_alignment.safe_number(row.get("reported")), "eps_estimate": earnings_alignment.safe_number(row.get("estimate")), } ) return out