Addresses the A2 review:
1. Every dolt subprocess is now bounded by a hard timeout
(dolt_command_timeout_seconds, default 600s); on expiry the process is killed
and DoltError raised — a hung pull/sql can no longer pin the import
connection and advisory lock indefinitely. Tested (timeout + non-zero exit).
2. Initial-load validate is stronger: besides zero-future, an initial load now
requires a real forward horizon (>= 21d, under the ~35d observed on the
clone) AND universe coverage >= 50% (a broken symbol join can't seed a hollow
calendar). Subsequent runs keep the 50% collapse gate.
3. Revision uses DOLT_HASHOF('HEAD') — formally HEAD, not dolt_log-by-timestamp.
4. Free-disk floor raised 2 GB -> 5 GB (safe headroom over the ~1.7 GB clone).
Full suite 702 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
358 lines
14 KiB
Python
358 lines
14 KiB
Python
"""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
|
|
# Initial-load gates (when nothing is loaded yet — the ratio gate has no baseline).
|
|
# The source publishes a forward calendar; require a real horizon, not one stray
|
|
# future row. 21 days is a conservative floor under the ~35d horizon observed on
|
|
# the live clone.
|
|
MIN_FORWARD_HORIZON_DAYS = 21
|
|
# ...and require the symbol join to reach most of the tracked universe, so a
|
|
# broken/normalization-dropped join can't seed a hollow calendar.
|
|
MIN_INITIAL_COVERAGE = 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:
|
|
timeout = settings.dolt_command_timeout_seconds
|
|
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, timeout=timeout)
|
|
return await self._dolt.current_commit(
|
|
self.repo_dir, binary=self.binary, timeout=timeout
|
|
)
|
|
|
|
async def stage(self, db) -> StagedEarnings:
|
|
universe = await self._load_universe(db) # {normalised symbol: ticker_id}
|
|
|
|
timeout = settings.dolt_command_timeout_seconds
|
|
cal_raw = await self._dolt.query_csv(
|
|
self.repo_dir, _CAL_SQL, binary=self.binary, timeout=timeout
|
|
)
|
|
hist_raw = await self._dolt.query_csv(
|
|
self.repo_dir, _HIST_SQL, binary=self.binary, timeout=timeout
|
|
)
|
|
_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:
|
|
# Promote deletes+reinserts the forward calendar, so this gate is
|
|
# fail-closed. The forward calendar is the project's acceptance gate.
|
|
messages: list[str] = []
|
|
current_future = await self._current_future_count(db)
|
|
universe_size = int(staged.stats.get("universe_size", 0) or 0)
|
|
coverage = (
|
|
staged.stats.get("symbols_with_calendar", 0) / universe_size
|
|
if universe_size
|
|
else 0.0
|
|
)
|
|
horizon_days = (
|
|
(staged.max_announce_date - self.today).days if staged.max_announce_date else 0
|
|
)
|
|
|
|
if staged.future_count == 0:
|
|
messages.append("no future-dated earnings rows staged")
|
|
elif current_future == 0:
|
|
# Initial load: no baseline for the ratio gate, so require a real
|
|
# forward horizon and broad universe coverage instead of one stray row.
|
|
if horizon_days < MIN_FORWARD_HORIZON_DAYS:
|
|
messages.append(
|
|
f"forward horizon only {horizon_days}d < {MIN_FORWARD_HORIZON_DAYS}d "
|
|
"on initial load"
|
|
)
|
|
if coverage < MIN_INITIAL_COVERAGE:
|
|
messages.append(
|
|
f"initial universe coverage {coverage:.0%} "
|
|
f"< {MIN_INITIAL_COVERAGE:.0%} — symbol join likely broken"
|
|
)
|
|
elif 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,
|
|
"forward_horizon_days": horizon_days,
|
|
"universe_coverage": round(coverage, 3),
|
|
}
|
|
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
|