Files
2026-07-13 13:26:28 +08:00

381 lines
12 KiB
Python

"""Registry completeness checks and skip-if-exists logic.
Provides a single entry point for "should I train this config?" decisions
across all model families (linear, gbm, tabular_dl, deep_learning,
latent_factors, causal_dml) and for backtests.
Contract
--------
Every training notebook should guard each config:
spec = build_training_spec(...)
status = training_run_status(CASE_STUDY_ID, spec)
if status.complete and not FORCE_RETRAIN:
print(f" {cfg['config_name']}: SKIP — {status.summary()}")
continue
if status.partial:
print(f" {cfg['config_name']}: RETRAIN — {status.summary()}")
# ... train and register
Every backtest sweep should guard each variant:
strategy_spec = build_backtest_spec(...)
status = backtest_run_status(CASE_STUDY_ID, pred_hash, strategy_spec)
if status.complete and not FORCE_REBACKTEST:
print(f" {variant_name}: SKIP — backtest already complete")
continue
# ... run backtest
Rationale
---------
Large sweeps (GBM on 9.2M-row us_equities_panel, nasdaq100 microstructure,
DL families) can take hours. Re-running from scratch after a correctness
fix, partial interruption, or added configs wastes compute. Re-running
training where the training_hash already has complete artifacts is pure
waste — the hash IS the identity. If the hash exists and has all expected
artifacts, the result is reproducible and can be reused.
The only legitimate reasons to retrain:
1. The fix or config change produces a NEW hash (handled automatically).
2. The existing artifacts are corrupt or partially written.
3. FORCE_RETRAIN=True (explicit opt-in for debugging).
Partial state handling
----------------------
If some artifacts exist but not all (e.g., training_runs row but no
predictions.parquet), report the partial state and retrain. We NEVER
silently reuse a partial state because the result would be misleading
(the ic_mean might exist while the predictions are gone).
"""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from .specs import backtest_hash_from_parts, canonical_json, training_hash_from_spec
from .store import (
_backtest_dir,
_case_dir,
_open_registry,
_prediction_dir,
)
# ---------------------------------------------------------------------------
# Dataclasses
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class TrainingRunStatus:
"""Completeness status of a training run in the registry.
Fields
------
training_hash : str
Canonical identity hash from the spec.
exists : bool
True if the training_runs row exists.
has_predictions : bool
True if at least one prediction_sets row exists.
has_predictions_file : bool
True if at least one predictions.parquet file exists on disk.
has_metrics : bool
True if the prediction has an ic_mean value.
complete : bool
True if all required artifacts are present.
partial : bool
True if the run exists but some artifacts are missing.
missing : tuple[str, ...]
Names of missing artifacts.
"""
training_hash: str
exists: bool
has_predictions: bool
has_predictions_file: bool
has_metrics: bool
missing: tuple[str, ...] = ()
@property
def complete(self) -> bool:
return not self.missing and self.exists
@property
def partial(self) -> bool:
return self.exists and bool(self.missing)
def summary(self) -> str:
if not self.exists:
return f"no training_run for hash {self.training_hash[:12]}"
if self.complete:
return f"complete (hash={self.training_hash[:12]})"
return f"partial (hash={self.training_hash[:12]}, missing: {', '.join(self.missing)})"
@dataclass(frozen=True)
class BacktestRunStatus:
"""Completeness status of a backtest run in the registry."""
backtest_hash: str
exists: bool
has_returns: bool
has_metrics: bool
missing: tuple[str, ...] = ()
@property
def complete(self) -> bool:
return not self.missing and self.exists
@property
def partial(self) -> bool:
return self.exists and bool(self.missing)
def summary(self) -> str:
if not self.exists:
return f"no backtest_run for hash {self.backtest_hash[:12]}"
if self.complete:
return f"complete (hash={self.backtest_hash[:12]})"
return f"partial (hash={self.backtest_hash[:12]}, missing: {', '.join(self.missing)})"
# ---------------------------------------------------------------------------
# Training run completeness
# ---------------------------------------------------------------------------
def training_run_status(
case_study: str,
spec: dict,
*,
require_metrics: bool = True,
require_predictions_file: bool = True,
case_dir: Path | None = None,
) -> TrainingRunStatus:
"""Inspect the registry for a training run matching the given spec.
Parameters
----------
case_study : str
Case study id.
spec : dict
Complete training spec (same structure build_training_spec produces).
require_metrics : bool
Whether ic_mean must be non-NULL for the run to count as complete.
Default True. Causal DML runs are tracked in `causal_runs`, not
through this path.
require_predictions_file : bool
Whether predictions.parquet must exist on disk. Default True.
case_dir : Path, optional
Override case study directory.
Returns
-------
TrainingRunStatus
"""
if case_dir is None:
case_dir = _case_dir(case_study)
t_hash = training_hash_from_spec(spec)
db = _open_registry(case_dir)
try:
row = db.execute(
"SELECT training_hash FROM training_runs WHERE training_hash = ?",
(t_hash,),
).fetchone()
exists = row is not None
if not exists:
return TrainingRunStatus(
training_hash=t_hash,
exists=False,
has_predictions=False,
has_predictions_file=False,
has_metrics=False,
missing=("training_run",),
)
# Prediction sets
pred_hashes = [
r[0]
for r in db.execute(
"SELECT prediction_hash FROM prediction_sets WHERE training_hash = ?",
(t_hash,),
).fetchall()
]
has_predictions = len(pred_hashes) > 0
# Metrics on the prediction(s)
has_metrics = False
if has_predictions:
# Get any prediction with non-null ic_mean
q = (
f"SELECT prediction_hash FROM prediction_metrics "
f"WHERE prediction_hash IN ({','.join('?' * len(pred_hashes))}) "
f"AND ic_mean IS NOT NULL"
)
m_rows = db.execute(q, tuple(pred_hashes)).fetchall()
has_metrics = len(m_rows) > 0
finally:
db.close()
# Check predictions.parquet files on disk
has_predictions_file = False
if has_predictions:
for ph in pred_hashes:
f = _prediction_dir(case_dir, ph) / "predictions.parquet"
if f.exists():
has_predictions_file = True
break
missing = []
if not has_predictions:
missing.append("prediction_sets")
if require_predictions_file and not has_predictions_file:
missing.append("predictions.parquet")
if require_metrics and not has_metrics:
missing.append("ic_mean")
return TrainingRunStatus(
training_hash=t_hash,
exists=exists,
has_predictions=has_predictions,
has_predictions_file=has_predictions_file,
has_metrics=has_metrics,
missing=tuple(missing),
)
def skip_training_if_complete(
case_study: str,
spec: dict,
*,
force_retrain: bool = False,
verbose: bool = True,
**kwargs,
) -> TrainingRunStatus:
"""Convenience wrapper for the "should I train?" decision.
Returns the status. Caller should check ``status.complete`` and
``force_retrain`` to decide whether to skip.
When ``verbose=True``, prints a one-line status for partial/complete runs
so interactive runs get visible feedback.
Example
-------
status = skip_training_if_complete(CASE_STUDY_ID, spec,
force_retrain=FORCE_RETRAIN)
if status.complete and not FORCE_RETRAIN:
print(f" {cfg_name}: SKIP ({status.summary()})")
continue
"""
status = training_run_status(case_study, spec, **kwargs)
if verbose:
if status.complete and not force_retrain:
return status # caller prints
if status.partial:
print(f" WARNING: partial run detected, will retrain: {status.summary()}")
return status
# ---------------------------------------------------------------------------
# Backtest run completeness
# ---------------------------------------------------------------------------
def backtest_run_status(
case_study: str,
prediction_hash: str,
strategy_spec: dict,
*,
require_metrics: bool = True,
require_returns_file: bool = True,
case_dir: Path | None = None,
) -> BacktestRunStatus:
"""Inspect the registry for a backtest run matching prediction_hash + strategy_spec."""
if case_dir is None:
case_dir = _case_dir(case_study)
b_hash = backtest_hash_from_parts(prediction_hash, strategy_spec)
db = _open_registry(case_dir)
try:
row = db.execute(
"SELECT backtest_hash FROM backtest_runs WHERE backtest_hash = ?",
(b_hash,),
).fetchone()
exists = row is not None
if not exists:
return BacktestRunStatus(
backtest_hash=b_hash,
exists=False,
has_returns=False,
has_metrics=False,
missing=("backtest_run",),
)
has_metrics = False
if require_metrics:
m_row = db.execute(
"SELECT sharpe FROM backtest_metrics WHERE backtest_hash = ? AND sharpe IS NOT NULL",
(b_hash,),
).fetchone()
has_metrics = m_row is not None
finally:
db.close()
# Check returns.parquet on disk
has_returns = (_backtest_dir(case_dir, b_hash) / "daily_returns.parquet").exists()
missing = []
if require_returns_file and not has_returns:
missing.append("daily_returns.parquet")
if require_metrics and not has_metrics:
missing.append("sharpe")
return BacktestRunStatus(
backtest_hash=b_hash,
exists=exists,
has_returns=has_returns,
has_metrics=has_metrics,
missing=tuple(missing),
)
def skip_backtest_if_complete(
case_study: str,
prediction_hash: str,
strategy_spec: dict,
*,
force_rebacktest: bool = False,
verbose: bool = True,
**kwargs,
) -> BacktestRunStatus:
"""Convenience wrapper for the "should I backtest?" decision.
Example
-------
status = skip_backtest_if_complete(CASE_STUDY_ID, pred_hash, spec,
force_rebacktest=FORCE_REBACKTEST)
if status.complete and not FORCE_REBACKTEST:
print(f" {variant_name}: SKIP ({status.summary()})")
continue
"""
status = backtest_run_status(case_study, prediction_hash, strategy_spec, **kwargs)
if verbose:
if status.partial:
print(f" WARNING: partial backtest detected, will re-run: {status.summary()}")
return status
__all__ = [
"TrainingRunStatus",
"BacktestRunStatus",
"training_run_status",
"skip_training_if_complete",
"backtest_run_status",
"skip_backtest_if_complete",
]