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

163 lines
5.6 KiB
Python

"""Tests for case_studies/utils/cv_window.py P2.6 fixes (#2471).
Covers:
1. ``_fold_splits`` raises ``ValueError`` with the actionable
"Add buffer to labels.buffer..." hint when ``label_buffer`` is
missing from setup.yaml — restores the loud-fail contract that
matches ``utils.modeling.load_modeling_dataset``.
2. ``_fold_splits`` detects the time column from the parquet schema
(``timestamp`` else ``date``), so legacy parquets that haven't
migrated to the canonical ``timestamp`` name don't crash with
``ColumnNotFoundError``.
3. ``_fold_splits`` returns ``None`` when the label parquet doesn't
exist (unchanged contract).
"""
from __future__ import annotations
from datetime import date
from pathlib import Path
import polars as pl
import pytest
import yaml
@pytest.fixture
def isolated_case_study(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Redirect get_case_study_dir to tmp_path via ML4T_OUTPUT_DIR.
Also clears the _fold_splits / _load_setup_yaml / _holdout_window
lru caches so tests don't leak case-study state across runs.
"""
monkeypatch.setenv("ML4T_OUTPUT_DIR", str(tmp_path))
from case_studies.utils import cv_window
cv_window._fold_splits.cache_clear()
cv_window._load_setup_yaml.cache_clear()
cv_window._holdout_window.cache_clear()
yield tmp_path
cv_window._fold_splits.cache_clear()
cv_window._load_setup_yaml.cache_clear()
cv_window._holdout_window.cache_clear()
def _seed_setup_yaml(cs_dir: Path, *, with_buffer: bool, label: str) -> None:
cs_dir.mkdir(parents=True, exist_ok=True)
cfg = cs_dir / "config"
cfg.mkdir(exist_ok=True)
setup: dict = {
"strategy_id": cs_dir.name,
"labels": {"primary": label},
"evaluation": {
"n_splits": 2,
"train_size": "1Y",
"val_size": "6M",
"holdout_start": "2023-01-01",
"holdout_end": "2023-12-31",
"calendar": "NYSE",
"periods_per_year": 252,
},
}
if with_buffer:
setup["labels"]["buffer"] = "21D"
(cfg / "setup.yaml").write_text(yaml.safe_dump(setup))
def _seed_label_parquet(cs_dir: Path, *, label: str, date_col: str) -> None:
"""Write a minimal label parquet with the given time column name."""
labels_dir = cs_dir / "labels"
labels_dir.mkdir(parents=True, exist_ok=True)
dates = pl.date_range(start=date(2020, 1, 1), end=date(2023, 12, 31), interval="1d", eager=True)
df = pl.DataFrame(
{
date_col: dates,
"symbol": ["AAA"] * len(dates),
label: [0.01] * len(dates),
}
)
df.write_parquet(labels_dir / f"{label}.parquet")
def test_missing_label_buffer_raises_with_actionable_hint(
isolated_case_study: Path,
) -> None:
"""Setup.yaml without labels.buffer must raise loudly."""
from case_studies.utils.cv_window import _fold_splits
cs = "test_cs_missing_buffer"
cs_dir = isolated_case_study / cs
_seed_setup_yaml(cs_dir, with_buffer=False, label="fwd_ret_21d")
_seed_label_parquet(cs_dir, label="fwd_ret_21d", date_col="timestamp")
with pytest.raises(ValueError, match=r"No explicit label buffer found for 'fwd_ret_21d'"):
_fold_splits(cs, "fwd_ret_21d")
def test_missing_label_parquet_returns_none(isolated_case_study: Path) -> None:
"""No parquet means 'no folds derivable' — still a None return."""
from case_studies.utils.cv_window import _fold_splits
cs = "test_cs_no_parquet"
cs_dir = isolated_case_study / cs
_seed_setup_yaml(cs_dir, with_buffer=True, label="fwd_ret_21d")
# NB: no parquet written
assert _fold_splits(cs, "fwd_ret_21d") is None
def test_schema_detection_picks_timestamp_column(isolated_case_study: Path) -> None:
"""Canonical-schema parquet with 'timestamp' column resolves folds."""
from case_studies.utils.cv_window import _fold_splits
cs = "test_cs_ts"
cs_dir = isolated_case_study / cs
_seed_setup_yaml(cs_dir, with_buffer=True, label="fwd_ret_21d")
_seed_label_parquet(cs_dir, label="fwd_ret_21d", date_col="timestamp")
splits = _fold_splits(cs, "fwd_ret_21d")
assert splits is not None
assert len(splits) >= 1
fold_id, val_start, val_end = splits[0]
assert fold_id == 0
assert isinstance(val_start, date) and isinstance(val_end, date)
assert val_start <= val_end
def test_schema_detection_falls_back_to_date_column(
isolated_case_study: Path,
) -> None:
"""Legacy 'date'-column parquet still works — no ColumnNotFoundError."""
from case_studies.utils.cv_window import _fold_splits
cs = "test_cs_date"
cs_dir = isolated_case_study / cs
_seed_setup_yaml(cs_dir, with_buffer=True, label="fwd_ret_21d")
_seed_label_parquet(cs_dir, label="fwd_ret_21d", date_col="date")
splits = _fold_splits(cs, "fwd_ret_21d")
assert splits is not None
assert len(splits) >= 1
def test_schema_without_timestamp_or_date_raises(
isolated_case_study: Path,
) -> None:
"""A parquet with neither 'timestamp' nor 'date' must raise actionably."""
from case_studies.utils.cv_window import _fold_splits
cs = "test_cs_no_time_col"
cs_dir = isolated_case_study / cs
_seed_setup_yaml(cs_dir, with_buffer=True, label="fwd_ret_21d")
# Write a parquet with neither column.
labels_dir = cs_dir / "labels"
labels_dir.mkdir(parents=True, exist_ok=True)
pl.DataFrame({"symbol": ["AAA"], "fwd_ret_21d": [0.01]}).write_parquet(
labels_dir / "fwd_ret_21d.parquet"
)
with pytest.raises(ValueError, match=r"neither 'timestamp' nor 'date'"):
_fold_splits(cs, "fwd_ret_21d")