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

339 lines
12 KiB
Python

"""Cross-validation split generation for case study pipelines.
Reads the ``evaluation`` section from ``setup.yaml`` and generates
walk-forward date boundaries by delegating to ml4t-diagnostic's
``WalkForwardCV``. This is the single source of truth for CV splits
used by all case studies (Ch11+).
Usage:
from utils.cv_splits import generate_cv_splits, load_evaluation_config, make_walk_forward_config
# Date-boundary splits
splits = generate_cv_splits(dataset, case_study_id="etfs", label_buffer="21D")
for split in splits:
train_mask = (df[date_col] >= split["train_start"]) & (df[date_col] <= split["train_end"])
val_mask = (df[date_col] >= split["val_start"]) & (df[date_col] <= split["val_end"])
# WalkForwardConfig for library integration
config = make_walk_forward_config("etfs", label_horizon="21D")
Design decisions:
- Delegates fold generation to ml4t-diagnostic's WalkForwardCV
- Calendar-aware splitting (NYSE, CME, etc.) replaces broken ppd arithmetic
- Operates on unique dates (handles panel data correctly)
- Rolling training windows (respects train_size from config)
- Backward stepping from holdout boundary
- label_buffer is provided at call time (depends on label, not config)
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import TYPE_CHECKING, Any
import numpy as np
import pandas as pd
import polars as pl
import yaml
from utils.artifact_specs import resolve_market_semantics
from utils.paths import get_case_study_dir
if TYPE_CHECKING:
from ml4t.diagnostic.splitters.config import WalkForwardConfig
# ---------------------------------------------------------------------------
# Calendar name mapping: setup.yaml → pandas_market_calendars exchange names
# ---------------------------------------------------------------------------
_CALENDAR_MAP: dict[str, str | None] = {
"NYSE": "NYSE",
"CME": "CME_Equity",
"FX": "CME_FX",
"crypto": None, # 24/7 trading, no calendar
}
def _map_calendar_id(calendar: str | None) -> str | None:
"""Map setup.yaml calendar name to pandas_market_calendars exchange name.
Returns None for 24/7 markets (crypto) to disable calendar-aware splitting.
Unknown names are passed through unchanged (will error in the library if invalid).
"""
if calendar is None:
return None
return _CALENDAR_MAP.get(calendar, calendar)
def _normalize_duration(s: str) -> str:
"""Strip ISO 8601 prefix (P, PT) and normalize unit aliases.
Examples: P5Y → 5Y, P1Y → 1Y, PT8H → 8h, 21D → 21D (unchanged).
"""
s = re.sub(r"^P?T?", "", s)
s = re.sub(r"(\d+)H$", r"\1h", s)
s = re.sub(r"(\d+)T$", r"\1min", s)
return s
def _normalize_label_buffer(s: str) -> str:
"""Normalize label buffer for pd.Timedelta compatibility.
Strips ISO prefix, normalizes units, and converts month-based
durations to day equivalents since pd.Timedelta rejects 'M' as ambiguous.
"""
s = _normalize_duration(s)
m = re.match(r"^(\d+)M$", s)
if m:
return f"{int(m.group(1)) * 30}D"
return s
def load_evaluation_config(case_study_id: str) -> dict[str, Any]:
"""Read the evaluation section from setup.yaml.
Parameters
----------
case_study_id : str
Case study identifier (e.g., "etfs", "crypto_perps_funding").
Returns
-------
dict
Evaluation config with keys: n_splits, train_size, val_size,
holdout_start, holdout_end, calendar.
"""
import os
setup_path = get_case_study_dir(case_study_id) / "config" / "setup.yaml"
setup: dict[str, Any] = {}
if setup_path.exists():
with open(setup_path) as f:
setup = yaml.safe_load(f) or {}
if "evaluation" not in setup:
# Under ML4T_OUTPUT_DIR isolation, the redirected setup.yaml may
# be absent or lack hand-curated sections. Fall back to source.
test_output = os.environ.get("ML4T_OUTPUT_DIR")
if test_output:
from utils import CASE_STUDIES_DIR
source_path = CASE_STUDIES_DIR / case_study_id / "config" / "setup.yaml"
if source_path.exists():
with open(source_path) as f:
setup = yaml.safe_load(f) or {}
if "evaluation" not in setup:
raise KeyError(
f"No 'evaluation' section in {setup_path}. "
f"Expected keys: n_splits, train_size, val_size, holdout_start, holdout_end, calendar."
)
evaluation = dict(setup["evaluation"])
market_semantics = resolve_market_semantics(case_study_id, setup)
if market_semantics.get("calendar") and not evaluation.get("calendar"):
evaluation["calendar"] = market_semantics["calendar"]
return evaluation
def make_walk_forward_config(
case_study_id: str,
label_horizon: str = "0D",
date_col: str = "timestamp",
) -> WalkForwardConfig:
"""Create a WalkForwardConfig from a case study's setup.yaml.
Bridges the setup.yaml evaluation section to the ml4t-diagnostic
library's WalkForwardConfig, using its built-in aliases
(val_size→test_size, holdout_start→test_start, etc.).
Parameters
----------
case_study_id : str
Case study identifier (e.g., "etfs").
label_horizon : str, default "0D"
Label buffer as duration string (e.g., "21D" for fwd_ret_21d).
date_col : str, default "timestamp"
Timestamp column name for the dataset.
Returns
-------
WalkForwardConfig
Configured for the case study's walk-forward protocol.
"""
from ml4t.diagnostic.splitters import WalkForwardConfig
eval_config = load_evaluation_config(case_study_id)
calendar_id = _map_calendar_id(eval_config.get("calendar"))
# For D-unit buffers with a calendar, pass as int (trading days)
normalized_horizon: int | str = _normalize_label_buffer(label_horizon)
if calendar_id is not None and isinstance(normalized_horizon, str):
d_match = re.match(r"^(\d+)D$", normalized_horizon)
if d_match:
normalized_horizon = int(d_match.group(1))
return WalkForwardConfig(
n_splits=eval_config["n_splits"],
train_size=_normalize_duration(str(eval_config["train_size"])),
val_size=_normalize_duration(str(eval_config["val_size"])),
holdout_start=eval_config.get("holdout_start"),
holdout_end=eval_config.get("holdout_end"),
label_horizon=normalized_horizon,
calendar_id=calendar_id,
timestamp_col=date_col,
fold_direction="backward",
)
def make_wf_config(
case_study_id: str,
label_horizon: str = "0D",
date_col: str = "timestamp",
) -> WalkForwardConfig:
"""Backward-compatible alias for make_walk_forward_config."""
return make_walk_forward_config(
case_study_id=case_study_id,
label_horizon=label_horizon,
date_col=date_col,
)
def generate_cv_splits(
dataset: pl.DataFrame | pd.DataFrame,
case_study_id: str | None = None,
setup_path: Path | None = None,
label_buffer: str = "0D",
date_col: str = "timestamp",
*,
cv_config: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""Generate walk-forward date splits from evaluation config.
Delegates to ml4t-diagnostic's ``WalkForwardCV`` for calendar-aware
fold generation. Reads the ``evaluation`` section from ``setup.yaml``
(via ``case_study_id`` or ``setup_path``).
Parameters
----------
dataset : pl.DataFrame or pd.DataFrame
Dataset with a date/timestamp column. Only used to extract unique
timestamps -- the full panel rows are not needed.
case_study_id : str, optional
Case study identifier. Used to locate setup.yaml.
setup_path : Path, optional
Explicit path to setup.yaml. Takes precedence over case_study_id.
label_buffer : str, default "0D"
Gap between train_end and val_start sized to the label horizon.
Determined by the label being trained on (e.g., "21D" for fwd_ret_21d).
date_col : str, default "timestamp"
Name of the date/timestamp column.
cv_config : dict, optional
Pass a cv_config dict directly (e.g. from cv_config.json).
If provided, case_study_id and setup_path are ignored.
Returns
-------
list[dict]
List of split dicts with keys: ``fold``, ``train_start``,
``train_end``, ``val_start``, ``val_end``.
"""
from ml4t.diagnostic.splitters import WalkForwardCV
from ml4t.diagnostic.splitters.config import WalkForwardConfig as LibWalkForwardConfig
# Legacy path: pre-computed explicit splits
if cv_config is not None and "splits" in cv_config:
return cv_config["splits"]
# Normalize label buffer (strip ISO prefix, convert M → days)
label_buffer = _normalize_label_buffer(label_buffer)
# Load evaluation config
if cv_config is not None:
# Legacy cv_config dict
test_size_key = "val_size" if "val_size" in cv_config else "test_size"
holdout_start_key = "holdout_start" if "holdout_start" in cv_config else "test_start"
holdout_end_key = "holdout_end" if "holdout_end" in cv_config else "test_end"
eval_config = {
"n_splits": cv_config["n_splits"],
"train_size": str(cv_config["train_size"]),
"val_size": str(cv_config[test_size_key]),
"holdout_start": cv_config.get(holdout_start_key),
"holdout_end": cv_config.get(holdout_end_key),
"calendar": cv_config.get("calendar"),
}
elif setup_path is not None:
with open(setup_path) as f:
setup = yaml.safe_load(f)
eval_config = dict(setup["evaluation"])
elif case_study_id is not None:
eval_config = load_evaluation_config(case_study_id)
else:
raise ValueError("Provide either case_study_id, setup_path, or cv_config")
# Map calendar name to library exchange name
calendar_id = _map_calendar_id(eval_config.get("calendar"))
# For D-unit buffers with a calendar, pass label_horizon as int so the
# library interprets it as trading days (not calendar days). This fixes
# the under-buffering where "21D" → pd.Timedelta("21 days") → ~15 trading
# days instead of the intended 21 trading days.
label_horizon: int | str = label_buffer
if calendar_id is not None:
d_match = re.match(r"^(\d+)D$", label_buffer)
if d_match:
label_horizon = int(d_match.group(1))
# Build WalkForwardConfig (library Pydantic model)
config = LibWalkForwardConfig(
n_splits=eval_config["n_splits"],
train_size=_normalize_duration(str(eval_config["train_size"])),
val_size=_normalize_duration(str(eval_config["val_size"])),
holdout_start=eval_config.get("holdout_start"),
holdout_end=eval_config.get("holdout_end"),
label_horizon=label_horizon,
calendar_id=calendar_id,
fold_direction="backward",
)
# Extract sorted unique timestamps from the dataset
if isinstance(dataset, pl.DataFrame):
unique_ts = dataset.select(date_col).unique().sort(date_col).to_series().to_pandas()
else:
unique_ts = pd.Series(sorted(dataset[date_col].dropna().unique()))
if len(unique_ts) == 0:
raise ValueError("No timestamps found in dataset")
# Build a single-column DataFrame with DatetimeIndex for the splitter
ts_index = pd.DatetimeIndex(unique_ts)
input_tz_naive = ts_index.tz is None
if input_tz_naive:
ts_index = ts_index.tz_localize("UTC")
ts_df = pd.DataFrame(
{"_dummy": np.zeros(len(ts_index), dtype=np.int8)},
index=ts_index,
)
# Create WalkForwardCV with rolling window (expanding=False)
cv = WalkForwardCV(config=config)
cv.expanding = False
# Generate splits and extract date boundaries.
# Match tz-awareness to the input data so comparisons work.
def _ts(idx):
t = ts_index[idx]
return t.tz_localize(None) if input_tz_naive else t
splits = []
for fold_i, (train_idx, val_idx) in enumerate(cv.split(ts_df)):
splits.append(
{
"fold": fold_i,
"train_start": _ts(train_idx[0]),
"train_end": _ts(train_idx[-1]),
"val_start": _ts(val_idx[0]),
"val_end": _ts(val_idx[-1]),
}
)
return splits