"""Canonical (cs, label, split) windows for backtest slicing. Single source of truth for the date range a backtest's daily-returns parquet should cover. Validation window = union of CV fold val_starts/val_ends → (min start, max end). Holdout window = setup.yaml.evaluation.{holdout_start, holdout_end}. Same window for every strategy on the same (cs, label, split). Idempotent — re-running ``run_backtest`` on a sliced parquet is a no-op. """ from __future__ import annotations import sqlite3 from datetime import date, datetime from functools import lru_cache from pathlib import Path from typing import Literal import yaml from utils.paths import get_case_study_dir Split = Literal["validation", "holdout"] def _to_date(x) -> date: if isinstance(x, date) and not isinstance(x, datetime): return x if isinstance(x, datetime): return x.date() return datetime.fromisoformat(str(x)[:19]).date() @lru_cache(maxsize=32) def _load_setup_yaml(case_study: str) -> dict | None: """Cached parse of ``case_studies//config/setup.yaml``. Called from both ``_fold_splits`` and ``_holdout_window`` (and transitively from ``generate_cv_splits → load_evaluation_config``); the file is small but the parse repeated O(K_labels × N_runs) times is wasted work. Returns ``None`` when the file is absent. """ setup_path = get_case_study_dir(case_study) / "config" / "setup.yaml" if not setup_path.exists(): return None return yaml.safe_load(setup_path.read_text()) @lru_cache(maxsize=128) def _fold_splits(case_study: str, label: str) -> tuple[tuple[int, date, date], ...] | None: """All CV folds as ((fold_id, val_start, val_end), ...) — same source canonical_window uses. Returns tuple-of-tuples (immutable, hashable) for lru_cache friendliness. ``None`` when the label artifact itself is missing (no folds derivable). Misconfiguration — missing ``label_buffer`` for a label that *does* have a parquet — raises ``ValueError`` with the same actionable hint as :func:`utils.modeling.load_modeling_dataset` (loud-fail contract: config drift must surface as an error, not silently degrade to a predictions-min/max fallback downstream). Reads only the label parquet's time column to derive folds — ``generate_cv_splits`` is calendar-aware and uses only unique timestamps (see its docstring). Schema is introspected to pick ``timestamp`` else ``date`` (matching ``load_us_equities`` / ``load_sp500_daily_bars``); the full feature/label/temporal join is not needed. """ import logging import polars as pl from utils.artifact_specs import load_label_spec, resolve_label_buffer, resolve_storage_path from utils.cv_splits import generate_cv_splits logger = logging.getLogger(__name__) try: label_spec = load_label_spec(case_study, label) label_path = resolve_storage_path(case_study, label_spec, f"labels/{label}.parquet") except (KeyError, FileNotFoundError) as e: logger.debug("_fold_splits(%s, %s): no folds (%s)", case_study, label, e) return None if not label_path.exists(): logger.debug( "_fold_splits(%s, %s): label parquet missing at %s", case_study, label, label_path, ) return None setup = _load_setup_yaml(case_study) label_buffer = resolve_label_buffer(case_study, label, setup) if not label_buffer: raise ValueError( f"No explicit label buffer found for '{label}' in " f"case_studies/{case_study}/config/setup.yaml. " f"Add buffer to labels.buffer (primary) or labels.variant_buffers (variants)." ) schema_names = pl.scan_parquet(label_path).collect_schema().names() if "timestamp" in schema_names: date_col = "timestamp" elif "date" in schema_names: date_col = "date" else: raise ValueError( f"Label parquet for '{label}' in case_study '{case_study}' " f"({label_path}) has neither 'timestamp' nor 'date' column. " f"Found: {schema_names}. Canonical schema requires 'timestamp'." ) ts_df = pl.scan_parquet(label_path).select(date_col).unique().sort(date_col).collect() splits = generate_cv_splits( ts_df, case_study_id=case_study, label_buffer=label_buffer, date_col=date_col, ) out = tuple((i, _to_date(s["val_start"]), _to_date(s["val_end"])) for i, s in enumerate(splits)) return out if out else None def _validation_window_for_label(case_study: str, label: str) -> tuple[date, date] | None: """Validation window from CV folds: min(val_start) → max(val_end).""" splits = _fold_splits(case_study, label) if splits is None: return None starts = [s[1] for s in splits] ends = [s[2] for s in splits] return min(starts), max(ends) def fold_boundaries(case_study: str, label: str) -> list[dict] | None: """Public accessor for fold boundaries: ``[{fold, val_start, val_end}, ...]``. Use this instead of calling ``generate_cv_splits(daily_returns, ...)`` when daily_returns is val-only — that path fails for case studies whose CV config requires a train_size larger than the val window (e.g., ``train_size=10Y`` on an 8-year validation period). Same source as the canonical_window helper, so fold IDs are stable. """ splits = _fold_splits(case_study, label) if splits is None: return None return [{"fold": fold, "val_start": vs, "val_end": ve} for fold, vs, ve in splits] @lru_cache(maxsize=32) def _holdout_window(case_study: str) -> tuple[date, date] | None: setup = _load_setup_yaml(case_study) if setup is None: return None e = setup.get("evaluation", {}) hs, he = e.get("holdout_start"), e.get("holdout_end") if hs is None or he is None: return None return _to_date(hs), _to_date(he) def canonical_window( case_study: str, label: str, *, split: Split = "validation", ) -> tuple[date, date] | None: """Per-(cs, label, split) date range for the daily_returns parquet. Returns None if not derivable (no CV folds for the label, no holdout configured, etc.). Callers must handle None as "skip the slice". """ if split == "holdout": return _holdout_window(case_study) return _validation_window_for_label(case_study, label) def lookup_split(case_study: str, prediction_hash: str) -> Split | None: """Resolve the split a prediction_hash belongs to from the registry. Returns None when: - the registry DB doesn't exist - the prediction_hash isn't found - the stored split value isn't one of {"validation", "holdout"} (NULL, empty string, or schema-drift values like "oos") — caller must decide how to handle, never assume "validation". """ db = get_case_study_dir(case_study) / "run_log" / "registry.db" if not db.exists(): return None con = sqlite3.connect(str(db)) try: row = con.execute( "SELECT split FROM prediction_sets WHERE prediction_hash = ?", (prediction_hash,), ).fetchone() finally: con.close() if row is None: return None if row[0] == "holdout": return "holdout" if row[0] == "validation": return "validation" return None