chore: import upstream snapshot with attribution
This commit is contained in:
+237
@@ -0,0 +1,237 @@
|
||||
# Notebook Test Suite
|
||||
|
||||
Every notebook in this repository is tested via [Papermill](https://papermill.readthedocs.io/) parameter injection. The same code path always runs — only the data scale differs between production and test.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Run all environments (ml4t, gpu, py312, benchmark, neo4j)
|
||||
./scripts/run_all_tests.sh
|
||||
|
||||
# Run one environment
|
||||
./scripts/run_all_tests.sh ml4t
|
||||
|
||||
# Rerun everything (ignore already-passed)
|
||||
./scripts/run_all_tests.sh --force
|
||||
|
||||
# Run a specific notebook via pytest
|
||||
docker compose run --rm ml4t pytest tests/test_chapter_notebooks.py -v -k "01_timegan"
|
||||
|
||||
# Run locally (with uv)
|
||||
uv run pytest tests/test_chapter_notebooks.py -v -k "11_ml_pipeline"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
### Papermill Parameter Injection
|
||||
|
||||
Every notebook has a `# %% tags=["parameters"]` cell with **production defaults** — the values readers see in the book:
|
||||
|
||||
```python
|
||||
# %% tags=["parameters"]
|
||||
MAX_SYMBOLS = 0 # 0 = all symbols (production)
|
||||
N_EPOCHS = 500
|
||||
START_DATE = "2006-01-01"
|
||||
```
|
||||
|
||||
During testing, Papermill creates an **injected cell** after the tagged cell that overrides selected values:
|
||||
|
||||
```python
|
||||
# Injected by Papermill
|
||||
MAX_SYMBOLS = 15 # Reduced for fast execution
|
||||
N_EPOCHS = 2
|
||||
```
|
||||
|
||||
The notebook code sees only the final (overridden) values. **There are no `if TEST:` branches** — the same code path always runs, just with less data or fewer iterations.
|
||||
|
||||
### Output Isolation
|
||||
|
||||
Tests set `ML4T_OUTPUT_DIR` to a temp directory. All notebook writes (`get_output_dir()`, `get_case_study_dir()`) redirect there, so production artifacts (trained models, backtest results) are never overwritten.
|
||||
|
||||
### Seeded Fixtures
|
||||
|
||||
Downstream notebooks (Ch11+) need upstream pipeline outputs (features, labels, model predictions). Rather than running the full pipeline during every test, `tests/fixtures/seed_results.py` creates minimal but schema-correct fixtures:
|
||||
|
||||
- Registry databases (`run_log/registry.db`) with realistic training_runs, prediction_sets, and backtest entries
|
||||
- Feature parquets (350 rows × 15 symbols)
|
||||
- Label parquets (5 label variants per case study)
|
||||
- Prediction parquets (200 rows per model)
|
||||
|
||||
Fixtures are deterministic and only written if the file doesn't already exist — real upstream results take priority.
|
||||
|
||||
---
|
||||
|
||||
## Test Data
|
||||
|
||||
### Architecture
|
||||
|
||||
Tests require two things: **raw market data** (what loaders read) and **pipeline intermediates** (what downstream notebooks consume). Both live in a private repo that CI pulls automatically.
|
||||
|
||||
```
|
||||
~/ml4t/test-data/ # Local clone of ml4t/third-edition-test-data (~2 GB private GitHub repo)
|
||||
├── data/ # Pre-subsampled raw data (553 MB)
|
||||
│ ├── etfs/ # 15 most liquid ETFs (full date range)
|
||||
│ ├── crypto/ # 5 largest perps
|
||||
│ ├── futures/ # 8 CME products
|
||||
│ ├── fx/ # 8 major pairs
|
||||
│ ├── equities/ # 50 US stocks, 3 NASDAQ-100 (minute bars)
|
||||
│ │ ├── microstructure/ # Synthetic ITCH/LOB/MBO for Ch03
|
||||
│ │ └── firm_characteristics/ # 200 most-observed per month
|
||||
│ ├── factors/ # Fama-French + AQR
|
||||
│ └── manifest.json # Symbol counts per dataset
|
||||
│
|
||||
└── intermediates/ # Pre-computed pipeline outputs (301 MB)
|
||||
├── etfs/ # registry.db, features, labels, predictions
|
||||
├── crypto_perps_funding/
|
||||
├── ... # All 9 case studies
|
||||
└── _metadata.json # Generation timestamp and parameters
|
||||
```
|
||||
|
||||
**Key design decisions:**
|
||||
|
||||
- **Same schema, fewer symbols**: Loaders work without code changes. Full date ranges are preserved so cross-validation folds remain valid.
|
||||
- **Pre-computed intermediates**: Running all 9 case study pipelines from scratch takes ~25 minutes. Pre-computing once and shipping the results lets CI focus on testing individual notebooks.
|
||||
- **Fixtures as safety net**: If intermediates are missing or a new notebook needs data that wasn't pre-computed, `seed_results.py` generates minimal placeholders at test time.
|
||||
|
||||
### Running Tests Locally
|
||||
|
||||
**With the test-data repo** (full test coverage):
|
||||
|
||||
```bash
|
||||
# Clone test data once
|
||||
git clone git@github.com:ml4t/third-edition-test-data.git ~/ml4t/test-data
|
||||
|
||||
# Point tests at it
|
||||
export ML4T_DATA_PATH=~/ml4t/test-data/data
|
||||
export ML4T_OUTPUT_DIR=/tmp/ml4t-test-output
|
||||
|
||||
# Seed intermediates
|
||||
mkdir -p $ML4T_OUTPUT_DIR
|
||||
cp -r ~/ml4t/test-data/intermediates/* $ML4T_OUTPUT_DIR/
|
||||
|
||||
# Run tests
|
||||
uv run pytest tests/test_chapter_notebooks.py -v -k "11_ml_pipeline"
|
||||
```
|
||||
|
||||
**Without the test-data repo** (limited coverage):
|
||||
|
||||
```bash
|
||||
# Point at your production data
|
||||
export ML4T_DATA_PATH=/path/to/your/data
|
||||
export ML4T_OUTPUT_DIR=/tmp/ml4t-test-output
|
||||
|
||||
# Fixtures will be auto-generated for missing intermediates
|
||||
uv run pytest tests/test_chapter_notebooks.py -v -k "01_ols_inference"
|
||||
```
|
||||
|
||||
### Regenerating Test Data
|
||||
|
||||
If the data schema or pipeline logic changes, regenerate the test data:
|
||||
|
||||
```bash
|
||||
# 1. Subsample raw data
|
||||
uv run python tests/create_test_data.py --output ~/ml4t/test-data
|
||||
|
||||
# 2. Generate pipeline intermediates (runs all 9 case studies, ~25 min)
|
||||
ML4T_DATA_PATH=~/ml4t/test-data/data \
|
||||
ML4T_OUTPUT_DIR=~/ml4t/test-data/intermediates \
|
||||
uv run python tests/_internal/generate_intermediates.py
|
||||
|
||||
# 3. Commit and push to test-data repo
|
||||
cd ~/ml4t/test-data && git add -A && git commit -m "regenerate test data"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environments
|
||||
|
||||
Each notebook is assigned to exactly one Docker environment via `docker_env` in `overrides.yaml`.
|
||||
|
||||
| Environment | Docker Service | Notebooks | What It Provides |
|
||||
|-------------|---------------|-----------|-----------------|
|
||||
| `ml4t` | `ml4t` | ~410 | CPU, all Python packages |
|
||||
| `gpu` | `ml4t-gpu` | ~31 | NVIDIA GPU (PyTorch CUDA) |
|
||||
| `py312` | `py312` | ~10 | gensim, signatory, esig, pfhedge, tfcausalimpact (Python 3.12) |
|
||||
| `benchmark` | `benchmark` + database services | 2 | TimescaleDB, ClickHouse, QuestDB, InfluxDB |
|
||||
| `neo4j` | `ml4t` + Neo4j service | 7 | Neo4j graph database |
|
||||
|
||||
Notebooks default to `ml4t` unless tagged otherwise. Multi-environment tags (e.g., `docker_env: ml4t+neo4j+gpu`) require all listed services.
|
||||
|
||||
The test runner (`scripts/run_all_tests.sh`) iterates over environments, running only notebooks tagged for each one.
|
||||
|
||||
---
|
||||
|
||||
## Override Format
|
||||
|
||||
Per-notebook configuration lives in `tests/overrides.yaml`:
|
||||
|
||||
```yaml
|
||||
05_synthetic_data/01_timegan:
|
||||
docker_env: gpu # Runs only in GPU environment
|
||||
timeout: 600 # Max seconds before test is killed
|
||||
parameters: # Papermill parameter overrides
|
||||
TRAIN_STEPS: 100
|
||||
BATCH_SIZE: 32
|
||||
|
||||
case_studies/etfs/07_gbm:
|
||||
timeout: 300
|
||||
parameters:
|
||||
MAX_SYMBOLS: 15
|
||||
START_DATE: "2020-01-01"
|
||||
|
||||
26_mlops_governance/05b_feast_live:
|
||||
skip: true # Never runs
|
||||
skip_reason: "Requires Feast feature server"
|
||||
```
|
||||
|
||||
**Override fields:**
|
||||
|
||||
| Field | Default | Purpose |
|
||||
|-------|---------|---------|
|
||||
| `timeout` | 300 | Max seconds per notebook |
|
||||
| `docker_env` | `ml4t` | Which Docker environment to use |
|
||||
| `skip` | false | Skip this notebook entirely |
|
||||
| `skip_reason` | — | Reason displayed in test output |
|
||||
| `parameters` | {} | Papermill parameter overrides |
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `test_chapter_notebooks.py` | Parametrized tests for Ch01-Ch27 teaching notebooks |
|
||||
| `test_case_studies.py` | Parametrized tests for all 9 case study pipelines |
|
||||
| `test_backtest_schedule.py` | Backtest-specific integration tests |
|
||||
| `conftest.py` | Session fixtures: data dirs, output seeding, config patching |
|
||||
| `pm_helpers.py` | Papermill execution, override loading, Docker env detection |
|
||||
| `overrides.yaml` | Per-notebook parameter overrides, timeouts, skip/env tags |
|
||||
| `fixtures/seed_results.py` | Registry DB and parquet fixture generation |
|
||||
| `_internal/` | Scripts for generating test data and intermediates (not run during tests) |
|
||||
|
||||
---
|
||||
|
||||
## CI Pipeline
|
||||
|
||||
GitHub Actions runs tests on every push and PR:
|
||||
|
||||
1. **Checkout** code + test data repo (via deploy key)
|
||||
2. **Seed** intermediates into `ML4T_OUTPUT_DIR`
|
||||
3. **Run** pytest inside Docker containers (one job per environment)
|
||||
4. **Upload** JUnit XML results as artifacts
|
||||
|
||||
See `.github/workflows/test.yml` for the full configuration.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Notebook
|
||||
|
||||
1. Add a `# %% tags=["parameters"]` cell with production defaults
|
||||
2. Add an entry in `overrides.yaml` with timeout and parameter overrides
|
||||
3. If GPU-required, add `docker_env: gpu`
|
||||
4. Run: `./scripts/run_all_tests.sh ml4t` (or the relevant environment)
|
||||
5. If the notebook depends on upstream pipeline outputs, ensure `seed_results.py` generates the necessary fixtures
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Add missing Papermill parameters cells to notebooks.
|
||||
|
||||
Notebooks that already have a `# %% tags=["parameters"]` cell are skipped.
|
||||
For notebooks without one, this inserts an empty parameters cell after the
|
||||
first code cell (or after imports if detectable).
|
||||
|
||||
Usage:
|
||||
uv run python tests/add_missing_parameters_cells.py [--dry-run]
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
DRY_RUN = "--dry-run" in sys.argv
|
||||
|
||||
# Parameters cell template
|
||||
PARAMS_CELL = """
|
||||
# %% tags=["parameters"]
|
||||
# Production defaults — Papermill injects overrides for CI
|
||||
"""
|
||||
|
||||
|
||||
def find_notebooks() -> list[Path]:
|
||||
"""Find all Jupytext notebooks missing a parameters cell."""
|
||||
notebooks = []
|
||||
for d in sorted(REPO_ROOT.glob("[0-9]*_*")):
|
||||
notebooks.extend(sorted(d.glob("**/*.py")))
|
||||
for d in sorted((REPO_ROOT / "case_studies").glob("*")):
|
||||
if d.is_dir() and d.name not in ("utils", "__pycache__"):
|
||||
notebooks.extend(sorted(d.glob("**/*.py")))
|
||||
|
||||
missing = []
|
||||
for nb in notebooks:
|
||||
content = nb.read_text()
|
||||
if "# %%" not in content:
|
||||
continue
|
||||
if 'tags=["parameters"]' in content or "tags=['parameters']" in content:
|
||||
continue
|
||||
missing.append(nb)
|
||||
return missing
|
||||
|
||||
|
||||
def add_parameters_cell(nb_path: Path) -> bool:
|
||||
"""Insert a parameters cell after the first code cell.
|
||||
|
||||
Strategy: find the first `# %%` code cell (not markdown, not the header)
|
||||
and insert the parameters cell after it. If the first code cell has imports,
|
||||
the parameters cell goes after the import block.
|
||||
"""
|
||||
content = nb_path.read_text()
|
||||
lines = content.split("\n")
|
||||
|
||||
# Find insertion point: after the first code cell
|
||||
# Skip: header (---/jupyter metadata), markdown cells
|
||||
in_header = False
|
||||
found_first_code = False
|
||||
insert_after = None
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
|
||||
# Track Jupytext header
|
||||
if i == 0 and stripped == "# ---":
|
||||
in_header = True
|
||||
continue
|
||||
if in_header:
|
||||
if stripped == "# ---":
|
||||
in_header = False
|
||||
continue
|
||||
|
||||
# Found a code cell
|
||||
if stripped == "# %%" and not found_first_code:
|
||||
found_first_code = True
|
||||
# Look ahead to find end of this cell (next # %% or end)
|
||||
for j in range(i + 1, len(lines)):
|
||||
if lines[j].strip().startswith("# %%"):
|
||||
insert_after = j
|
||||
break
|
||||
else:
|
||||
insert_after = len(lines)
|
||||
break
|
||||
|
||||
if insert_after is None:
|
||||
return False
|
||||
|
||||
# Insert the parameters cell
|
||||
new_lines = (
|
||||
lines[:insert_after] + PARAMS_CELL.rstrip().split("\n") + [""] + lines[insert_after:]
|
||||
)
|
||||
new_content = "\n".join(new_lines)
|
||||
|
||||
if not DRY_RUN:
|
||||
nb_path.write_text(new_content)
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
missing = find_notebooks()
|
||||
print(f"Found {len(missing)} notebooks missing parameters cell")
|
||||
if DRY_RUN:
|
||||
print("(dry run — no changes)")
|
||||
|
||||
modified = 0
|
||||
for nb in missing:
|
||||
rel = nb.relative_to(REPO_ROOT)
|
||||
ok = add_parameters_cell(nb)
|
||||
status = "ADDED" if ok else "SKIPPED"
|
||||
if ok:
|
||||
modified += 1
|
||||
print(f" {status}: {rel}")
|
||||
|
||||
print(f"\nModified: {modified}/{len(missing)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,490 @@
|
||||
"""Pytest fixtures for ML4T test suite.
|
||||
|
||||
Two modes of operation:
|
||||
1. CI (GHA): ML4T_DATA_PATH points to pre-subsampled real data (from private repo).
|
||||
populated_data_dir just returns that path — no synthetic data needed.
|
||||
2. Local dev: ML4T_DATA_PATH points to full production data or test data.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
|
||||
# Case study IDs whose config/setup.yaml should be seeded into test output dirs
|
||||
CASE_STUDY_IDS = [
|
||||
"etfs",
|
||||
"crypto_perps_funding",
|
||||
"nasdaq100_microstructure",
|
||||
"sp500_equity_option_analytics",
|
||||
"us_firm_characteristics",
|
||||
"fx_pairs",
|
||||
"cme_futures",
|
||||
"sp500_options",
|
||||
"us_equities_panel",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def ci_env_setup():
|
||||
"""Create .env file if running in CI (where ML4T_DATA_PATH is set externally).
|
||||
|
||||
utils/config.py requires a .env file to exist.
|
||||
In CI, environment variables are set by the workflow, but the .env
|
||||
file still needs to exist to avoid FileNotFoundError on import.
|
||||
"""
|
||||
env_file = REPO_ROOT / ".env"
|
||||
created = False
|
||||
|
||||
if not env_file.exists():
|
||||
# Create minimal .env for CI
|
||||
env_file.write_text(
|
||||
f"ML4T_PATH={REPO_ROOT}\n"
|
||||
f"ML4T_DATA_PATH={os.environ.get('ML4T_DATA_PATH', REPO_ROOT / 'data')}\n"
|
||||
)
|
||||
created = True
|
||||
|
||||
yield
|
||||
|
||||
# Clean up CI-created .env (don't leave artifacts)
|
||||
if created and env_file.exists():
|
||||
env_file.unlink()
|
||||
|
||||
|
||||
def _resolve_data_path() -> Path | None:
|
||||
"""Find ML4T_DATA_PATH from env var, .env file, or default location.
|
||||
|
||||
pytest-xdist workers may not inherit env vars set by the parent process,
|
||||
so we also check the .env file and well-known test-data locations.
|
||||
"""
|
||||
# 1. Explicit env var (works in single-process pytest and CI)
|
||||
env_path = os.environ.get("ML4T_DATA_PATH")
|
||||
if env_path:
|
||||
p = Path(env_path).expanduser().resolve()
|
||||
if p.exists() and any(p.iterdir()):
|
||||
return p
|
||||
|
||||
# 2. Read from .env file (works in xdist workers)
|
||||
env_file = REPO_ROOT / ".env"
|
||||
if env_file.exists():
|
||||
for line in env_file.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("ML4T_DATA_PATH") and "=" in line:
|
||||
val = line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
if val and not val.startswith("#"):
|
||||
p = Path(val).expanduser().resolve()
|
||||
if p.exists() and any(p.iterdir()):
|
||||
return p
|
||||
|
||||
# 3. Well-known test-data repo location
|
||||
test_data = Path.home() / "ml4t" / "test-data" / "data"
|
||||
if test_data.exists() and (test_data / "etfs").exists():
|
||||
return test_data
|
||||
|
||||
# 4. Default: repo's own data/ directory
|
||||
repo_data = REPO_ROOT / "data"
|
||||
if repo_data.exists() and any(repo_data.glob("*/*.parquet")):
|
||||
return repo_data
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_data_dir(tmp_path_factory):
|
||||
"""Return the data directory for tests.
|
||||
|
||||
Resolves ML4T_DATA_PATH from env var, .env file, well-known test-data
|
||||
repo location, or repo's data/ directory. Works with pytest-xdist.
|
||||
"""
|
||||
resolved = _resolve_data_path()
|
||||
if resolved:
|
||||
os.environ["ML4T_DATA_PATH"] = str(resolved)
|
||||
return resolved
|
||||
|
||||
# Fallback: create temp directory
|
||||
data_dir = tmp_path_factory.mktemp("ml4t_data")
|
||||
os.environ["ML4T_DATA_PATH"] = str(data_dir)
|
||||
return data_dir
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def populated_data_dir(test_data_dir):
|
||||
"""Return a data directory populated with test data.
|
||||
|
||||
If ML4T_DATA_PATH points to pre-populated data (e.g., from GHA checkout
|
||||
of ml4t/third-edition-test-data), returns it directly.
|
||||
"""
|
||||
if (test_data_dir / "etfs" / "market" / "etf_universe.parquet").exists():
|
||||
return test_data_dir
|
||||
|
||||
pytest.skip("No test data available. Set ML4T_DATA_PATH or run in CI.")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def intermediates_dir(test_data_dir):
|
||||
"""Return directory with pre-computed pipeline intermediates.
|
||||
|
||||
When running downstream chapters (Ch11+), they need labels/features
|
||||
from pipeline stages. These are pre-computed and stored in test-data repo.
|
||||
"""
|
||||
idir = test_data_dir.parent / "intermediates"
|
||||
if idir.exists() and any(idir.iterdir()):
|
||||
return idir
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def seeded_output_dir(tmp_path_factory):
|
||||
"""Session-scoped output dir seeded with case study config files.
|
||||
|
||||
Chapter notebooks that read case study setup.yaml (via get_case_study_dir())
|
||||
need these configs to exist even when ML4T_OUTPUT_DIR redirects writes to
|
||||
a temp directory. This fixture copies the real config files into the test
|
||||
output dir so notebooks can find them.
|
||||
|
||||
With pytest-xdist, each worker gets its own subdirectory to avoid races
|
||||
on shutil.rmtree/copytree when multiple workers seed simultaneously.
|
||||
"""
|
||||
base_dir = os.environ.get("ML4T_OUTPUT_DIR")
|
||||
if base_dir:
|
||||
# With xdist, append worker id to avoid races
|
||||
worker_id = os.environ.get("PYTEST_XDIST_WORKER", "")
|
||||
if worker_id:
|
||||
output_dir = Path(base_dir) / f"worker_{worker_id}"
|
||||
else:
|
||||
output_dir = Path(base_dir)
|
||||
else:
|
||||
output_dir = tmp_path_factory.mktemp("ml4t_output")
|
||||
|
||||
# Set the env var so notebooks see this worker's output dir
|
||||
os.environ["ML4T_OUTPUT_DIR"] = str(output_dir)
|
||||
|
||||
cs_root = REPO_ROOT / "case_studies"
|
||||
|
||||
# Copy per-case-study config files (setup.yaml, training menus, backtest presets, etc.)
|
||||
for cs_id in CASE_STUDY_IDS:
|
||||
src_config_dir = cs_root / cs_id / "config"
|
||||
if not src_config_dir.exists():
|
||||
continue
|
||||
dst_config_dir = output_dir / cs_id / "config"
|
||||
if dst_config_dir.exists():
|
||||
shutil.rmtree(dst_config_dir)
|
||||
shutil.copytree(src_config_dir, dst_config_dir)
|
||||
_trim_label_configs(dst_config_dir)
|
||||
|
||||
# Copy global model presets (case_studies/config/) so load_configs() can find them.
|
||||
# load_configs() resolves presets at {case_dir.parent}/config/{model_type}/*.yaml
|
||||
# We copy (not symlink) so we can patch presets for fast testing.
|
||||
global_config_src = cs_root / "config"
|
||||
global_config_dst = output_dir / "config"
|
||||
if global_config_src.exists():
|
||||
if global_config_dst.exists():
|
||||
shutil.rmtree(global_config_dst)
|
||||
shutil.copytree(global_config_src, global_config_dst)
|
||||
_patch_presets_for_testing(global_config_dst)
|
||||
|
||||
# Copy pipeline intermediates (features, labels, run_log) from test-data repo.
|
||||
# These are pre-computed so downstream notebooks (Ch11+) can run without
|
||||
# executing the full pipeline first.
|
||||
# Look for intermediates next to data (test-data repo layout) or at well-known path.
|
||||
data_path = _resolve_data_path()
|
||||
intermediates_root = None
|
||||
if data_path:
|
||||
candidate = Path(data_path).parent / "intermediates"
|
||||
if candidate.exists():
|
||||
intermediates_root = candidate
|
||||
if intermediates_root is None:
|
||||
# Well-known test-data repo location
|
||||
candidate = Path.home() / "ml4t" / "test-data" / "intermediates"
|
||||
if candidate.exists():
|
||||
intermediates_root = candidate
|
||||
if intermediates_root and intermediates_root.exists():
|
||||
for cs_id in CASE_STUDY_IDS:
|
||||
src = intermediates_root / cs_id
|
||||
if not src.exists():
|
||||
continue
|
||||
dst = output_dir / cs_id
|
||||
# Copy features, labels, evaluation, run_log, results, benchmark —
|
||||
# anything that downstream notebooks look for in get_case_study_dir()
|
||||
for subdir in ["features", "labels", "evaluation", "run_log", "results", "benchmark"]:
|
||||
src_sub = src / subdir
|
||||
dst_sub = dst / subdir
|
||||
if src_sub.exists() and not dst_sub.exists():
|
||||
shutil.copytree(src_sub, dst_sub)
|
||||
# Copy top-level intermediate files (e.g. etfs/eligibility.csv,
|
||||
# protocol.yaml, baseline_checkpoint.yaml) that sit directly in
|
||||
# intermediates/{cs_id}/ rather than in a subdir. Downstream
|
||||
# notebooks (etfs 02_labels, 03_financial_features) read these via
|
||||
# get_case_study_dir(); without this they fail with FileNotFoundError.
|
||||
for item in src.iterdir():
|
||||
if item.is_file():
|
||||
dst_file = dst / item.name
|
||||
if not dst_file.exists():
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(item, dst_file)
|
||||
# Schema reconciliation: test-data predictions parquets were
|
||||
# generated with an older column convention (y_score / y_true /
|
||||
# fold_id). Production registry uses (prediction / actual / fold).
|
||||
# Rename in place so notebooks reading via get_case_study_dir()
|
||||
# see the canonical names without per-notebook compat shims.
|
||||
preds_root = dst / "run_log" / "predictions"
|
||||
if preds_root.exists():
|
||||
_migrate_predictions_schema(preds_root)
|
||||
|
||||
# Copy non-case-study intermediates (chapter-scoped fixtures).
|
||||
# These are intermediates that downstream teaching notebooks need but aren't
|
||||
# part of the per-case-study pipeline (e.g., Ch16 signal comparison, Ch20 synthesis).
|
||||
if intermediates_root and intermediates_root.exists():
|
||||
for extra_id in ["ch16_signal_method_comparison", "ch20_synthesis"]:
|
||||
src = intermediates_root / extra_id
|
||||
if not src.exists():
|
||||
continue
|
||||
dst = output_dir / extra_id
|
||||
if not dst.exists():
|
||||
shutil.copytree(src, dst)
|
||||
|
||||
# Seed minimal results fixtures so downstream notebooks (latent factors, DL,
|
||||
# backtest) can find baseline results without depending on upstream execution.
|
||||
# These fill gaps where intermediates don't provide enough (e.g., Ch25 demo
|
||||
# predictions, Ch15 causal JSON, synthetic registry entries).
|
||||
from tests.fixtures.seed_results import seed_results
|
||||
|
||||
seed_results(output_dir, CASE_STUDY_IDS)
|
||||
|
||||
# Symlink AQR factor data so AQRFactorProvider finds it at ~/ml4t/data/aqr_factors
|
||||
aqr_src = data_path.parent / "data" / "factors" / "aqr" if data_path else None
|
||||
if aqr_src is None:
|
||||
aqr_src = Path.home() / "ml4t" / "test-data" / "data" / "factors" / "aqr"
|
||||
aqr_dst = Path.home() / "ml4t" / "data" / "aqr_factors"
|
||||
if aqr_src.exists() and not aqr_dst.exists():
|
||||
aqr_dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
aqr_dst.symlink_to(aqr_src)
|
||||
|
||||
return output_dir
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preset patching — reduce workload for CI/test runs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Per-model-type overrides applied to copied preset YAMLs.
|
||||
# Goal: minimal workload that still exercises the training loop + registry.
|
||||
_TEST_PRESET_PATCHES: dict[str, dict] = {
|
||||
"lgb": {"max_iterations": 2, "checkpoint_interval": 1},
|
||||
# DL families: 2 epochs, checkpoint every epoch
|
||||
"lstm": {"n_epochs": 2, "checkpoint_interval": 1},
|
||||
"tsmixer": {"n_epochs": 2, "checkpoint_interval": 1},
|
||||
"tcn": {"n_epochs": 2, "checkpoint_interval": 1},
|
||||
"nlinear": {"n_epochs": 2, "checkpoint_interval": 1},
|
||||
"patchtst": {"n_epochs": 2, "checkpoint_interval": 1},
|
||||
# TabDL: 2 epochs
|
||||
"tabm": {"n_epochs": 2, "checkpoint_interval": 1},
|
||||
# Latent factors: 2 epochs
|
||||
"cae": {"n_epochs": 2, "checkpoint_interval": 1},
|
||||
"sdf": {"n_epochs": 2, "checkpoint_interval": 1},
|
||||
"sae": {"n_epochs": 2, "checkpoint_interval": 1},
|
||||
"ipca": {"n_epochs": 2, "checkpoint_interval": 1},
|
||||
}
|
||||
|
||||
|
||||
_PREDICTION_COL_RENAMES = {
|
||||
"y_score": "prediction",
|
||||
"y_true": "actual",
|
||||
"fold_id": "fold",
|
||||
}
|
||||
|
||||
|
||||
def _migrate_predictions_schema(preds_root: Path) -> None:
|
||||
"""Rename test-data prediction columns to canonical production schema.
|
||||
|
||||
Test-data parquets were generated with an older convention
|
||||
(y_score / y_true / fold_id). Production registry uses
|
||||
(prediction / actual / fold). Walking the seeded predictions tree once
|
||||
avoids per-notebook compat shims while keeping test-data immutable on
|
||||
its own repo schedule.
|
||||
"""
|
||||
import polars as pl
|
||||
|
||||
for parquet in preds_root.rglob("predictions.parquet"):
|
||||
cols = pl.read_parquet(parquet, n_rows=0).columns
|
||||
renames = {old: new for old, new in _PREDICTION_COL_RENAMES.items() if old in cols}
|
||||
if not renames:
|
||||
continue
|
||||
df = pl.read_parquet(parquet).rename(renames)
|
||||
df.write_parquet(parquet)
|
||||
|
||||
|
||||
def _patch_presets_for_testing(config_dir: Path) -> None:
|
||||
"""Patch copied preset YAMLs with reduced-workload values for testing."""
|
||||
for model_type, overrides in _TEST_PRESET_PATCHES.items():
|
||||
model_dir = config_dir / model_type
|
||||
if not model_dir.exists():
|
||||
continue
|
||||
for preset_path in model_dir.glob("*.yaml"):
|
||||
preset = yaml.safe_load(preset_path.read_text())
|
||||
if preset is None:
|
||||
continue
|
||||
preset.update(overrides)
|
||||
with open(preset_path, "w") as f:
|
||||
yaml.dump(preset, f, default_flow_style=False)
|
||||
|
||||
|
||||
# Max configs per family in label config files (keep tests fast but comprehensive).
|
||||
# Only applied to families with homogeneous sweep configs (linear, gbm).
|
||||
# DL/TabDL/latent/causal families are NOT trimmed because each config often
|
||||
# maps to a dedicated notebook (e.g., 09_dl_lstm, 10_dl_tsmixer).
|
||||
_MAX_CONFIGS_PER_FAMILY = 2
|
||||
_TRIM_FAMILIES = {"linear", "gbm"}
|
||||
|
||||
|
||||
def _trim_label_configs(cs_config_dir: Path) -> None:
|
||||
"""Trim training menu YAMLs to at most _MAX_CONFIGS_PER_FAMILY for sweep families."""
|
||||
training_dir = cs_config_dir / "training"
|
||||
label_root = training_dir if training_dir.exists() else cs_config_dir
|
||||
for label_yaml in label_root.glob("fwd_*.yaml"):
|
||||
data = yaml.safe_load(label_yaml.read_text())
|
||||
if data is None or not isinstance(data, dict):
|
||||
continue
|
||||
trimmed = False
|
||||
for family, configs in data.items():
|
||||
if (
|
||||
family in _TRIM_FAMILIES
|
||||
and isinstance(configs, list)
|
||||
and len(configs) > _MAX_CONFIGS_PER_FAMILY
|
||||
):
|
||||
data[family] = configs[:_MAX_CONFIGS_PER_FAMILY]
|
||||
trimmed = True
|
||||
if trimmed:
|
||||
with open(label_yaml, "w") as f:
|
||||
yaml.dump(data, f, default_flow_style=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GPU marker — apply `@pytest.mark.gpu` at collection time based on overrides.
|
||||
# Usage: pytest -m gpu (GPU only) | pytest -m "not gpu" (CPU only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line("markers", "gpu: notebook requires GPU (from overrides.yaml gpu: true)")
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"long_running: notebook takes >10min even with reduced params (from overrides.yaml long_running: true)",
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"weekly: notebook tier=weekly — runs only in scheduled weekly-external workflow. "
|
||||
"To execute locally, set ML4T_TEST_TIER=weekly alongside `pytest -m weekly`; "
|
||||
"without the env var, matching items are collected but skipped.",
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"on_demand: notebook tier=on_demand — runs only on manual dispatch (e.g., GPU Tier 3). "
|
||||
"To execute locally, set ML4T_TEST_TIER=on_demand alongside `pytest -m on_demand`; "
|
||||
"without the env var, matching items are collected but skipped.",
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers",
|
||||
"drift: live external drift smoke (Phase 3) — one tiny real pull per free "
|
||||
"data source. Network-bound; runs only in the scheduled weekly-external "
|
||||
"workflow's `drift` job, never per-PR.",
|
||||
)
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(items):
|
||||
"""Add markers to test items based on overrides.yaml flags."""
|
||||
from tests.pm_helpers import (
|
||||
TIER_ON_DEMAND,
|
||||
TIER_WEEKLY,
|
||||
get_overrides,
|
||||
get_reruns,
|
||||
get_tier,
|
||||
)
|
||||
|
||||
# pytest-rerunfailures provides @pytest.mark.flaky(reruns=N). Detect once
|
||||
# so per-NB reruns kick in automatically when the dep lands in Step 2.
|
||||
try:
|
||||
import pytest_rerunfailures # noqa: F401
|
||||
|
||||
has_rerunfailures = True
|
||||
except ImportError:
|
||||
has_rerunfailures = False
|
||||
|
||||
for item in items:
|
||||
if hasattr(item, "callspec") and "notebook_path" in item.callspec.params:
|
||||
nb_path = item.callspec.params["notebook_path"]
|
||||
rel = (
|
||||
nb_path.relative_to(REPO_ROOT).with_suffix("")
|
||||
if hasattr(nb_path, "relative_to")
|
||||
else nb_path
|
||||
)
|
||||
overrides = get_overrides(str(rel)) or {}
|
||||
if overrides.get("gpu"):
|
||||
item.add_marker(pytest.mark.gpu)
|
||||
if overrides.get("long_running"):
|
||||
item.add_marker(pytest.mark.long_running)
|
||||
|
||||
tier = get_tier(overrides)
|
||||
if tier == TIER_WEEKLY:
|
||||
item.add_marker(pytest.mark.weekly)
|
||||
elif tier == TIER_ON_DEMAND:
|
||||
item.add_marker(pytest.mark.on_demand)
|
||||
|
||||
reruns = get_reruns(overrides)
|
||||
if reruns > 0 and has_rerunfailures:
|
||||
item.add_marker(pytest.mark.flaky(reruns=reruns, reruns_delay=30))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Incremental result saving — write JSONL after each test so results survive
|
||||
# process kills. Results file: /tmp/ml4t-test-results.jsonl
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_RESULTS_PATH = Path(os.environ.get("ML4T_RESULTS_FILE", "/tmp/ml4t-test-results.jsonl"))
|
||||
_test_start_times: dict[str, float] = {}
|
||||
|
||||
|
||||
@pytest.hookimpl(tryfirst=True)
|
||||
def pytest_runtest_setup(item):
|
||||
"""Record test start time."""
|
||||
_test_start_times[item.nodeid] = time.time()
|
||||
|
||||
|
||||
@pytest.hookimpl(trylast=True)
|
||||
def pytest_runtest_logreport(report):
|
||||
"""Write each test result to JSONL as it completes."""
|
||||
if report.when != "call" and not (report.when == "setup" and report.skipped):
|
||||
return
|
||||
|
||||
start = _test_start_times.pop(report.nodeid, 0)
|
||||
duration = report.duration if hasattr(report, "duration") else 0
|
||||
|
||||
outcome = report.outcome # "passed", "failed", or "skipped"
|
||||
|
||||
record = {
|
||||
"nodeid": report.nodeid,
|
||||
"outcome": outcome,
|
||||
"duration_s": round(duration, 2),
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
}
|
||||
if outcome == "failed" and report.longreprtext:
|
||||
record["error"] = report.longreprtext[:500]
|
||||
|
||||
with open(_RESULTS_PATH, "a") as f:
|
||||
f.write(json.dumps(record) + "\n")
|
||||
f.flush()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_env():
|
||||
"""Fixture that provides a clean environment and restores it after."""
|
||||
saved_env = os.environ.copy()
|
||||
yield os.environ
|
||||
os.environ.clear()
|
||||
os.environ.update(saved_env)
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
# Test Fixtures
|
||||
|
||||
Minimal results JSON files seeded into test output directories so downstream
|
||||
notebooks (latent factors, DL, backtest, etc.) can find baseline results
|
||||
without depending on upstream notebook execution order.
|
||||
|
||||
These are NOT real results — they contain plausible placeholder values that
|
||||
allow the comparison/selection code paths to execute without crashing.
|
||||
|
||||
Seeded by `conftest.py::seeded_output_dir` fixture.
|
||||
Vendored
Vendored
+1183
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate pipeline intermediates for the test-data repo.
|
||||
|
||||
Runs all 9 case study pipelines through specified stages
|
||||
via Papermill with test overrides, capturing outputs to the specified directory.
|
||||
|
||||
The outputs are committed to ml4t/third-edition-test-data so that downstream
|
||||
chapters (Ch11+) can read pre-computed labels/features/predictions without
|
||||
re-running the full pipeline.
|
||||
|
||||
Usage:
|
||||
cd ~/ml4t/third_edition/code
|
||||
ML4T_DATA_PATH=~/ml4t/test-data/data \
|
||||
uv run python tests/generate_intermediates.py \
|
||||
--output ~/ml4t/test-data/intermediates
|
||||
|
||||
# Run only through features (stages 01-03)
|
||||
uv run python tests/generate_intermediates.py \
|
||||
--output ~/ml4t/test-data/intermediates \
|
||||
--through-stage 3
|
||||
|
||||
# Include DL stages (slow)
|
||||
uv run python tests/generate_intermediates.py \
|
||||
--output ~/ml4t/test-data/intermediates \
|
||||
--through-stage 12 --no-skip-dl
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
try:
|
||||
from tests.pm_helpers import get_overrides, run_notebook
|
||||
except ModuleNotFoundError:
|
||||
from pm_helpers import get_overrides, run_notebook
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
|
||||
CASE_STUDIES = [
|
||||
"etfs",
|
||||
"crypto_perps_funding",
|
||||
"nasdaq100_microstructure",
|
||||
"sp500_equity_option_analytics",
|
||||
"us_firm_characteristics",
|
||||
"fx_pairs",
|
||||
"cme_futures",
|
||||
"sp500_options",
|
||||
"us_equities_panel",
|
||||
]
|
||||
|
||||
# Stage patterns to skip when --skip-dl is active (DL/latent/causal are heavy)
|
||||
DL_STAGE_PATTERNS = re.compile(
|
||||
r"\d{2}_("
|
||||
r"dl_|deep_learning|tabular_dl|latent_factors|pca|ipca|cae|sdf|sae|"
|
||||
r"autoencoder|term_structure_pca|causal_dml"
|
||||
r")"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config seeding — replicate conftest.py seeded_output_dir logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Per-model-type overrides applied to copied preset YAMLs.
|
||||
# Goal: minimal workload that still exercises the training loop + registry.
|
||||
_TEST_PRESET_PATCHES: dict[str, dict] = {
|
||||
"lgb": {"max_iterations": 2, "checkpoint_interval": 1},
|
||||
"lstm": {"n_epochs": 2, "checkpoint_interval": 1},
|
||||
"tsmixer": {"n_epochs": 2, "checkpoint_interval": 1},
|
||||
"tcn": {"n_epochs": 2, "checkpoint_interval": 1},
|
||||
"nlinear": {"n_epochs": 2, "checkpoint_interval": 1},
|
||||
"patchtst": {"n_epochs": 2, "checkpoint_interval": 1},
|
||||
"tabm": {"n_epochs": 2, "checkpoint_interval": 1},
|
||||
"cae": {"n_epochs": 2, "checkpoint_interval": 1},
|
||||
"sdf": {"n_epochs": 2, "checkpoint_interval": 1},
|
||||
"sae": {"n_epochs": 2, "checkpoint_interval": 1},
|
||||
"ipca": {"n_epochs": 2, "checkpoint_interval": 1},
|
||||
}
|
||||
|
||||
_MAX_CONFIGS_PER_FAMILY = 2
|
||||
_TRIM_FAMILIES = {"linear", "gbm"}
|
||||
|
||||
|
||||
def _patch_presets_for_testing(config_dir: Path) -> None:
|
||||
"""Patch copied preset YAMLs with reduced-workload values for testing."""
|
||||
for model_type, overrides in _TEST_PRESET_PATCHES.items():
|
||||
model_dir = config_dir / model_type
|
||||
if not model_dir.exists():
|
||||
continue
|
||||
for preset_path in model_dir.glob("*.yaml"):
|
||||
preset = yaml.safe_load(preset_path.read_text())
|
||||
if preset is None:
|
||||
continue
|
||||
preset.update(overrides)
|
||||
with open(preset_path, "w") as f:
|
||||
yaml.dump(preset, f, default_flow_style=False)
|
||||
|
||||
|
||||
def _trim_label_configs(cs_config_dir: Path) -> None:
|
||||
"""Trim label config YAMLs to at most _MAX_CONFIGS_PER_FAMILY for sweep families."""
|
||||
for label_yaml in cs_config_dir.glob("fwd_*.yaml"):
|
||||
data = yaml.safe_load(label_yaml.read_text())
|
||||
if data is None or not isinstance(data, dict):
|
||||
continue
|
||||
trimmed = False
|
||||
for family, configs in data.items():
|
||||
if (
|
||||
family in _TRIM_FAMILIES
|
||||
and isinstance(configs, list)
|
||||
and len(configs) > _MAX_CONFIGS_PER_FAMILY
|
||||
):
|
||||
data[family] = configs[:_MAX_CONFIGS_PER_FAMILY]
|
||||
trimmed = True
|
||||
if trimmed:
|
||||
with open(label_yaml, "w") as f:
|
||||
yaml.dump(data, f, default_flow_style=False)
|
||||
|
||||
|
||||
def seed_configs(output_dir: Path) -> None:
|
||||
"""Copy case study configs and global model presets into output_dir.
|
||||
|
||||
Replicates the logic of conftest.py's seeded_output_dir fixture so that
|
||||
notebooks executed via generate_intermediates.py find patched configs.
|
||||
"""
|
||||
cs_root = REPO_ROOT / "case_studies"
|
||||
|
||||
# Copy per-case-study config files (setup.yaml, training menus, backtest presets, etc.)
|
||||
for cs_id in CASE_STUDIES:
|
||||
src_config_dir = cs_root / cs_id / "config"
|
||||
if not src_config_dir.exists():
|
||||
continue
|
||||
dst_config_dir = output_dir / cs_id / "config"
|
||||
if dst_config_dir.exists():
|
||||
shutil.rmtree(dst_config_dir)
|
||||
shutil.copytree(src_config_dir, dst_config_dir)
|
||||
_trim_label_configs(dst_config_dir)
|
||||
|
||||
# Copy global model presets so load_configs() can find them.
|
||||
# load_configs() resolves presets at {case_dir.parent}/config/{model_type}/*.yaml
|
||||
global_config_src = cs_root / "config"
|
||||
global_config_dst = output_dir / "config"
|
||||
if global_config_src.exists() and not global_config_dst.exists():
|
||||
shutil.copytree(global_config_src, global_config_dst)
|
||||
_patch_presets_for_testing(global_config_dst)
|
||||
|
||||
print(f"Seeded configs into {output_dir}")
|
||||
|
||||
|
||||
def discover_stages(cs_dir: Path, through_stage: int, skip_dl: bool) -> list[Path]:
|
||||
"""Auto-discover pipeline stages in a case study directory.
|
||||
|
||||
Returns sorted list of .py notebook paths up through the specified stage number.
|
||||
Skips DL/latent/causal stages when skip_dl is True.
|
||||
"""
|
||||
stages = []
|
||||
for notebook in sorted(cs_dir.glob("[0-9][0-9]_*.py")):
|
||||
if notebook.name.startswith("_"):
|
||||
continue
|
||||
|
||||
stage_num = int(notebook.stem[:2])
|
||||
if stage_num > through_stage:
|
||||
continue
|
||||
|
||||
if skip_dl and DL_STAGE_PATTERNS.match(notebook.stem):
|
||||
continue
|
||||
|
||||
stages.append(notebook)
|
||||
|
||||
return stages
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate pipeline intermediates for CI")
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="Output directory for intermediates",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--case-studies",
|
||||
nargs="+",
|
||||
default=CASE_STUDIES,
|
||||
help="Case studies to run (default: all)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--through-stage",
|
||||
type=int,
|
||||
default=8,
|
||||
help="Run stages up to this number (default: 8 = through GBM for all case studies including sp500_options/08_gbm)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-dl",
|
||||
action="store_true",
|
||||
default=True,
|
||||
help="Skip DL/latent/causal stages (default: True)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-skip-dl",
|
||||
action="store_false",
|
||||
dest="skip_dl",
|
||||
help="Include DL/latent/causal stages",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
output_dir = args.output.expanduser().resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Seed configs (setup.yaml, label configs, model presets) into output dir
|
||||
# so notebooks find patched configs when ML4T_OUTPUT_DIR is set.
|
||||
seed_configs(output_dir)
|
||||
|
||||
# Set ML4T_OUTPUT_DIR so all pipeline writes go to our output directory
|
||||
os.environ["ML4T_OUTPUT_DIR"] = str(output_dir)
|
||||
os.environ["MPLBACKEND"] = "Agg"
|
||||
os.environ["PLOTLY_RENDERER"] = "json"
|
||||
|
||||
results = {}
|
||||
total_start = time.time()
|
||||
|
||||
for cs in args.case_studies:
|
||||
cs_dir = REPO_ROOT / "case_studies" / cs
|
||||
if not cs_dir.exists():
|
||||
print(f"\nSKIP {cs}: directory not found")
|
||||
continue
|
||||
|
||||
stages = discover_stages(cs_dir, args.through_stage, args.skip_dl)
|
||||
if not stages:
|
||||
print(f"\nSKIP {cs}: no stages found")
|
||||
continue
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Case study: {cs} ({len(stages)} stages)")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
cs_failed = False
|
||||
for notebook in stages:
|
||||
stage = notebook.stem
|
||||
|
||||
if cs_failed:
|
||||
print(f" {stage}: SKIP (earlier stage failed)")
|
||||
results[f"{cs}::{stage}"] = "skipped"
|
||||
continue
|
||||
|
||||
rel_path = notebook.relative_to(REPO_ROOT).with_suffix("")
|
||||
overrides = get_overrides(str(rel_path))
|
||||
|
||||
# Skip if overrides say so
|
||||
if overrides.get("skip"):
|
||||
reason = overrides.get("skip_reason", "marked skip")
|
||||
print(f" {stage}: SKIP ({reason})")
|
||||
results[f"{cs}::{stage}"] = "skipped"
|
||||
# Pipeline stages (01-05) cascade their skip
|
||||
stage_num = int(stage[:2])
|
||||
if stage_num <= 5:
|
||||
cs_failed = True
|
||||
continue
|
||||
|
||||
timeout = overrides.get("timeout", 300)
|
||||
parameters = overrides.get("parameters", {})
|
||||
|
||||
print(f" {stage}: running...", end="", flush=True)
|
||||
start = time.time()
|
||||
|
||||
result = run_notebook(
|
||||
py_path=notebook,
|
||||
parameters=parameters,
|
||||
timeout=timeout,
|
||||
output_dir=output_dir,
|
||||
)
|
||||
|
||||
elapsed = time.time() - start
|
||||
|
||||
if result["status"] == "ok":
|
||||
print(f" OK ({elapsed:.0f}s)")
|
||||
results[f"{cs}::{stage}"] = "ok"
|
||||
else:
|
||||
print(f" FAILED ({elapsed:.0f}s)")
|
||||
print(f" Error: {result['error']}")
|
||||
results[f"{cs}::{stage}"] = "failed"
|
||||
cs_failed = True
|
||||
|
||||
total_elapsed = time.time() - total_start
|
||||
|
||||
# Summary
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Summary ({total_elapsed:.0f}s total)")
|
||||
print(f"{'=' * 60}")
|
||||
ok = sum(1 for v in results.values() if v == "ok")
|
||||
failed = sum(1 for v in results.values() if v == "failed")
|
||||
skipped = sum(1 for v in results.values() if v == "skipped")
|
||||
print(f" OK: {ok} Failed: {failed} Skipped: {skipped}")
|
||||
|
||||
if failed:
|
||||
print("\nFailed stages:")
|
||||
for k, v in results.items():
|
||||
if v == "failed":
|
||||
print(f" - {k}")
|
||||
|
||||
# Show output size
|
||||
total_bytes = sum(f.stat().st_size for f in output_dir.rglob("*") if f.is_file())
|
||||
print(f"\nOutput: {output_dir} ({total_bytes / 1e6:.1f} MB)")
|
||||
|
||||
# Write metadata for staleness tracking
|
||||
metadata = {
|
||||
"generated_at": datetime.now(UTC).isoformat(),
|
||||
"through_stage": args.through_stage,
|
||||
"skip_dl": args.skip_dl,
|
||||
"results": results,
|
||||
"total_seconds": round(total_elapsed),
|
||||
"size_mb": round(total_bytes / 1e6, 1),
|
||||
}
|
||||
metadata_path = output_dir / "_metadata.json"
|
||||
with open(metadata_path, "w") as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
print(f"Metadata: {metadata_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,392 @@
|
||||
"""Generate synthetic test data for currently-skipped notebooks.
|
||||
|
||||
Run once to enrich the test-data repo with minimal synthetic datasets
|
||||
that allow the remaining skipped notebooks to execute their code paths.
|
||||
|
||||
Usage:
|
||||
uv run python tests/generate_skip_data.py --output ~/ml4t/test-data
|
||||
|
||||
This generates data for:
|
||||
1. FNSPID news dataset (Ch10/07, Ch10/08)
|
||||
2. SEC 10-Q MD&A text (Ch10/09)
|
||||
3. ADV columns for Kyle lambda (Ch18/03)
|
||||
4. Engine divergence predictions (Ch16/07)
|
||||
5. Signal quality synthesis data (Ch20/02)
|
||||
6. MLOps drift detection features (Ch26/02)
|
||||
7. MLOps safe model rollout (Ch26/03)
|
||||
8. MLOps MLflow registry (Ch26/06)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import polars as pl
|
||||
|
||||
np.random.seed(42)
|
||||
|
||||
SYMBOLS_ETF = ["SPY", "QQQ", "IWM", "TLT", "GLD", "XLF", "XLK", "XLE", "EFA", "VWO"]
|
||||
SYMBOLS_EQ = ["AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "TSLA", "JPM", "V", "JNJ"]
|
||||
|
||||
|
||||
def generate_fnspid_news(data_dir: Path):
|
||||
"""Generate synthetic FNSPID financial news data."""
|
||||
out = data_dir / "alternative" / "news" / "fnspid"
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
headlines = [
|
||||
"{sym} reports strong quarterly earnings, beats estimates",
|
||||
"{sym} shares drop on weaker-than-expected revenue guidance",
|
||||
"{sym} announces major acquisition worth $2.5B",
|
||||
"Analysts upgrade {sym} citing improving margins",
|
||||
"{sym} CEO discusses expansion plans in earnings call",
|
||||
"Market volatility hits {sym} as sector rotates",
|
||||
"{sym} launches new product line targeting enterprise customers",
|
||||
"Institutional investors increase {sym} holdings in Q3",
|
||||
"{sym} faces regulatory scrutiny over data practices",
|
||||
"{sym} dividend increase signals management confidence",
|
||||
]
|
||||
|
||||
rows = []
|
||||
dates = pl.date_range(date(2022, 1, 3), date(2024, 12, 31), "1d", eager=True)
|
||||
for d in dates:
|
||||
# 2-5 news items per day
|
||||
n_items = np.random.randint(2, 6)
|
||||
for _ in range(n_items):
|
||||
sym = np.random.choice(SYMBOLS_EQ)
|
||||
headline = np.random.choice(headlines).format(sym=sym)
|
||||
rows.append(
|
||||
{
|
||||
"ticker": sym,
|
||||
"timestamp": d,
|
||||
"title": headline,
|
||||
"source": np.random.choice(["Reuters", "Bloomberg", "CNBC", "WSJ"]),
|
||||
}
|
||||
)
|
||||
|
||||
df = pl.DataFrame(rows)
|
||||
df.write_parquet(out / "fnspid_sample.parquet")
|
||||
print(f" FNSPID: {len(df)} news items -> {out / 'fnspid_sample.parquet'}")
|
||||
|
||||
|
||||
def generate_sec_10q_mda(data_dir: Path):
|
||||
"""Generate synthetic SEC 10-Q MD&A text data."""
|
||||
out = data_dir / "alternative" / "text"
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
rows = []
|
||||
for sym in SYMBOLS_EQ[:6]:
|
||||
for year in range(2019, 2024):
|
||||
for quarter in range(1, 5):
|
||||
month = quarter * 3 + 1
|
||||
if month > 12:
|
||||
month = 1
|
||||
year_f = year + 1
|
||||
else:
|
||||
year_f = year
|
||||
filing_date = date(year_f, min(month, 12), 15)
|
||||
period_end = date(year, quarter * 3, 28)
|
||||
|
||||
mda_text = (
|
||||
f"Management's Discussion and Analysis for {sym}. "
|
||||
f"During Q{quarter} {year}, revenue increased by {np.random.uniform(2, 15):.1f}% "
|
||||
f"year-over-year. Operating margins improved to {np.random.uniform(15, 35):.1f}%. "
|
||||
f"We continue to invest in R&D and expect continued growth. "
|
||||
f"Key risks include market volatility and regulatory changes."
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"symbol": sym,
|
||||
"cik": str(np.random.randint(100000, 999999)),
|
||||
"accession_no": f"0001234567-{year_f:04d}-{np.random.randint(10000, 99999):05d}",
|
||||
"filing_date": filing_date,
|
||||
"period_end": period_end,
|
||||
"mda_text": mda_text,
|
||||
"mda_word_count": len(mda_text.split()),
|
||||
"mda_char_count": len(mda_text),
|
||||
}
|
||||
)
|
||||
|
||||
df = pl.DataFrame(rows)
|
||||
df.write_parquet(out / "sp500_10q_mda.parquet")
|
||||
print(f" SEC 10-Q: {len(df)} filings -> {out / 'sp500_10q_mda.parquet'}")
|
||||
|
||||
|
||||
def enrich_adv_columns(data_dir: Path):
|
||||
"""Add adv_21d (21-day average daily volume) to datasets that need it.
|
||||
|
||||
The Kyle lambda market impact calibration notebook (Ch18/03) reads
|
||||
adv_21d from equity price data. The test data doesn't have this computed.
|
||||
"""
|
||||
datasets = [
|
||||
("etfs", "etf_universe.parquet"),
|
||||
("equities", "us_equities.parquet"),
|
||||
]
|
||||
for subdir, filename in datasets:
|
||||
path = data_dir / subdir / filename
|
||||
if not path.exists():
|
||||
print(f" ADV: SKIP {path} (not found)")
|
||||
continue
|
||||
df = pl.read_parquet(path)
|
||||
if "adv_21d" in df.columns:
|
||||
print(f" ADV: SKIP {path} (already has adv_21d)")
|
||||
continue
|
||||
if "volume" not in df.columns:
|
||||
print(f" ADV: SKIP {path} (no volume column)")
|
||||
continue
|
||||
|
||||
# Compute rolling 21-day average volume per symbol
|
||||
sort_cols = ["symbol", "timestamp"] if "symbol" in df.columns else ["timestamp"]
|
||||
group_col = "symbol" if "symbol" in df.columns else None
|
||||
|
||||
if group_col:
|
||||
df = df.sort(sort_cols).with_columns(
|
||||
pl.col("volume")
|
||||
.rolling_mean(window_size=21, min_samples=1)
|
||||
.over(group_col)
|
||||
.alias("adv_21d")
|
||||
)
|
||||
else:
|
||||
df = df.sort("timestamp").with_columns(
|
||||
pl.col("volume").rolling_mean(window_size=21, min_samples=1).alias("adv_21d")
|
||||
)
|
||||
|
||||
df.write_parquet(path)
|
||||
print(f" ADV: Added adv_21d to {path} ({len(df)} rows)")
|
||||
|
||||
|
||||
def generate_engine_divergence_predictions(intermediates_dir: Path):
|
||||
"""Generate predictions with model column for Ch16/07 engine divergence."""
|
||||
out = intermediates_dir / "ch16_signal_method_comparison"
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
dates = pl.date_range(date(2022, 1, 3), date(2023, 12, 29), "1d", eager=True)
|
||||
rows = []
|
||||
for d in dates:
|
||||
for sym in SYMBOLS_ETF[:5]:
|
||||
rows.append(
|
||||
{
|
||||
"timestamp": d,
|
||||
"symbol": sym,
|
||||
"prediction": np.random.normal(0, 0.02),
|
||||
"model": "ridge_a1.0",
|
||||
}
|
||||
)
|
||||
|
||||
df = pl.DataFrame(rows)
|
||||
df.write_parquet(out / "predictions_with_model.parquet")
|
||||
print(f" Engine divergence: {len(df)} rows -> {out}")
|
||||
|
||||
|
||||
def generate_signal_quality_data(intermediates_dir: Path):
|
||||
"""Generate synthesis data for Ch20/02 signal quality notebook."""
|
||||
# The notebook reads from Ch20/01 aggregate_synthesis outputs
|
||||
out = intermediates_dir / "ch20_synthesis"
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
case_studies = [
|
||||
"etfs",
|
||||
"crypto_perps_funding",
|
||||
"nasdaq100_microstructure",
|
||||
"sp500_equity_option_analytics",
|
||||
"us_firm_characteristics",
|
||||
"fx_pairs",
|
||||
"cme_futures",
|
||||
"sp500_options",
|
||||
"us_equities_panel",
|
||||
]
|
||||
models = ["linear/ridge", "gbm/leaves_15", "deep_learning/lstm", "tabular_dl/tabm_l"]
|
||||
|
||||
# IC comparison data
|
||||
ic_rows = []
|
||||
for cs in case_studies:
|
||||
for model in models:
|
||||
ic_rows.append(
|
||||
{
|
||||
"case_study": cs,
|
||||
"source": model,
|
||||
"ic_mean": np.random.uniform(-0.02, 0.06),
|
||||
"ic_std": np.random.uniform(0.01, 0.04),
|
||||
"n_folds": 5,
|
||||
}
|
||||
)
|
||||
|
||||
ic_df = pl.DataFrame(ic_rows)
|
||||
ic_df.write_parquet(out / "ic_comparison.parquet")
|
||||
|
||||
# Synthesis JSON
|
||||
synthesis = {
|
||||
"case_studies": {
|
||||
cs: {
|
||||
"champion": {
|
||||
"source": "gbm/leaves_15",
|
||||
"sharpe": float(np.random.uniform(-0.5, 2.0)),
|
||||
},
|
||||
"holdout": {
|
||||
"ic": float(np.random.uniform(-0.02, 0.1)),
|
||||
"sharpe": float(np.random.uniform(-1, 3)),
|
||||
},
|
||||
}
|
||||
for cs in case_studies
|
||||
}
|
||||
}
|
||||
(out / "all_synthesis.json").write_text(json.dumps(synthesis, indent=2))
|
||||
print(f" Signal quality: IC comparison + synthesis -> {out}")
|
||||
|
||||
|
||||
def generate_mlops_data(intermediates_dir: Path, data_dir: Path):
|
||||
"""Generate data for Ch26 MLOps notebooks (02, 03, 06)."""
|
||||
# Ch26/02 needs ETFs features with adv_21d — handled by enrich_adv_columns
|
||||
|
||||
# Ch26/03 needs a linear/lasso validation run in registry
|
||||
out = intermediates_dir / "us_equities_panel" / "run_log"
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
db_path = out / "registry.db"
|
||||
db = sqlite3.connect(str(db_path))
|
||||
db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS training_runs (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
entry_point TEXT,
|
||||
source TEXT,
|
||||
label TEXT,
|
||||
config_hash TEXT,
|
||||
created_at TEXT,
|
||||
ic_mean REAL,
|
||||
status TEXT DEFAULT 'completed'
|
||||
)
|
||||
""")
|
||||
db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS prediction_sets (
|
||||
pred_id TEXT PRIMARY KEY,
|
||||
run_id TEXT,
|
||||
entry_point TEXT,
|
||||
source TEXT,
|
||||
label TEXT,
|
||||
config_hash TEXT,
|
||||
created_at TEXT,
|
||||
ic_mean REAL,
|
||||
n_rows INTEGER,
|
||||
pred_path TEXT
|
||||
)
|
||||
""")
|
||||
db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS prediction_metrics (
|
||||
metric_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pred_id TEXT,
|
||||
fold INTEGER,
|
||||
ic REAL,
|
||||
n_rows INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
# Insert a few synthetic runs
|
||||
for i, (source, ic) in enumerate(
|
||||
[
|
||||
("linear/ridge_a1.0", 0.025),
|
||||
("linear/lasso_a0.01", 0.018),
|
||||
("gbm/leaves_15_mae", 0.042),
|
||||
]
|
||||
):
|
||||
run_id = f"run_{i:03d}"
|
||||
pred_id = f"pred_{i:03d}"
|
||||
db.execute(
|
||||
"INSERT OR REPLACE INTO training_runs VALUES (?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
run_id,
|
||||
"06_linear" if "linear" in source else "07_gbm",
|
||||
source,
|
||||
"fwd_ret_1d",
|
||||
f"hash_{i}",
|
||||
"2026-01-01T00:00:00",
|
||||
ic,
|
||||
"completed",
|
||||
),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT OR REPLACE INTO prediction_sets VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
pred_id,
|
||||
run_id,
|
||||
"06_linear" if "linear" in source else "07_gbm",
|
||||
source,
|
||||
"fwd_ret_1d",
|
||||
f"hash_{i}",
|
||||
"2026-01-01T00:00:00",
|
||||
ic,
|
||||
1000,
|
||||
f"predictions/{pred_id}.parquet",
|
||||
),
|
||||
)
|
||||
for fold in range(5):
|
||||
db.execute(
|
||||
"INSERT INTO prediction_metrics (pred_id, fold, ic, n_rows) VALUES (?,?,?,?)",
|
||||
(pred_id, fold, ic + np.random.normal(0, 0.005), 200),
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.close()
|
||||
print(f" MLOps registry: 3 runs -> {db_path}")
|
||||
|
||||
# Generate stub predictions for the registry entries
|
||||
preds_dir = out.parent / "predictions"
|
||||
preds_dir.mkdir(parents=True, exist_ok=True)
|
||||
dates = pl.date_range(date(2023, 1, 2), date(2023, 12, 29), "1d", eager=True)
|
||||
for i in range(3):
|
||||
rows = []
|
||||
for d in dates:
|
||||
for sym in SYMBOLS_EQ[:5]:
|
||||
rows.append(
|
||||
{
|
||||
"timestamp": d,
|
||||
"symbol": sym,
|
||||
"prediction": np.random.normal(0, 0.02),
|
||||
"fold": np.random.randint(0, 5),
|
||||
}
|
||||
)
|
||||
df = pl.DataFrame(rows)
|
||||
df.write_parquet(preds_dir / f"pred_{i:03d}.parquet")
|
||||
print(f" MLOps predictions: 3 files -> {preds_dir}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate synthetic test data for skipped notebooks"
|
||||
)
|
||||
parser.add_argument("--output", required=True, help="Test data repo root")
|
||||
args = parser.parse_args()
|
||||
|
||||
root = Path(args.output)
|
||||
data_dir = root / "data"
|
||||
intermediates_dir = root / "intermediates"
|
||||
|
||||
print("Generating synthetic test data for skipped notebooks...")
|
||||
print()
|
||||
|
||||
print("[1/6] FNSPID news data (Ch10/07, Ch10/08)...")
|
||||
generate_fnspid_news(data_dir)
|
||||
|
||||
print("[2/6] SEC 10-Q MD&A text (Ch10/09)...")
|
||||
generate_sec_10q_mda(data_dir)
|
||||
|
||||
print("[3/6] ADV columns for Kyle lambda (Ch18/03)...")
|
||||
enrich_adv_columns(data_dir)
|
||||
|
||||
print("[4/6] Engine divergence predictions (Ch16/07)...")
|
||||
generate_engine_divergence_predictions(intermediates_dir)
|
||||
|
||||
print("[5/6] Signal quality synthesis data (Ch20/02)...")
|
||||
generate_signal_quality_data(intermediates_dir)
|
||||
|
||||
print("[6/6] MLOps registry and predictions (Ch26/02-06)...")
|
||||
generate_mlops_data(intermediates_dir, data_dir)
|
||||
|
||||
print()
|
||||
print("Done! Now commit changes to the test-data repo and update overrides.yaml.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
# Regenerate all test data (raw + intermediates) for CI.
|
||||
#
|
||||
# Run from the review repo root. Requires:
|
||||
# - ML4T_DATA_PATH to point to full production data (default: ~/Dropbox/ml4t/data)
|
||||
# - The working repo at ~/ml4t/third-edition
|
||||
# - The review repo at ~/ml4t/technical_review
|
||||
#
|
||||
# Usage:
|
||||
# cd ~/ml4t/technical_review
|
||||
# bash tests/generate_test_data.sh [TEST_DATA_DIR]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
TEST_DATA_DIR="${1:-$HOME/ml4t/test-data}"
|
||||
WORKING_REPO="$HOME/ml4t/third-edition"
|
||||
REVIEW_REPO="$HOME/ml4t/technical_review"
|
||||
|
||||
echo "=== ML4T Test Data Generator ==="
|
||||
echo "Output: $TEST_DATA_DIR"
|
||||
echo "Working repo: $WORKING_REPO"
|
||||
echo "Review repo: $REVIEW_REPO"
|
||||
echo ""
|
||||
|
||||
# Step 1: Generate subsampled raw data
|
||||
echo "=== Step 1: Generating subsampled data ==="
|
||||
cd "$WORKING_REPO"
|
||||
uv run python tests/create_test_data.py \
|
||||
--source "${ML4T_DATA_PATH:-$HOME/Dropbox/ml4t/data}" \
|
||||
--output "$TEST_DATA_DIR/data" \
|
||||
--clean
|
||||
echo ""
|
||||
|
||||
# Step 2: Deploy latest notebooks from third-edition -> review repo
|
||||
echo "=== Step 2: Deploying latest notebooks ==="
|
||||
cd "$WORKING_REPO"
|
||||
uv run python scripts/deploy_to_review.py --all
|
||||
echo ""
|
||||
|
||||
# Step 3: Generate intermediates (runs pipeline notebooks via Papermill)
|
||||
echo "=== Step 3: Generating intermediates ==="
|
||||
cd "$REVIEW_REPO"
|
||||
ML4T_DATA_PATH="$TEST_DATA_DIR/data" \
|
||||
MPLBACKEND=Agg \
|
||||
PLOTLY_RENDERER=json \
|
||||
uv run python tests/generate_intermediates.py \
|
||||
--output "$TEST_DATA_DIR/intermediates"
|
||||
echo ""
|
||||
|
||||
# Summary
|
||||
echo "=== Done ==="
|
||||
echo "Test data directory: $TEST_DATA_DIR"
|
||||
du -sh "$TEST_DATA_DIR/data" "$TEST_DATA_DIR/intermediates" 2>/dev/null || true
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " cd $TEST_DATA_DIR"
|
||||
echo " git add -A"
|
||||
echo " git commit -m 'update: regenerate test data'"
|
||||
echo " git push"
|
||||
@@ -0,0 +1,865 @@
|
||||
"""Generate minimal synthetic test data for Ch03 microstructure notebooks.
|
||||
|
||||
Creates test-sized datasets that match the schemas expected by Ch03 notebooks
|
||||
and related notebooks (Ch02 futures individual, Ch04 prediction markets).
|
||||
|
||||
Writes to ~/ml4t/test-data/data/ which serves as ML4T_DATA_PATH
|
||||
in CI.
|
||||
|
||||
Usage:
|
||||
uv run python tests/generate_test_microstructure.py
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import polars as pl
|
||||
|
||||
# ── Output root ──────────────────────────────────────────────────────────────
|
||||
TEST_DATA_ROOT = Path.home() / "ml4t" / "test-data" / "data"
|
||||
|
||||
# Seed for reproducibility
|
||||
RNG = np.random.default_rng(42)
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 1. ITCH Parsed Messages (for NB 02-10)
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# These go into the ITCH messages path that load_nasdaq_itch() resolves:
|
||||
# ML4T_DATA_PATH / "equities" / "market" / "microstructure" / "nasdaq_itch" / "messages"
|
||||
# Notebooks 02-10 read via utils.limit_orderbook.load_itch_messages(itch_dir, msg_type, symbol)
|
||||
|
||||
|
||||
def _ns_timestamp(hour: int, minute: int, second: int = 0, micro: int = 0) -> datetime:
|
||||
"""Create a nanosecond-precision datetime on the ITCH trading day (2020-01-30)."""
|
||||
return datetime(2020, 1, 30, hour, minute, second, micro)
|
||||
|
||||
|
||||
def generate_itch_messages() -> None:
|
||||
"""Generate all ITCH message type parquet files."""
|
||||
itch_dir = (
|
||||
TEST_DATA_ROOT / "equities" / "market" / "microstructure" / "nasdaq_itch" / "messages"
|
||||
)
|
||||
|
||||
# ── R (Stock Directory) ──────────────────────────────────────────────
|
||||
r_dir = itch_dir / "R"
|
||||
r_dir.mkdir(parents=True, exist_ok=True)
|
||||
r_df = pl.DataFrame(
|
||||
{
|
||||
"stock_locate": pl.Series([1, 2, 3], dtype=pl.UInt16),
|
||||
"tracking_number": pl.Series([0, 0, 0], dtype=pl.UInt16),
|
||||
"timestamp": [
|
||||
_ns_timestamp(4, 0, 0),
|
||||
_ns_timestamp(4, 0, 0),
|
||||
_ns_timestamp(4, 0, 0),
|
||||
],
|
||||
"stock": ["AAPL", "MSFT", "NVDA"],
|
||||
"market_category": ["Q", "Q", "Q"],
|
||||
"financial_status": ["N", "N", "N"],
|
||||
"round_lot_size": pl.Series([100, 100, 100], dtype=pl.UInt32),
|
||||
"round_lots_only": ["N", "N", "N"],
|
||||
"issue_classification": ["C", "C", "C"],
|
||||
"issue_subtype": ["Z", "Z", "Z"],
|
||||
"authenticity": ["P", "P", "P"],
|
||||
"short_sale_threshold": ["N", "N", "N"],
|
||||
"ipo_flag": ["N", "N", "N"],
|
||||
"luld_reference_price_tier": ["1", "1", "1"],
|
||||
"etp_flag": ["N", "N", "N"],
|
||||
"etp_leverage_factor": pl.Series([0, 0, 0], dtype=pl.UInt32),
|
||||
"inverse_indicator": ["N", "N", "N"],
|
||||
}
|
||||
).cast({"timestamp": pl.Datetime("ns")})
|
||||
r_df.write_parquet(r_dir / "part-000000.parquet")
|
||||
|
||||
# ── S (System Event) ─────────────────────────────────────────────────
|
||||
s_dir = itch_dir / "S"
|
||||
s_dir.mkdir(parents=True, exist_ok=True)
|
||||
s_df = pl.DataFrame(
|
||||
{
|
||||
"stock_locate": pl.Series([0, 0, 0, 0], dtype=pl.UInt16),
|
||||
"tracking_number": pl.Series([0, 0, 0, 0], dtype=pl.UInt16),
|
||||
"timestamp": [
|
||||
_ns_timestamp(4, 0, 0),
|
||||
_ns_timestamp(9, 30, 0),
|
||||
_ns_timestamp(16, 0, 0),
|
||||
_ns_timestamp(20, 0, 0),
|
||||
],
|
||||
"event_code": ["O", "Q", "M", "C"],
|
||||
}
|
||||
).cast({"timestamp": pl.Datetime("ns")})
|
||||
s_df.write_parquet(s_dir / "part-000000.parquet")
|
||||
|
||||
# ── A (Add Order) ────────────────────────────────────────────────────
|
||||
# 20 orders for AAPL (stock_locate=1), spanning 10:00 to 15:00
|
||||
a_dir = itch_dir / "A"
|
||||
a_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
n_orders = 20
|
||||
base_price_aapl = 320.0 # AAPL price circa Jan 2020
|
||||
order_refs = list(range(1001, 1001 + n_orders))
|
||||
sides = ["B" if i % 2 == 0 else "S" for i in range(n_orders)]
|
||||
shares = [int(RNG.integers(100, 1001)) for _ in range(n_orders)]
|
||||
# Prices: bids slightly below base, asks slightly above
|
||||
prices = []
|
||||
for i, side in enumerate(sides):
|
||||
offset = RNG.uniform(0.01, 0.50)
|
||||
if side == "B":
|
||||
prices.append(round(base_price_aapl - offset, 4))
|
||||
else:
|
||||
prices.append(round(base_price_aapl + offset, 4))
|
||||
|
||||
# Timestamps spaced across 10:00-15:00 (300 minutes = 18000 seconds)
|
||||
a_timestamps = [
|
||||
_ns_timestamp(10, 0) + timedelta(seconds=int(i * 18000 / n_orders)) for i in range(n_orders)
|
||||
]
|
||||
|
||||
# Prices stored as ITCH price4 integers (multiply by 10000) per spec
|
||||
a_df = pl.DataFrame(
|
||||
{
|
||||
"stock_locate": pl.Series([1] * n_orders, dtype=pl.UInt16),
|
||||
"tracking_number": pl.Series([0] * n_orders, dtype=pl.UInt16),
|
||||
"timestamp": a_timestamps,
|
||||
"order_reference_number": pl.Series(order_refs, dtype=pl.UInt64),
|
||||
"buy_sell_indicator": sides,
|
||||
"shares": pl.Series(shares, dtype=pl.UInt32),
|
||||
"stock": ["AAPL"] * n_orders,
|
||||
"price": pl.Series([int(p * 10000) for p in prices], dtype=pl.UInt32),
|
||||
}
|
||||
).cast({"timestamp": pl.Datetime("ns")})
|
||||
a_df.write_parquet(a_dir / "part-000000.parquet")
|
||||
|
||||
# ── D (Order Delete) ─────────────────────────────────────────────────
|
||||
d_dir = itch_dir / "D"
|
||||
d_dir.mkdir(parents=True, exist_ok=True)
|
||||
delete_refs = [1001, 1003, 1005, 1007, 1009]
|
||||
d_df = pl.DataFrame(
|
||||
{
|
||||
"stock_locate": pl.Series([1] * 5, dtype=pl.UInt16),
|
||||
"tracking_number": pl.Series([0] * 5, dtype=pl.UInt16),
|
||||
"timestamp": [
|
||||
a_timestamps[0] + timedelta(seconds=30),
|
||||
a_timestamps[2] + timedelta(seconds=30),
|
||||
a_timestamps[4] + timedelta(seconds=30),
|
||||
a_timestamps[6] + timedelta(seconds=30),
|
||||
a_timestamps[8] + timedelta(seconds=30),
|
||||
],
|
||||
"order_reference_number": pl.Series(delete_refs, dtype=pl.UInt64),
|
||||
}
|
||||
).cast({"timestamp": pl.Datetime("ns")})
|
||||
d_df.write_parquet(d_dir / "part-000000.parquet")
|
||||
|
||||
# ── E (Order Executed) ───────────────────────────────────────────────
|
||||
e_dir = itch_dir / "E"
|
||||
e_dir.mkdir(parents=True, exist_ok=True)
|
||||
exec_refs = [1002, 1004, 1006, 1008, 1010, 1012, 1014, 1016]
|
||||
exec_shares = [min(shares[r - 1001] // 2, 200) for r in exec_refs]
|
||||
e_df = pl.DataFrame(
|
||||
{
|
||||
"stock_locate": pl.Series([1] * 8, dtype=pl.UInt16),
|
||||
"tracking_number": pl.Series([0] * 8, dtype=pl.UInt16),
|
||||
"timestamp": [a_timestamps[r - 1001] + timedelta(seconds=60) for r in exec_refs],
|
||||
"order_reference_number": pl.Series(exec_refs, dtype=pl.UInt64),
|
||||
"executed_shares": pl.Series(exec_shares, dtype=pl.UInt32),
|
||||
"match_number": pl.Series(list(range(5001, 5009)), dtype=pl.UInt64),
|
||||
}
|
||||
).cast({"timestamp": pl.Datetime("ns")})
|
||||
e_df.write_parquet(e_dir / "part-000000.parquet")
|
||||
|
||||
# ── X (Order Cancel) ─────────────────────────────────────────────────
|
||||
x_dir = itch_dir / "X"
|
||||
x_dir.mkdir(parents=True, exist_ok=True)
|
||||
cancel_refs = [1011, 1013, 1015]
|
||||
cancel_shares = [shares[r - 1001] // 3 for r in cancel_refs]
|
||||
x_df = pl.DataFrame(
|
||||
{
|
||||
"stock_locate": pl.Series([1] * 3, dtype=pl.UInt16),
|
||||
"tracking_number": pl.Series([0] * 3, dtype=pl.UInt16),
|
||||
"timestamp": [a_timestamps[r - 1001] + timedelta(seconds=45) for r in cancel_refs],
|
||||
"order_reference_number": pl.Series(cancel_refs, dtype=pl.UInt64),
|
||||
"cancelled_shares": pl.Series(cancel_shares, dtype=pl.UInt32),
|
||||
}
|
||||
).cast({"timestamp": pl.Datetime("ns")})
|
||||
x_df.write_parquet(x_dir / "part-000000.parquet")
|
||||
|
||||
# ── C (Order Executed with Price) ────────────────────────────────────
|
||||
c_dir = itch_dir / "C"
|
||||
c_dir.mkdir(parents=True, exist_ok=True)
|
||||
c_refs = [1017, 1018]
|
||||
c_df = pl.DataFrame(
|
||||
{
|
||||
"stock_locate": pl.Series([1] * 2, dtype=pl.UInt16),
|
||||
"tracking_number": pl.Series([0] * 2, dtype=pl.UInt16),
|
||||
"timestamp": [
|
||||
a_timestamps[16] + timedelta(seconds=90),
|
||||
a_timestamps[17] + timedelta(seconds=90),
|
||||
],
|
||||
"order_reference_number": pl.Series(c_refs, dtype=pl.UInt64),
|
||||
"executed_shares": pl.Series([shares[16] // 4, shares[17] // 4], dtype=pl.UInt32),
|
||||
"match_number": pl.Series([6001, 6002], dtype=pl.UInt64),
|
||||
"printable": ["Y", "Y"],
|
||||
"execution_price": pl.Series(
|
||||
[int(prices[16] * 10000), int(prices[17] * 10000)], dtype=pl.UInt32
|
||||
),
|
||||
}
|
||||
).cast({"timestamp": pl.Datetime("ns")})
|
||||
c_df.write_parquet(c_dir / "part-000000.parquet")
|
||||
|
||||
# ── P (Non-Cross Trade) ──────────────────────────────────────────────
|
||||
p_dir = itch_dir / "P"
|
||||
p_dir.mkdir(parents=True, exist_ok=True)
|
||||
p_df = pl.DataFrame(
|
||||
{
|
||||
"stock_locate": pl.Series([1, 1, 1], dtype=pl.UInt16),
|
||||
"tracking_number": pl.Series([0, 0, 0], dtype=pl.UInt16),
|
||||
"timestamp": [
|
||||
_ns_timestamp(11, 30, 0),
|
||||
_ns_timestamp(13, 0, 0),
|
||||
_ns_timestamp(14, 30, 0),
|
||||
],
|
||||
"order_reference_number": pl.Series([2001, 2002, 2003], dtype=pl.UInt64),
|
||||
"buy_sell_indicator": ["B", "S", "B"],
|
||||
"shares": pl.Series([200, 150, 300], dtype=pl.UInt32),
|
||||
"stock": ["AAPL", "AAPL", "AAPL"],
|
||||
"price": pl.Series([int(base_price_aapl * 10000)] * 3, dtype=pl.UInt32),
|
||||
"match_number": pl.Series([7001, 7002, 7003], dtype=pl.UInt64),
|
||||
}
|
||||
).cast({"timestamp": pl.Datetime("ns")})
|
||||
p_df.write_parquet(p_dir / "part-000000.parquet")
|
||||
|
||||
# ── U (Order Replace) ────────────────────────────────────────────────
|
||||
u_dir = itch_dir / "U"
|
||||
u_dir.mkdir(parents=True, exist_ok=True)
|
||||
u_df = pl.DataFrame(
|
||||
{
|
||||
"stock_locate": pl.Series([1, 1], dtype=pl.UInt16),
|
||||
"tracking_number": pl.Series([0, 0], dtype=pl.UInt16),
|
||||
"timestamp": [
|
||||
a_timestamps[18] + timedelta(seconds=20),
|
||||
a_timestamps[19] + timedelta(seconds=20),
|
||||
],
|
||||
"original_order_reference_number": pl.Series([1019, 1020], dtype=pl.UInt64),
|
||||
"new_order_reference_number": pl.Series([3001, 3002], dtype=pl.UInt64),
|
||||
"shares": pl.Series([500, 600], dtype=pl.UInt32),
|
||||
"price": pl.Series(
|
||||
[int((base_price_aapl - 0.10) * 10000), int((base_price_aapl + 0.10) * 10000)],
|
||||
dtype=pl.UInt32,
|
||||
),
|
||||
}
|
||||
).cast({"timestamp": pl.Datetime("ns")})
|
||||
u_df.write_parquet(u_dir / "part-000000.parquet")
|
||||
|
||||
print(f" ITCH messages written to {itch_dir}")
|
||||
for sub in sorted(itch_dir.iterdir()):
|
||||
if sub.is_dir() and sub.name != "enriched":
|
||||
n = pl.scan_parquet(sub / "*.parquet").select(pl.len()).collect().item()
|
||||
print(f" {sub.name}/: {n} rows")
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 2. DataBento MBO (for NB 09-13)
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# Path: ML4T_DATA_PATH / "equities" / "market" / "microstructure" / "market_by_order" / "NVDA"
|
||||
# File naming: xnas-itch-YYYYMMDD.mbo.dbn.parquet (DataBento convention)
|
||||
# NB09, NB12 expect "timestamp" column. NB10, NB11, NB13 also need it.
|
||||
# We provide BOTH ts_event and timestamp (same values) for compatibility.
|
||||
|
||||
|
||||
def _generate_mbo_day(base_date: datetime, base_price_nano: int, start_order_id: int) -> list[dict]:
|
||||
"""Generate one day of MBO messages with realistic bid/ask structure.
|
||||
|
||||
Returns a list of row dicts (not yet a DataFrame).
|
||||
"""
|
||||
rows: list[dict] = []
|
||||
order_id = start_order_id
|
||||
n_cycles = 50 # 50 cycles spread across 6.5 hours of trading
|
||||
|
||||
for cycle in range(n_cycles):
|
||||
cycle_start_ms = cycle * 468_000 # ~7.8 min per cycle
|
||||
|
||||
# Phase 1: Adds (build book) - 15 orders per cycle
|
||||
for i in range(15):
|
||||
ts = base_date + timedelta(milliseconds=cycle_start_ms + i * 100)
|
||||
side = "B" if i % 2 == 0 else "A"
|
||||
if side == "B":
|
||||
price_offset = -RNG.integers(1, 51) * 10_000_000
|
||||
else:
|
||||
price_offset = RNG.integers(1, 51) * 10_000_000
|
||||
price = base_price_nano + price_offset
|
||||
size = int(RNG.integers(1, 501))
|
||||
rows.append(
|
||||
{
|
||||
"ts_event": ts,
|
||||
"ts_recv": ts + timedelta(microseconds=int(RNG.integers(1, 100))),
|
||||
"action": "A",
|
||||
"side": side,
|
||||
"price": price,
|
||||
"size": size,
|
||||
"order_id": order_id,
|
||||
"flags": 0,
|
||||
"publisher_id": 39,
|
||||
}
|
||||
)
|
||||
order_id += 1
|
||||
|
||||
# Phase 2: Modifications - 3 per cycle
|
||||
for i in range(3):
|
||||
ts = base_date + timedelta(milliseconds=cycle_start_ms + 1500 + i * 200)
|
||||
mod_order = order_id - 15 + i * 5
|
||||
mod_side = "B" if i % 2 == 0 else "A"
|
||||
if mod_side == "B":
|
||||
price_offset = -RNG.integers(1, 31) * 10_000_000
|
||||
else:
|
||||
price_offset = RNG.integers(1, 31) * 10_000_000
|
||||
rows.append(
|
||||
{
|
||||
"ts_event": ts,
|
||||
"ts_recv": ts + timedelta(microseconds=int(RNG.integers(1, 100))),
|
||||
"action": "M",
|
||||
"side": mod_side,
|
||||
"price": base_price_nano + price_offset,
|
||||
"size": int(RNG.integers(1, 300)),
|
||||
"order_id": mod_order,
|
||||
"flags": 0,
|
||||
"publisher_id": 39,
|
||||
}
|
||||
)
|
||||
|
||||
# Phase 3: Cancels - 3 per cycle
|
||||
for i in range(3):
|
||||
ts = base_date + timedelta(milliseconds=cycle_start_ms + 2100 + i * 200)
|
||||
cancel_order = order_id - 14 + i * 5
|
||||
rows.append(
|
||||
{
|
||||
"ts_event": ts,
|
||||
"ts_recv": ts + timedelta(microseconds=int(RNG.integers(1, 100))),
|
||||
"action": "C",
|
||||
"side": "A" if i % 2 == 0 else "B",
|
||||
"price": base_price_nano,
|
||||
"size": 0,
|
||||
"order_id": cancel_order,
|
||||
"flags": 0,
|
||||
"publisher_id": 39,
|
||||
}
|
||||
)
|
||||
|
||||
# Phase 4: Fills (F) and Trades (T) - 10 per cycle
|
||||
for i in range(10):
|
||||
ts = base_date + timedelta(milliseconds=cycle_start_ms + 2700 + i * 300)
|
||||
fill_order = order_id - 13 + i
|
||||
fill_size = int(RNG.integers(1, 200))
|
||||
# Biased aggressor side for realistic imbalance runs.
|
||||
# Runs of 10 consecutive cycles (~100 trades) with 95% bias,
|
||||
# creating sustained imbalance that triggers bar boundaries.
|
||||
# This mimics real institutional order flow patterns.
|
||||
run_idx = cycle // 10
|
||||
if run_idx % 2 == 0:
|
||||
aggressor = "B" if RNG.random() < 0.95 else "A"
|
||||
else:
|
||||
aggressor = "A" if RNG.random() < 0.95 else "B"
|
||||
trade_price = base_price_nano + RNG.integers(-5, 6) * 10_000_000
|
||||
fill_side = "A" if aggressor == "B" else "B"
|
||||
rows.append(
|
||||
{
|
||||
"ts_event": ts,
|
||||
"ts_recv": ts + timedelta(microseconds=int(RNG.integers(1, 100))),
|
||||
"action": "F",
|
||||
"side": fill_side,
|
||||
"price": trade_price,
|
||||
"size": fill_size,
|
||||
"order_id": fill_order,
|
||||
"flags": 128,
|
||||
"publisher_id": 39,
|
||||
}
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"ts_event": ts + timedelta(microseconds=1),
|
||||
"ts_recv": ts + timedelta(microseconds=int(RNG.integers(2, 150))),
|
||||
"action": "T",
|
||||
"side": aggressor,
|
||||
"price": trade_price,
|
||||
"size": fill_size,
|
||||
"order_id": fill_order,
|
||||
"flags": 128,
|
||||
"publisher_id": 39,
|
||||
}
|
||||
)
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def generate_mbo_data() -> None:
|
||||
"""Generate synthetic DataBento MBO tick data for NVDA.
|
||||
|
||||
Key schema requirements from notebooks:
|
||||
- NB09 (lee_ready): expects "timestamp" column, reads parquet directly
|
||||
- NB10 (information_bars): expects filename like xnas-itch-YYYYMMDD.mbo.dbn.parquet
|
||||
- NB11 (lob_reconstruction): expects "ts_event" column, reads parquet directly
|
||||
- NB12 (mbo_analysis): expects "timestamp" column, reads parquet directly
|
||||
- NB13 (bar_sampling): expects "timestamp" column, filename like xnas-itch-*
|
||||
|
||||
We include both ts_event and timestamp columns, and use DataBento file naming.
|
||||
We also generate enough data (spread across hours) for meaningful analysis.
|
||||
"""
|
||||
mbo_dir = TEST_DATA_ROOT / "equities" / "market" / "microstructure" / "market_by_order" / "NVDA"
|
||||
mbo_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Remove old file if it exists (was named 20241104.parquet before)
|
||||
old_file = mbo_dir / "20241104.parquet"
|
||||
if old_file.exists():
|
||||
old_file.unlink()
|
||||
|
||||
base_price_nano = 140_000_000_000 # $140 in nanodollars
|
||||
|
||||
# Generate 3 days of data. NB13 (bar_sampling) computes day-to-day CV which
|
||||
# needs >= 2 days. NB10 (information_bars) also benefits from more trades.
|
||||
trading_days = [
|
||||
datetime(2024, 11, 4, 14, 30, 0), # Monday 9:30 AM ET in UTC
|
||||
datetime(2024, 11, 5, 14, 30, 0), # Tuesday
|
||||
datetime(2024, 11, 6, 14, 30, 0), # Wednesday
|
||||
]
|
||||
|
||||
for day_idx, base_date in enumerate(trading_days):
|
||||
rows = _generate_mbo_day(base_date, base_price_nano, 100_000 + day_idx * 10_000)
|
||||
|
||||
df = (
|
||||
pl.DataFrame(rows)
|
||||
.cast(
|
||||
{
|
||||
"ts_event": pl.Datetime("ns"),
|
||||
"ts_recv": pl.Datetime("ns"),
|
||||
"price": pl.Int64,
|
||||
"size": pl.Int64,
|
||||
"order_id": pl.Int64,
|
||||
"flags": pl.Int64,
|
||||
"publisher_id": pl.Int64,
|
||||
}
|
||||
)
|
||||
.sort("ts_event")
|
||||
)
|
||||
|
||||
# Add canonical "timestamp" column (same as ts_event) for notebooks that expect it.
|
||||
# NB09, NB12, NB13 use "timestamp"; NB10, NB11 use "ts_event".
|
||||
df = df.with_columns(pl.col("ts_event").alias("timestamp"))
|
||||
|
||||
# Write with DataBento filename convention: xnas-itch-YYYYMMDD.mbo.dbn.parquet
|
||||
# NB10 and NB13 parse the filename: file_path.name.split("-")[2].split(".")[0]
|
||||
date_str = base_date.strftime("%Y%m%d")
|
||||
out_file = mbo_dir / f"xnas-itch-{date_str}.mbo.dbn.parquet"
|
||||
df.write_parquet(out_file)
|
||||
print(f" MBO day {date_str}: {len(df)} rows -> {out_file}")
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 3. AlgoSeek TAQ (for NB 15-16)
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# Path: ML4T_DATA_PATH / "equities" / "market" / "microstructure" / "trade_and_quotes" / "symbol=AAPL" / "data.parquet"
|
||||
# NB15 expects: TRADE, QUOTE BID, QUOTE ASK, QUOTE BID NB, QUOTE ASK NB event types
|
||||
# NB15 does spread analysis using NBBO quotes and trade size distribution
|
||||
|
||||
|
||||
def generate_taq_data() -> None:
|
||||
"""Generate synthetic AlgoSeek TAQ tick data for AAPL on 2020-03-16.
|
||||
|
||||
Key schema requirements from notebooks:
|
||||
- NB15 (taq_eda): Needs TRADE, QUOTE BID NB, QUOTE ASK NB event types
|
||||
for spread analysis. Needs enough trades for size distribution.
|
||||
- NB16 (taq_lob): Needs QUOTE BID/ASK for LOB reconstruction.
|
||||
|
||||
We generate ~600 events with realistic distributions.
|
||||
"""
|
||||
taq_dir = (
|
||||
TEST_DATA_ROOT
|
||||
/ "equities"
|
||||
/ "market"
|
||||
/ "microstructure"
|
||||
/ "trade_and_quotes"
|
||||
/ "symbol=AAPL"
|
||||
)
|
||||
taq_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# March 16, 2020: AAPL around $250, huge volatility day
|
||||
base_date = datetime(2020, 3, 16)
|
||||
base_price = 250.0
|
||||
exchanges = ["Q", "N", "Z", "P", "K"]
|
||||
|
||||
rows = []
|
||||
# Generate ~600 rows: mix of trades, exchange quotes, and NBBO quotes
|
||||
# NB15 needs: TRADE events for trade analysis, QUOTE BID NB / QUOTE ASK NB for spread
|
||||
for i in range(600):
|
||||
# Random time between 9:30 and 16:00 (6.5 hours = 23400 seconds)
|
||||
seconds_offset = int(RNG.integers(0, 23400))
|
||||
ts = base_date + timedelta(
|
||||
hours=9, minutes=30, seconds=seconds_offset, microseconds=int(RNG.integers(0, 999999))
|
||||
)
|
||||
|
||||
# Event type distribution:
|
||||
# ~15% trades, ~20% NBBO bids, ~20% NBBO asks, ~20% exchange bids, ~20% exchange asks
|
||||
# We need QUOTE BID NB and QUOTE ASK NB for NB15's spread analysis
|
||||
r = RNG.random()
|
||||
if r < 0.15:
|
||||
event_type = "TRADE"
|
||||
price = round(base_price + RNG.normal(0, 5), 2)
|
||||
quantity = int(RNG.integers(10, 10001))
|
||||
elif r < 0.35:
|
||||
event_type = "QUOTE BID NB"
|
||||
price = round(base_price - abs(RNG.normal(0.03, 0.10)), 2)
|
||||
quantity = int(RNG.integers(100, 5001))
|
||||
elif r < 0.55:
|
||||
event_type = "QUOTE ASK NB"
|
||||
price = round(base_price + abs(RNG.normal(0.03, 0.10)), 2)
|
||||
quantity = int(RNG.integers(100, 5001))
|
||||
elif r < 0.75:
|
||||
event_type = "QUOTE BID"
|
||||
price = round(base_price - abs(RNG.normal(0.05, 0.20)), 2)
|
||||
quantity = int(RNG.integers(100, 5001))
|
||||
else:
|
||||
event_type = "QUOTE ASK"
|
||||
price = round(base_price + abs(RNG.normal(0.05, 0.20)), 2)
|
||||
quantity = int(RNG.integers(100, 5001))
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"timestamp": ts,
|
||||
"event_type": event_type,
|
||||
"price": price,
|
||||
"quantity": quantity,
|
||||
"exchange": exchanges[int(RNG.integers(0, len(exchanges)))],
|
||||
"conditions": "00000000",
|
||||
}
|
||||
)
|
||||
|
||||
df = (
|
||||
pl.DataFrame(rows)
|
||||
.cast(
|
||||
{
|
||||
"timestamp": pl.Datetime("us"),
|
||||
"price": pl.Float64,
|
||||
"quantity": pl.Int64,
|
||||
}
|
||||
)
|
||||
.sort("timestamp")
|
||||
)
|
||||
|
||||
df.write_parquet(taq_dir / "data.parquet")
|
||||
print(f" TAQ data: {len(df)} rows -> {taq_dir / 'data.parquet'}")
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 4. IEX Parsed Data (for NB 14)
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# Path: ML4T_DATA_PATH / "equities" / "market" / "microstructure" / "iex" / "deep" / "parsed" / {type}/
|
||||
|
||||
|
||||
def generate_iex_data() -> None:
|
||||
"""Generate synthetic IEX DEEP parsed data."""
|
||||
parsed_dir = (
|
||||
TEST_DATA_ROOT / "equities" / "market" / "microstructure" / "iex" / "deep" / "parsed"
|
||||
)
|
||||
|
||||
base_date = datetime(2025, 1, 15, 14, 30, 0) # 9:30 AM ET in UTC
|
||||
base_price = 240.0 # AAPL-ish
|
||||
|
||||
# ── Quotes ───────────────────────────────────────────────────────────
|
||||
quotes_dir = parsed_dir / "quotes"
|
||||
quotes_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
quote_rows = []
|
||||
for i in range(30):
|
||||
ts = base_date + timedelta(seconds=i * 60)
|
||||
spread = round(abs(RNG.normal(0.02, 0.01)), 4)
|
||||
mid = base_price + RNG.normal(0, 0.5)
|
||||
quote_rows.append(
|
||||
{
|
||||
"timestamp": ts,
|
||||
"symbol": "AAPL",
|
||||
"bid_price": round(mid - spread / 2, 2),
|
||||
"bid_size": int(RNG.integers(100, 5001)),
|
||||
"ask_price": round(mid + spread / 2, 2),
|
||||
"ask_size": int(RNG.integers(100, 5001)),
|
||||
}
|
||||
)
|
||||
|
||||
quotes_df = pl.DataFrame(quote_rows).cast(
|
||||
{
|
||||
"timestamp": pl.Datetime("ns"),
|
||||
"bid_price": pl.Float64,
|
||||
"ask_price": pl.Float64,
|
||||
"bid_size": pl.Int64,
|
||||
"ask_size": pl.Int64,
|
||||
}
|
||||
)
|
||||
quotes_df.write_parquet(quotes_dir / "data.parquet")
|
||||
print(f" IEX quotes: {len(quotes_df)} rows -> {quotes_dir / 'data.parquet'}")
|
||||
|
||||
# ── Trades ───────────────────────────────────────────────────────────
|
||||
trades_dir = parsed_dir / "trades"
|
||||
trades_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
trade_rows = []
|
||||
for i in range(20):
|
||||
ts = base_date + timedelta(seconds=i * 90 + int(RNG.integers(0, 30)))
|
||||
trade_rows.append(
|
||||
{
|
||||
"timestamp": ts,
|
||||
"symbol": "AAPL",
|
||||
"price": round(base_price + RNG.normal(0, 0.3), 2),
|
||||
"size": int(RNG.integers(1, 501)),
|
||||
}
|
||||
)
|
||||
|
||||
trades_df = pl.DataFrame(trade_rows).cast(
|
||||
{
|
||||
"timestamp": pl.Datetime("ns"),
|
||||
"price": pl.Float64,
|
||||
"size": pl.Int64,
|
||||
}
|
||||
)
|
||||
trades_df.write_parquet(trades_dir / "data.parquet")
|
||||
print(f" IEX trades: {len(trades_df)} rows -> {trades_dir / 'data.parquet'}")
|
||||
|
||||
# ── Price Levels ─────────────────────────────────────────────────────
|
||||
price_levels_dir = parsed_dir / "price_levels"
|
||||
price_levels_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
pl_rows = []
|
||||
for i in range(40):
|
||||
ts = base_date + timedelta(seconds=i * 45)
|
||||
side = "bid" if i % 2 == 0 else "ask"
|
||||
offset = RNG.uniform(0.01, 0.50)
|
||||
price = round(base_price - offset if side == "bid" else base_price + offset, 2)
|
||||
pl_rows.append(
|
||||
{
|
||||
"timestamp": ts,
|
||||
"symbol": "AAPL",
|
||||
"side": side,
|
||||
"price": price,
|
||||
"size": int(RNG.integers(100, 3001)),
|
||||
}
|
||||
)
|
||||
|
||||
pl_df = pl.DataFrame(pl_rows).cast(
|
||||
{
|
||||
"timestamp": pl.Datetime("ns"),
|
||||
"price": pl.Float64,
|
||||
"size": pl.Int64,
|
||||
}
|
||||
)
|
||||
pl_df.write_parquet(price_levels_dir / "data.parquet")
|
||||
print(f" IEX price_levels: {len(pl_df)} rows -> {price_levels_dir / 'data.parquet'}")
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 5. CME Individual Contracts (for Ch02 NB 04-06)
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# Path: ML4T_DATA_PATH / "futures" / "market" / "individual" / "{PRODUCT}" / "data.parquet"
|
||||
# Schema matches what load_cme_futures(continuous=False) returns:
|
||||
# timestamp (datetime[ns, UTC]), rtype, publisher_id, instrument_id,
|
||||
# open, high, low, close, volume, product
|
||||
#
|
||||
# NB06 (futures_continuous) needs:
|
||||
# - Multiple contracts with OVERLAPPING date ranges
|
||||
# - Volume patterns that make front-month detection possible
|
||||
# - Enough contracts for roll detection to produce adj_close
|
||||
|
||||
|
||||
def generate_individual_futures() -> None:
|
||||
"""Generate synthetic CME individual contract data for ES, NQ, CL.
|
||||
|
||||
Key requirements from NB06 (continuous construction):
|
||||
- Contracts must overlap in time (concurrent trading)
|
||||
- Front month should have highest volume (for volume-based roll detection)
|
||||
- Need at least 3 contracts with clear roll transitions
|
||||
- Need enough data points for roll gaps to produce adj_close
|
||||
"""
|
||||
individual_dir = TEST_DATA_ROOT / "futures" / "market" / "individual"
|
||||
|
||||
products = {
|
||||
"ES": {"base_price": 4500.0, "tick": 0.25},
|
||||
"NQ": {"base_price": 15500.0, "tick": 0.25},
|
||||
"CL": {"base_price": 75.0, "tick": 0.01},
|
||||
}
|
||||
|
||||
# Contract months: H=March, M=June, U=Sep, Z=Dec
|
||||
# Instrument IDs encode contract month. Simulate 4 quarterly contracts
|
||||
# overlapping across 2024, with volume-based rolls.
|
||||
contract_specs = [
|
||||
# (instrument_id, start_day_offset, end_day_offset, is_front_until_day)
|
||||
# Contract 1 (H24): front month days 0-29, then rolls to contract 2
|
||||
(49701, 0, 59, 29),
|
||||
# Contract 2 (M24): front month days 30-89, then rolls to contract 3
|
||||
(49702, 15, 119, 89),
|
||||
# Contract 3 (U24): front month days 90-149, then rolls to contract 4
|
||||
(49703, 75, 179, 149),
|
||||
# Contract 4 (Z24): front month from day 150 onward
|
||||
(49704, 135, 209, 209),
|
||||
]
|
||||
|
||||
for product, cfg in products.items():
|
||||
prod_dir = individual_dir / product
|
||||
prod_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
rows = []
|
||||
start = datetime(2024, 1, 2, 0, 0, 0)
|
||||
|
||||
for inst_id, start_day, end_day, front_until in contract_specs:
|
||||
# Adjust instrument_id per product to be unique
|
||||
if product == "NQ":
|
||||
inst_id += 1000
|
||||
elif product == "CL":
|
||||
inst_id += 2000
|
||||
|
||||
for day_offset in range(start_day, end_day + 1):
|
||||
# Generate one bar per day (24 hours apart for hourly-like data)
|
||||
ts = start + timedelta(days=day_offset)
|
||||
|
||||
# Price drifts slightly
|
||||
base = cfg["base_price"] + RNG.normal(0, cfg["base_price"] * 0.002)
|
||||
o = round(base, 2)
|
||||
h = round(base + abs(RNG.normal(0, cfg["base_price"] * 0.001)), 2)
|
||||
l = round(base - abs(RNG.normal(0, cfg["base_price"] * 0.001)), 2)
|
||||
c = round(base + RNG.normal(0, cfg["base_price"] * 0.0005), 2)
|
||||
|
||||
# Volume: high when front month, low when back month
|
||||
if day_offset <= front_until:
|
||||
vol = int(RNG.integers(10000, 50001)) # Front month: high volume
|
||||
else:
|
||||
vol = int(RNG.integers(100, 3001)) # Back month: low volume
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"timestamp": ts,
|
||||
"rtype": 35,
|
||||
"publisher_id": 1,
|
||||
"instrument_id": inst_id,
|
||||
"open": o,
|
||||
"high": h,
|
||||
"low": l,
|
||||
"close": c,
|
||||
"volume": vol,
|
||||
"product": product,
|
||||
}
|
||||
)
|
||||
|
||||
df = (
|
||||
pl.DataFrame(rows)
|
||||
.cast(
|
||||
{
|
||||
"timestamp": pl.Datetime("ns", time_zone="UTC"),
|
||||
"rtype": pl.UInt8,
|
||||
"publisher_id": pl.UInt16,
|
||||
"instrument_id": pl.UInt32,
|
||||
"open": pl.Float64,
|
||||
"high": pl.Float64,
|
||||
"low": pl.Float64,
|
||||
"close": pl.Float64,
|
||||
"volume": pl.UInt64,
|
||||
}
|
||||
)
|
||||
.sort("timestamp")
|
||||
)
|
||||
|
||||
df.write_parquet(prod_dir / "data.parquet")
|
||||
print(f" Futures individual {product}: {len(df)} rows -> {prod_dir / 'data.parquet'}")
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 6. Kalshi Events (for Ch04 NB 13)
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# Path: ML4T_DATA_PATH / "prediction_markets" / "kalshi_events.parquet"
|
||||
# Schema: timestamp (Date), symbol (str), open/high/low/close (Float64), volume (Int64)
|
||||
|
||||
|
||||
def generate_kalshi_data() -> None:
|
||||
"""Generate synthetic Kalshi prediction market data."""
|
||||
pm_dir = TEST_DATA_ROOT / "prediction_markets"
|
||||
pm_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 5 contracts, ~10 days each = ~50 rows
|
||||
contracts = [
|
||||
"KXFED-27APR-T4.25",
|
||||
"KXFED-27APR-T4.50",
|
||||
"KXFED-27JUN-T4.00",
|
||||
"KXINFL-27MAR-T3.0",
|
||||
"KXGDP-27Q1-T2.0",
|
||||
]
|
||||
|
||||
rows = []
|
||||
base_date = date(2027, 3, 1)
|
||||
|
||||
for contract in contracts:
|
||||
# Each contract gets a base probability and drifts
|
||||
base_prob = RNG.uniform(0.2, 0.8)
|
||||
for day in range(10):
|
||||
d = base_date + timedelta(days=day)
|
||||
# Random walk for probability
|
||||
base_prob = max(0.01, min(0.99, base_prob + RNG.normal(0, 0.03)))
|
||||
o = round(base_prob, 2)
|
||||
h = round(min(0.99, base_prob + abs(RNG.normal(0, 0.02))), 2)
|
||||
l = round(max(0.01, base_prob - abs(RNG.normal(0, 0.02))), 2)
|
||||
c = round(max(0.01, min(0.99, base_prob + RNG.normal(0, 0.01))), 2)
|
||||
vol = int(RNG.integers(50, 5001))
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"timestamp": d,
|
||||
"symbol": contract,
|
||||
"open": o,
|
||||
"high": h,
|
||||
"low": l,
|
||||
"close": c,
|
||||
"volume": vol,
|
||||
}
|
||||
)
|
||||
|
||||
df = (
|
||||
pl.DataFrame(rows)
|
||||
.cast(
|
||||
{
|
||||
"timestamp": pl.Date,
|
||||
"open": pl.Float64,
|
||||
"high": pl.Float64,
|
||||
"low": pl.Float64,
|
||||
"close": pl.Float64,
|
||||
"volume": pl.Int64,
|
||||
}
|
||||
)
|
||||
.sort(["symbol", "timestamp"])
|
||||
)
|
||||
|
||||
df.write_parquet(pm_dir / "kalshi_events.parquet")
|
||||
print(f" Kalshi events: {len(df)} rows -> {pm_dir / 'kalshi_events.parquet'}")
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# Main
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print(f"Generating test microstructure data in {TEST_DATA_ROOT}\n")
|
||||
|
||||
print("1. ITCH Parsed Messages")
|
||||
generate_itch_messages()
|
||||
|
||||
print("\n2. DataBento MBO")
|
||||
generate_mbo_data()
|
||||
|
||||
print("\n3. AlgoSeek TAQ")
|
||||
generate_taq_data()
|
||||
|
||||
print("\n4. IEX Parsed Data")
|
||||
generate_iex_data()
|
||||
|
||||
print("\n5. CME Individual Futures")
|
||||
generate_individual_futures()
|
||||
|
||||
print("\n6. Kalshi Prediction Markets")
|
||||
generate_kalshi_data()
|
||||
|
||||
print("\nDone.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,769 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from tests.pm_helpers import collect_chapter_notebooks, get_overrides
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_DB_PATH = REPO_ROOT / ".claude" / "work" / "notebook_testing" / "catalog.sqlite"
|
||||
|
||||
CASE_STUDIES = [
|
||||
"etfs",
|
||||
"crypto_perps_funding",
|
||||
"nasdaq100_microstructure",
|
||||
"sp500_equity_option_analytics",
|
||||
"us_firm_characteristics",
|
||||
"fx_pairs",
|
||||
"cme_futures",
|
||||
"sp500_options",
|
||||
"us_equities_panel",
|
||||
]
|
||||
|
||||
TRACKER_SCHEMA_COMPLETE_CHAPTERS = {1, 2, 8, 9, 10, 11, 12, 13, 14, 15}
|
||||
TRACKER_SCHEMA_IN_PROGRESS_CHAPTERS = {3, 4, 5, 6, 7, 16, 17, 18, 19, 20}
|
||||
TRACKER_SCHEMA_CASE_STUDIES = {
|
||||
"etfs": "complete",
|
||||
"fx_pairs": "complete",
|
||||
"crypto_perps_funding": "in_progress",
|
||||
"cme_futures": "pending",
|
||||
"nasdaq100_microstructure": "pending",
|
||||
"sp500_equity_option_analytics": "pending",
|
||||
"sp500_options": "pending",
|
||||
"us_equities_panel": "pending",
|
||||
"us_firm_characteristics": "pending",
|
||||
}
|
||||
|
||||
CRYPTO_REPRO_NOTE = (
|
||||
"Current Binance public downloads no longer reproduce MATICUSDT OHLCV. "
|
||||
"Crypto case study requires full refreshed model reruns and explicit old-vs-new "
|
||||
"comparison against the dev registry."
|
||||
)
|
||||
|
||||
HEAVY_KEYWORDS = {
|
||||
"timegan",
|
||||
"tailgan",
|
||||
"sigcwgan",
|
||||
"diffusion",
|
||||
"great",
|
||||
"dp_gan",
|
||||
"patchtst",
|
||||
"transformer",
|
||||
"lstm",
|
||||
"autoencoder",
|
||||
"finbert",
|
||||
"bert",
|
||||
"ner",
|
||||
"xgboost",
|
||||
"lightgbm",
|
||||
"catboost",
|
||||
"rl",
|
||||
"deepm",
|
||||
"gan",
|
||||
"backtest_sweep",
|
||||
}
|
||||
|
||||
GPU_KEYWORDS = {"gpu", "cuda", "torch", "tensorflow", "trainer"}
|
||||
|
||||
DATA_HINTS = {
|
||||
"etfs": ("etf", "etfs", "spy"),
|
||||
"crypto": ("crypto", "perp", "perps", "funding", "premium", "binance"),
|
||||
"fx": ("fx", "oanda", "eur_usd"),
|
||||
"futures": ("future", "futures", "cme", "databento", "glbx"),
|
||||
"us_equities": ("equities", "crsp", "stocks", "ticker", "secedgar"),
|
||||
"options": ("option", "options", "greeks", "iv"),
|
||||
"nasdaq_itch": ("itch", "nasdaq100", "algoseek", "taq", "lob", "iex"),
|
||||
"macro": ("macro", "fred", "yield", "calendar"),
|
||||
"text": ("text", "news", "filing", "sentiment", "word2vec"),
|
||||
"synthetic": ("synthetic", "simulation", "regime", "scenario"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class NotebookEntry:
|
||||
path: str
|
||||
notebook_key: str
|
||||
notebook_type: str
|
||||
chapter: int | None
|
||||
case_study_id: str | None
|
||||
stage: str | None
|
||||
stage_order: int | None
|
||||
title: str
|
||||
resource_profile: str
|
||||
execution_lane: str
|
||||
parallel_safe: int
|
||||
worker_slots: int
|
||||
gpu_required: int
|
||||
has_parameters_cell: int
|
||||
parameter_source: str
|
||||
default_timeout_seconds: int
|
||||
inputs_hint: str
|
||||
override_json: str
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(UTC).replace(microsecond=0).isoformat()
|
||||
|
||||
|
||||
def connect_catalog(db_path: Path | None = None) -> sqlite3.Connection:
|
||||
path = Path(db_path) if db_path else DEFAULT_DB_PATH
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
ensure_schema(conn)
|
||||
return conn
|
||||
|
||||
|
||||
def ensure_schema(conn: sqlite3.Connection) -> None:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS notebooks (
|
||||
path TEXT PRIMARY KEY,
|
||||
notebook_key TEXT NOT NULL UNIQUE,
|
||||
notebook_type TEXT NOT NULL,
|
||||
chapter INTEGER,
|
||||
case_study_id TEXT,
|
||||
stage TEXT,
|
||||
stage_order INTEGER,
|
||||
title TEXT NOT NULL,
|
||||
resource_profile TEXT NOT NULL,
|
||||
execution_lane TEXT NOT NULL,
|
||||
parallel_safe INTEGER NOT NULL DEFAULT 0,
|
||||
worker_slots INTEGER NOT NULL DEFAULT 1,
|
||||
gpu_required INTEGER NOT NULL DEFAULT 0,
|
||||
has_parameters_cell INTEGER NOT NULL DEFAULT 0,
|
||||
parameter_source TEXT NOT NULL DEFAULT 'none',
|
||||
default_timeout_seconds INTEGER NOT NULL DEFAULT 300,
|
||||
inputs_hint TEXT NOT NULL DEFAULT '',
|
||||
override_json TEXT NOT NULL DEFAULT '{}',
|
||||
last_inventory_at TEXT,
|
||||
last_status TEXT NOT NULL DEFAULT 'pending',
|
||||
last_execution_mode TEXT,
|
||||
last_runtime_seconds REAL,
|
||||
last_peak_memory_mb REAL,
|
||||
last_error_type TEXT,
|
||||
last_error_message TEXT,
|
||||
last_run_at TEXT,
|
||||
last_batch_id TEXT,
|
||||
notes TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notebook_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_id TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
notebook_type TEXT NOT NULL,
|
||||
chapter INTEGER,
|
||||
case_study_id TEXT,
|
||||
stage TEXT,
|
||||
status TEXT NOT NULL,
|
||||
execution_mode TEXT NOT NULL,
|
||||
runtime_seconds REAL,
|
||||
peak_memory_mb REAL,
|
||||
timeout_seconds INTEGER NOT NULL,
|
||||
worker_slots INTEGER NOT NULL,
|
||||
output_root TEXT,
|
||||
data_root TEXT,
|
||||
parameter_source TEXT NOT NULL,
|
||||
parameters_json TEXT NOT NULL DEFAULT '{}',
|
||||
error_type TEXT,
|
||||
error_message TEXT,
|
||||
log_path TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT NOT NULL,
|
||||
FOREIGN KEY(path) REFERENCES notebooks(path)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notebooks_type_chapter
|
||||
ON notebooks(notebook_type, chapter, case_study_id, stage_order);
|
||||
CREATE INDEX IF NOT EXISTS idx_notebooks_status
|
||||
ON notebooks(last_status, notebook_type, chapter);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_batch
|
||||
ON notebook_runs(batch_id, notebook_type, chapter, case_study_id, stage);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS program_tracker (
|
||||
item_key TEXT PRIMARY KEY,
|
||||
track TEXT NOT NULL,
|
||||
scope_type TEXT NOT NULL,
|
||||
scope_id TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL,
|
||||
required INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
status_source TEXT NOT NULL DEFAULT 'auto',
|
||||
metrics_json TEXT NOT NULL DEFAULT '{}',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_program_tracker_track
|
||||
ON program_tracker(track, scope_type, sort_order, scope_id);
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8", errors="ignore")
|
||||
|
||||
|
||||
def _has_parameters_cell(text: str) -> bool:
|
||||
tags = ('tags=["parameters"]', "tags=['parameters']", '"parameters"', "'parameters'")
|
||||
return any(tag in text for tag in tags)
|
||||
|
||||
|
||||
def _detect_inputs(text: str, rel_path: str) -> str:
|
||||
haystack = f"{rel_path.lower()} {text.lower()}"
|
||||
found = [name for name, markers in DATA_HINTS.items() if any(m in haystack for m in markers)]
|
||||
return ",".join(sorted(found))
|
||||
|
||||
|
||||
def _classify_entry(
|
||||
notebook_type: str,
|
||||
rel_path: str,
|
||||
text: str,
|
||||
chapter: int | None,
|
||||
stage_order: int | None,
|
||||
overrides: dict,
|
||||
) -> tuple[str, str, int, int]:
|
||||
key = rel_path.lower()
|
||||
haystack = f"{key} {text.lower()}"
|
||||
|
||||
if notebook_type == "case_study":
|
||||
if overrides.get("gpu") or any(k in haystack for k in GPU_KEYWORDS):
|
||||
return "pipeline_gpu", "serial_case_study", 0, 4
|
||||
if stage_order is not None and stage_order >= 11:
|
||||
return "pipeline_model", "serial_case_study", 0, 3
|
||||
if stage_order is not None and stage_order >= 6:
|
||||
return "pipeline_feature", "serial_case_study", 0, 2
|
||||
return "pipeline_setup", "serial_case_study", 0, 1
|
||||
|
||||
if overrides.get("gpu") or any(k in haystack for k in GPU_KEYWORDS):
|
||||
return "gpu", "serial_heavy", 0, 4
|
||||
|
||||
if any(k in haystack for k in HEAVY_KEYWORDS):
|
||||
return "heavy", "serial_heavy", 0, 3
|
||||
|
||||
if chapter is not None and chapter <= 6 and not overrides.get("parameters"):
|
||||
return "light", "parallel_light", 1, 1
|
||||
|
||||
if chapter is not None and chapter <= 10:
|
||||
return "medium", "parallel_medium", 1, 2
|
||||
|
||||
if overrides.get("parameters"):
|
||||
return "medium", "parallel_medium", 1, 2
|
||||
|
||||
return "heavy", "serial_heavy", 0, 3
|
||||
|
||||
|
||||
def _default_timeout(overrides: dict) -> int:
|
||||
return int(overrides.get("timeout", 300))
|
||||
|
||||
|
||||
def _parameter_source(overrides: dict, has_parameters_cell: bool) -> str:
|
||||
if overrides.get("parameters"):
|
||||
return "papermill"
|
||||
if has_parameters_cell:
|
||||
return "none"
|
||||
return "config"
|
||||
|
||||
|
||||
def _chapter_entries(repo_root: Path) -> list[NotebookEntry]:
|
||||
entries: list[NotebookEntry] = []
|
||||
for path in collect_chapter_notebooks(repo_root, range(1, 28)):
|
||||
rel = path.relative_to(repo_root)
|
||||
notebook_key = str(rel.with_suffix("")).replace(os.sep, "/")
|
||||
text = _read_text(path)
|
||||
overrides = get_overrides(notebook_key)
|
||||
chapter = int(rel.parts[0][:2])
|
||||
has_parameters_cell = int(_has_parameters_cell(text))
|
||||
resource_profile, execution_lane, parallel_safe, worker_slots = _classify_entry(
|
||||
"chapter", rel.as_posix(), text, chapter, None, overrides
|
||||
)
|
||||
entries.append(
|
||||
NotebookEntry(
|
||||
path=rel.as_posix(),
|
||||
notebook_key=notebook_key,
|
||||
notebook_type="chapter",
|
||||
chapter=chapter,
|
||||
case_study_id=None,
|
||||
stage=None,
|
||||
stage_order=None,
|
||||
title=path.stem,
|
||||
resource_profile=resource_profile,
|
||||
execution_lane=execution_lane,
|
||||
parallel_safe=parallel_safe,
|
||||
worker_slots=worker_slots,
|
||||
gpu_required=int(bool(overrides.get("gpu"))),
|
||||
has_parameters_cell=has_parameters_cell,
|
||||
parameter_source=_parameter_source(overrides, bool(has_parameters_cell)),
|
||||
default_timeout_seconds=_default_timeout(overrides),
|
||||
inputs_hint=_detect_inputs(text, rel.as_posix()),
|
||||
override_json=json.dumps(overrides, sort_keys=True),
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _case_study_entries(repo_root: Path) -> list[NotebookEntry]:
|
||||
entries: list[NotebookEntry] = []
|
||||
for case_study_id in CASE_STUDIES:
|
||||
cs_dir = repo_root / "case_studies" / case_study_id
|
||||
if not cs_dir.exists():
|
||||
continue
|
||||
for path in sorted(cs_dir.glob("[0-9][0-9]_*.py")):
|
||||
if path.name.startswith("_"):
|
||||
continue
|
||||
rel = path.relative_to(repo_root)
|
||||
notebook_key = str(rel.with_suffix("")).replace(os.sep, "/")
|
||||
text = _read_text(path)
|
||||
overrides = get_overrides(notebook_key)
|
||||
stage = path.stem
|
||||
stage_order = int(stage[:2]) if stage[:2].isdigit() else None
|
||||
has_parameters_cell = int(_has_parameters_cell(text))
|
||||
resource_profile, execution_lane, parallel_safe, worker_slots = _classify_entry(
|
||||
"case_study", rel.as_posix(), text, None, stage_order, overrides
|
||||
)
|
||||
entries.append(
|
||||
NotebookEntry(
|
||||
path=rel.as_posix(),
|
||||
notebook_key=notebook_key,
|
||||
notebook_type="case_study",
|
||||
chapter=None,
|
||||
case_study_id=case_study_id,
|
||||
stage=stage,
|
||||
stage_order=stage_order,
|
||||
title=path.stem,
|
||||
resource_profile=resource_profile,
|
||||
execution_lane=execution_lane,
|
||||
parallel_safe=parallel_safe,
|
||||
worker_slots=worker_slots,
|
||||
gpu_required=int(bool(overrides.get("gpu"))),
|
||||
has_parameters_cell=has_parameters_cell,
|
||||
parameter_source=_parameter_source(overrides, bool(has_parameters_cell)),
|
||||
default_timeout_seconds=_default_timeout(overrides),
|
||||
inputs_hint=_detect_inputs(text, rel.as_posix()),
|
||||
override_json=json.dumps(overrides, sort_keys=True),
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def build_inventory(repo_root: Path | None = None) -> list[NotebookEntry]:
|
||||
root = repo_root or REPO_ROOT
|
||||
return _chapter_entries(root) + _case_study_entries(root)
|
||||
|
||||
|
||||
def upsert_inventory(conn: sqlite3.Connection, entries: list[NotebookEntry]) -> None:
|
||||
now = utc_now()
|
||||
current_paths = [entry.path for entry in entries]
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO notebooks (
|
||||
path,
|
||||
notebook_key,
|
||||
notebook_type,
|
||||
chapter,
|
||||
case_study_id,
|
||||
stage,
|
||||
stage_order,
|
||||
title,
|
||||
resource_profile,
|
||||
execution_lane,
|
||||
parallel_safe,
|
||||
worker_slots,
|
||||
gpu_required,
|
||||
has_parameters_cell,
|
||||
parameter_source,
|
||||
default_timeout_seconds,
|
||||
inputs_hint,
|
||||
override_json,
|
||||
last_inventory_at
|
||||
) VALUES (
|
||||
:path,
|
||||
:notebook_key,
|
||||
:notebook_type,
|
||||
:chapter,
|
||||
:case_study_id,
|
||||
:stage,
|
||||
:stage_order,
|
||||
:title,
|
||||
:resource_profile,
|
||||
:execution_lane,
|
||||
:parallel_safe,
|
||||
:worker_slots,
|
||||
:gpu_required,
|
||||
:has_parameters_cell,
|
||||
:parameter_source,
|
||||
:default_timeout_seconds,
|
||||
:inputs_hint,
|
||||
:override_json,
|
||||
:last_inventory_at
|
||||
)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
notebook_key=excluded.notebook_key,
|
||||
notebook_type=excluded.notebook_type,
|
||||
chapter=excluded.chapter,
|
||||
case_study_id=excluded.case_study_id,
|
||||
stage=excluded.stage,
|
||||
stage_order=excluded.stage_order,
|
||||
title=excluded.title,
|
||||
resource_profile=excluded.resource_profile,
|
||||
execution_lane=excluded.execution_lane,
|
||||
parallel_safe=excluded.parallel_safe,
|
||||
worker_slots=excluded.worker_slots,
|
||||
gpu_required=excluded.gpu_required,
|
||||
has_parameters_cell=excluded.has_parameters_cell,
|
||||
parameter_source=excluded.parameter_source,
|
||||
default_timeout_seconds=excluded.default_timeout_seconds,
|
||||
inputs_hint=excluded.inputs_hint,
|
||||
override_json=excluded.override_json,
|
||||
last_inventory_at=excluded.last_inventory_at
|
||||
""",
|
||||
[
|
||||
{
|
||||
**asdict(entry),
|
||||
"last_inventory_at": now,
|
||||
}
|
||||
for entry in entries
|
||||
],
|
||||
)
|
||||
if current_paths:
|
||||
marks = ",".join("?" for _ in current_paths)
|
||||
stale_paths = [
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
f"SELECT path FROM notebooks WHERE path NOT IN ({marks})", current_paths
|
||||
).fetchall()
|
||||
]
|
||||
if stale_paths:
|
||||
stale_marks = ",".join("?" for _ in stale_paths)
|
||||
conn.execute(f"DELETE FROM notebook_runs WHERE path IN ({stale_marks})", stale_paths)
|
||||
conn.execute(f"DELETE FROM notebooks WHERE path NOT IN ({marks})", current_paths)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def refresh_inventory(
|
||||
conn: sqlite3.Connection, repo_root: Path | None = None
|
||||
) -> list[NotebookEntry]:
|
||||
entries = build_inventory(repo_root)
|
||||
upsert_inventory(conn, entries)
|
||||
return entries
|
||||
|
||||
|
||||
def resolve_data_root(repo_root: Path | None = None) -> Path | None:
|
||||
root = repo_root or REPO_ROOT
|
||||
env_data = os.environ.get("ML4T_DATA_PATH")
|
||||
if env_data:
|
||||
path = Path(env_data).expanduser()
|
||||
if path.exists():
|
||||
return path
|
||||
|
||||
env_file = root / ".env"
|
||||
if not env_file.exists():
|
||||
return None
|
||||
|
||||
for line in env_file.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
if key.strip() == "ML4T_DATA_PATH":
|
||||
path = Path(value.strip().strip('"').strip("'")).expanduser()
|
||||
if path.exists():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def latest_status_counts(conn: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
return conn.execute(
|
||||
"""
|
||||
SELECT notebook_type, last_status, COUNT(*) AS n
|
||||
FROM notebooks
|
||||
GROUP BY notebook_type, last_status
|
||||
ORDER BY notebook_type, last_status
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
|
||||
def _latest_notebook_rows(conn: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
return conn.execute(
|
||||
"""
|
||||
WITH latest AS (
|
||||
SELECT
|
||||
nr.*,
|
||||
ROW_NUMBER() OVER (PARTITION BY path ORDER BY id DESC) AS rn
|
||||
FROM notebook_runs nr
|
||||
)
|
||||
SELECT
|
||||
n.path,
|
||||
n.notebook_type,
|
||||
n.chapter,
|
||||
n.case_study_id,
|
||||
n.stage_order,
|
||||
COALESCE(l.status, 'pending') AS status,
|
||||
l.runtime_seconds,
|
||||
l.peak_memory_mb
|
||||
FROM notebooks n
|
||||
LEFT JOIN latest l
|
||||
ON n.path = l.path
|
||||
AND l.rn = 1
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
|
||||
def _rollup_status(statuses: list[str]) -> str:
|
||||
if not statuses:
|
||||
return "pending"
|
||||
unique = set(statuses)
|
||||
if unique == {"ok"}:
|
||||
return "complete"
|
||||
if unique == {"pending"}:
|
||||
return "pending"
|
||||
if {"error", "blocked", "skipped"} & unique:
|
||||
return "blocked"
|
||||
if "ok" in unique and "pending" in unique:
|
||||
return "in_progress"
|
||||
if "ok" in unique:
|
||||
return "in_progress"
|
||||
return "pending"
|
||||
|
||||
|
||||
def _functional_chapter_metrics(
|
||||
rows: list[sqlite3.Row], chapter: int
|
||||
) -> tuple[str, dict[str, int]]:
|
||||
chapter_rows = [
|
||||
row for row in rows if row["notebook_type"] == "chapter" and row["chapter"] == chapter
|
||||
]
|
||||
counts: dict[str, int] = {}
|
||||
for row in chapter_rows:
|
||||
counts[row["status"]] = counts.get(row["status"], 0) + 1
|
||||
return _rollup_status([row["status"] for row in chapter_rows]), {
|
||||
"total": len(chapter_rows),
|
||||
**counts,
|
||||
}
|
||||
|
||||
|
||||
def _functional_case_study_metrics(
|
||||
rows: list[sqlite3.Row], case_study_id: str, max_stage: int = 5
|
||||
) -> tuple[str, dict[str, int]]:
|
||||
cs_rows = [
|
||||
row
|
||||
for row in rows
|
||||
if row["notebook_type"] == "case_study"
|
||||
and row["case_study_id"] == case_study_id
|
||||
and row["stage_order"] is not None
|
||||
and row["stage_order"] <= max_stage
|
||||
]
|
||||
counts: dict[str, int] = {}
|
||||
for row in cs_rows:
|
||||
counts[row["status"]] = counts.get(row["status"], 0) + 1
|
||||
return _rollup_status([row["status"] for row in cs_rows]), {
|
||||
"total": len(cs_rows),
|
||||
**counts,
|
||||
}
|
||||
|
||||
|
||||
def _schema_status_for_chapter(chapter: int) -> str:
|
||||
if chapter in TRACKER_SCHEMA_COMPLETE_CHAPTERS:
|
||||
return "complete"
|
||||
if chapter in TRACKER_SCHEMA_IN_PROGRESS_CHAPTERS:
|
||||
return "in_progress"
|
||||
return "pending"
|
||||
|
||||
|
||||
def _schema_status_for_case_study(case_study_id: str) -> str:
|
||||
return TRACKER_SCHEMA_CASE_STUDIES.get(case_study_id, "pending")
|
||||
|
||||
|
||||
def sync_program_tracker(conn: sqlite3.Connection) -> None:
|
||||
rows = _latest_notebook_rows(conn)
|
||||
now = utc_now()
|
||||
tracker_rows: list[dict[str, object]] = [
|
||||
{
|
||||
"item_key": "foundation:catalog",
|
||||
"track": "foundation",
|
||||
"scope_type": "foundation",
|
||||
"scope_id": "catalog",
|
||||
"label": "Notebook catalog and Docker runner",
|
||||
"sort_order": 0,
|
||||
"required": 1,
|
||||
"status": "complete",
|
||||
"status_source": "manual",
|
||||
"metrics_json": json.dumps({}, sort_keys=True),
|
||||
"notes": "SQLite catalog, Docker runner, and isolated-output execution are in place.",
|
||||
"updated_at": now,
|
||||
}
|
||||
]
|
||||
|
||||
for chapter in range(1, 21):
|
||||
functional_status, functional_metrics = _functional_chapter_metrics(rows, chapter)
|
||||
tracker_rows.append(
|
||||
{
|
||||
"item_key": f"chapter:{chapter:02d}:functional",
|
||||
"track": "functional",
|
||||
"scope_type": "chapter",
|
||||
"scope_id": f"{chapter:02d}",
|
||||
"label": f"Chapter {chapter:02d} functional correctness",
|
||||
"sort_order": chapter,
|
||||
"required": 1,
|
||||
"status": functional_status,
|
||||
"status_source": "auto",
|
||||
"metrics_json": json.dumps(functional_metrics, sort_keys=True),
|
||||
"notes": "",
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
tracker_rows.append(
|
||||
{
|
||||
"item_key": f"chapter:{chapter:02d}:schema",
|
||||
"track": "schema",
|
||||
"scope_type": "chapter",
|
||||
"scope_id": f"{chapter:02d}",
|
||||
"label": f"Chapter {chapter:02d} canonical schema retrofit",
|
||||
"sort_order": chapter,
|
||||
"required": 1,
|
||||
"status": _schema_status_for_chapter(chapter),
|
||||
"status_source": "manual",
|
||||
"metrics_json": json.dumps({}, sort_keys=True),
|
||||
"notes": "",
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
repro_required = int(chapter >= 11)
|
||||
repro_status = "pending" if repro_required else "not_required"
|
||||
repro_notes = (
|
||||
"Required for model/results notebooks and any book-facing figures or reported results."
|
||||
if repro_required
|
||||
else "Teaching notebooks default to functional-only unless book-facing outputs require parity."
|
||||
)
|
||||
tracker_rows.append(
|
||||
{
|
||||
"item_key": f"chapter:{chapter:02d}:repro",
|
||||
"track": "repro",
|
||||
"scope_type": "chapter",
|
||||
"scope_id": f"{chapter:02d}",
|
||||
"label": f"Chapter {chapter:02d} dev-registry reproducibility validation",
|
||||
"sort_order": chapter,
|
||||
"required": repro_required,
|
||||
"status": repro_status,
|
||||
"status_source": "manual",
|
||||
"metrics_json": json.dumps({}, sort_keys=True),
|
||||
"notes": repro_notes,
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
|
||||
for idx, case_study_id in enumerate(CASE_STUDIES, start=1):
|
||||
functional_status, functional_metrics = _functional_case_study_metrics(rows, case_study_id)
|
||||
tracker_rows.append(
|
||||
{
|
||||
"item_key": f"case_study:{case_study_id}:functional_1_5",
|
||||
"track": "functional",
|
||||
"scope_type": "case_study",
|
||||
"scope_id": case_study_id,
|
||||
"label": f"{case_study_id} stages 1-5 functional correctness",
|
||||
"sort_order": idx,
|
||||
"required": 1,
|
||||
"status": functional_status,
|
||||
"status_source": "auto",
|
||||
"metrics_json": json.dumps(functional_metrics, sort_keys=True),
|
||||
"notes": "",
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
tracker_rows.append(
|
||||
{
|
||||
"item_key": f"case_study:{case_study_id}:schema_1_5",
|
||||
"track": "schema",
|
||||
"scope_type": "case_study",
|
||||
"scope_id": case_study_id,
|
||||
"label": f"{case_study_id} early-stage canonical schema retrofit",
|
||||
"sort_order": idx,
|
||||
"required": 1,
|
||||
"status": _schema_status_for_case_study(case_study_id),
|
||||
"status_source": "manual",
|
||||
"metrics_json": json.dumps({}, sort_keys=True),
|
||||
"notes": "",
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
tracker_rows.append(
|
||||
{
|
||||
"item_key": f"case_study:{case_study_id}:repro",
|
||||
"track": "repro",
|
||||
"scope_type": "case_study",
|
||||
"scope_id": case_study_id,
|
||||
"label": f"{case_study_id} dev-registry reproducibility validation",
|
||||
"sort_order": idx,
|
||||
"required": 1,
|
||||
"status": "pending",
|
||||
"status_source": "manual",
|
||||
"metrics_json": json.dumps({}, sort_keys=True),
|
||||
"notes": CRYPTO_REPRO_NOTE if case_study_id == "crypto_perps_funding" else "",
|
||||
"updated_at": now,
|
||||
}
|
||||
)
|
||||
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO program_tracker (
|
||||
item_key,
|
||||
track,
|
||||
scope_type,
|
||||
scope_id,
|
||||
label,
|
||||
sort_order,
|
||||
required,
|
||||
status,
|
||||
status_source,
|
||||
metrics_json,
|
||||
notes,
|
||||
updated_at
|
||||
) VALUES (
|
||||
:item_key,
|
||||
:track,
|
||||
:scope_type,
|
||||
:scope_id,
|
||||
:label,
|
||||
:sort_order,
|
||||
:required,
|
||||
:status,
|
||||
:status_source,
|
||||
:metrics_json,
|
||||
:notes,
|
||||
:updated_at
|
||||
)
|
||||
ON CONFLICT(item_key) DO UPDATE SET
|
||||
track=excluded.track,
|
||||
scope_type=excluded.scope_type,
|
||||
scope_id=excluded.scope_id,
|
||||
label=excluded.label,
|
||||
sort_order=excluded.sort_order,
|
||||
required=excluded.required,
|
||||
status=excluded.status,
|
||||
status_source=excluded.status_source,
|
||||
metrics_json=excluded.metrics_json,
|
||||
notes=excluded.notes,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
tracker_rows,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def tracker_status_counts(conn: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
return conn.execute(
|
||||
"""
|
||||
SELECT track, status, COUNT(*) AS n
|
||||
FROM program_tracker
|
||||
GROUP BY track, status
|
||||
ORDER BY track, status
|
||||
"""
|
||||
).fetchall()
|
||||
@@ -0,0 +1,189 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _sync_if_needed(py_path: Path, sync_policy: str) -> Path:
|
||||
ipynb_path = py_path.with_suffix(".ipynb")
|
||||
should_sync = sync_policy == "always" or (sync_policy == "missing" and not ipynb_path.exists())
|
||||
if should_sync:
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"jupytext",
|
||||
"--to",
|
||||
"notebook",
|
||||
"--set-kernel",
|
||||
"python3",
|
||||
"--output",
|
||||
str(ipynb_path),
|
||||
str(py_path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
cwd=str(
|
||||
py_path.parent.parent
|
||||
if py_path.parent.name.startswith("case_studies")
|
||||
else py_path.parent
|
||||
),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Jupytext sync failed for {py_path}: {result.stderr}")
|
||||
if not ipynb_path.exists():
|
||||
raise FileNotFoundError(f"Expected .ipynb not found for {py_path}")
|
||||
return ipynb_path
|
||||
|
||||
|
||||
def _run_full_notebook(
|
||||
py_path: Path,
|
||||
timeout: int,
|
||||
output_dir: Path | None,
|
||||
data_dir: Path | None,
|
||||
extra_env: dict[str, str],
|
||||
sync_policy: str,
|
||||
) -> dict:
|
||||
import papermill as pm
|
||||
|
||||
start = time.perf_counter()
|
||||
ipynb_path = _sync_if_needed(py_path, sync_policy)
|
||||
tmp_out = Path(tempfile.gettempdir()) / f"ml4t-full-{os.getpid()}-{py_path.stem}.ipynb"
|
||||
|
||||
saved_env: dict[str, str | None] = {}
|
||||
rc_dir = Path(tempfile.mkdtemp(prefix="ml4t-mplrc-"))
|
||||
rc_file = rc_dir / "matplotlibrc"
|
||||
rc_file.write_text("figure.constrained_layout.use: False\n", encoding="utf-8")
|
||||
env_vars = {
|
||||
"MPLBACKEND": "Agg",
|
||||
"PLOTLY_RENDERER": "json",
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
"MATPLOTLIBRC": str(rc_file),
|
||||
}
|
||||
if output_dir:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
env_vars["ML4T_OUTPUT_DIR"] = str(output_dir)
|
||||
if data_dir:
|
||||
env_vars["ML4T_DATA_PATH"] = str(data_dir)
|
||||
env_vars.update({key: str(value) for key, value in extra_env.items()})
|
||||
|
||||
existing = os.environ.get("PYTHONPATH", "")
|
||||
repo_root = py_path.parents[2] if "case_studies" in py_path.parts else py_path.parent.parent
|
||||
nb_dir = str(py_path.parent.resolve())
|
||||
env_vars["PYTHONPATH"] = (
|
||||
f"{repo_root}:{nb_dir}:{existing}" if existing else f"{repo_root}:{nb_dir}"
|
||||
)
|
||||
|
||||
try:
|
||||
import torch
|
||||
|
||||
torch_lib = str(Path(torch.__file__).parent / "lib")
|
||||
nvidia_libs = list((Path(torch.__file__).parent.parent / "nvidia").glob("*/lib"))
|
||||
cuda_paths = [torch_lib] + [str(p) for p in nvidia_libs]
|
||||
existing_ld = os.environ.get("LD_LIBRARY_PATH", "")
|
||||
env_vars["LD_LIBRARY_PATH"] = ":".join(cuda_paths + [existing_ld])
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
for key, value in env_vars.items():
|
||||
saved_env[key] = os.environ.get(key)
|
||||
os.environ[key] = value
|
||||
|
||||
try:
|
||||
pm.execute_notebook(
|
||||
str(ipynb_path),
|
||||
str(tmp_out),
|
||||
parameters={},
|
||||
cwd=str(repo_root),
|
||||
kernel_name="python3",
|
||||
execution_timeout=timeout,
|
||||
request_save_on_cell_execute=True,
|
||||
progress_bar=False,
|
||||
log_output=True,
|
||||
)
|
||||
shutil.copy2(tmp_out, ipynb_path)
|
||||
return {"status": "ok", "error": None, "runtime_seconds": time.perf_counter() - start}
|
||||
except pm.PapermillExecutionError as exc:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"Cell {exc.cell_index} ({exc.ename}): {exc.evalue}",
|
||||
"runtime_seconds": time.perf_counter() - start,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": str(exc),
|
||||
"runtime_seconds": time.perf_counter() - start,
|
||||
}
|
||||
finally:
|
||||
for key, value in saved_env.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
tmp_out.unlink(missing_ok=True)
|
||||
shutil.rmtree(rc_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--path", required=True)
|
||||
parser.add_argument("--timeout", type=int, required=True)
|
||||
parser.add_argument("--output-dir")
|
||||
parser.add_argument("--result-file", required=True)
|
||||
parser.add_argument("--data-dir")
|
||||
parser.add_argument("--parameters-json", default="{}")
|
||||
parser.add_argument("--env-json", default="{}")
|
||||
parser.add_argument("--execution-mode", choices=["reduced", "full"], default="reduced")
|
||||
parser.add_argument("--sync-policy", choices=["always", "missing", "never"], default="always")
|
||||
args = parser.parse_args()
|
||||
|
||||
path = Path(args.path).resolve()
|
||||
output_dir = Path(args.output_dir).resolve() if args.output_dir else None
|
||||
result_file = Path(args.result_file).resolve()
|
||||
params = json.loads(args.parameters_json)
|
||||
extra_env = json.loads(args.env_json)
|
||||
data_dir = Path(args.data_dir).resolve() if args.data_dir else None
|
||||
|
||||
started = time.perf_counter()
|
||||
if args.execution_mode == "full":
|
||||
result = _run_full_notebook(
|
||||
py_path=path,
|
||||
timeout=args.timeout,
|
||||
output_dir=output_dir,
|
||||
data_dir=data_dir,
|
||||
extra_env=extra_env,
|
||||
sync_policy=args.sync_policy,
|
||||
)
|
||||
else:
|
||||
from tests.pm_helpers import run_notebook
|
||||
|
||||
result = run_notebook(
|
||||
py_path=path,
|
||||
parameters=params,
|
||||
timeout=args.timeout,
|
||||
output_dir=output_dir,
|
||||
data_dir=data_dir,
|
||||
extra_env=extra_env,
|
||||
)
|
||||
elapsed = time.perf_counter() - started
|
||||
|
||||
payload = {
|
||||
"status": result.get("status", "error"),
|
||||
"error": result.get("error"),
|
||||
"runtime_seconds": elapsed,
|
||||
}
|
||||
result_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
result_file.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,415 @@
|
||||
"""Papermill-based notebook execution helpers.
|
||||
|
||||
Provides:
|
||||
- run_notebook(): Execute a .py notebook via Papermill with parameter injection
|
||||
- get_overrides(): Load per-notebook overrides from tests/overrides.yaml
|
||||
- collect_chapter_notebooks(): Discover notebooks in chapter directories
|
||||
- get_tier() / current_test_tier(): Test-tier routing (per-commit / weekly / on-demand)
|
||||
- get_record_mode(): VCR cassette mode (consumed by Step 5)
|
||||
|
||||
NOTE: Notebooks live directly in chapter directories
|
||||
(e.g., 05_synthetic_data/01_timegan.py), NOT in code/ subdirs.
|
||||
|
||||
overrides.yaml schema (per-notebook, all optional):
|
||||
timeout: int (seconds, default 300)
|
||||
parameters: dict (papermill -p overrides)
|
||||
skip: bool — hard skip in uv-native run (Docker tests ignore)
|
||||
skip_reason: str
|
||||
requires_import: str | list[str]
|
||||
gpu: bool
|
||||
long_running: bool
|
||||
docker_env: str — informational (e.g., "benchmark")
|
||||
tier: "per_commit" | "weekly" | "on_demand" — default "per_commit"
|
||||
Per-commit runs the Tests workflow on every PR/push.
|
||||
Weekly runs the weekly-external scheduled workflow (Step 2).
|
||||
On_demand runs only on manual dispatch (GPU-only NBs).
|
||||
reruns: int — flaky-retry count (consumed once pytest-rerunfailures lands,
|
||||
Step 2). Default 0.
|
||||
record_mode: "replay" | "rewrite" — VCR cassette mode (consumed by Step 5).
|
||||
Default "replay".
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
OVERRIDES_PATH = REPO_ROOT / "tests" / "overrides.yaml"
|
||||
|
||||
# Cache loaded overrides
|
||||
_overrides_cache: dict | None = None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test tier — controls when a notebook runs in CI.
|
||||
# Per-commit (default): every PR / push triggers the Tests workflow.
|
||||
# Weekly: only the scheduled weekly-external workflow (Mon 06:00 UTC).
|
||||
# On-demand: only manual workflow_dispatch (e.g., GPU-only Tier 3).
|
||||
# ---------------------------------------------------------------------------
|
||||
TIER_PER_COMMIT = "per_commit"
|
||||
TIER_WEEKLY = "weekly"
|
||||
TIER_ON_DEMAND = "on_demand"
|
||||
VALID_TIERS = frozenset({TIER_PER_COMMIT, TIER_WEEKLY, TIER_ON_DEMAND})
|
||||
|
||||
# VCR cassette modes (Step 5: pytest-recording).
|
||||
RECORD_REPLAY = "replay"
|
||||
RECORD_REWRITE = "rewrite"
|
||||
VALID_RECORD_MODES = frozenset({RECORD_REPLAY, RECORD_REWRITE})
|
||||
|
||||
|
||||
def get_tier(overrides: dict) -> str:
|
||||
"""Return the test tier declared by overrides (default: per_commit)."""
|
||||
tier = overrides.get("tier") or TIER_PER_COMMIT
|
||||
if tier not in VALID_TIERS:
|
||||
raise ValueError(
|
||||
f"Invalid tier {tier!r} in overrides — must be one of {sorted(VALID_TIERS)}"
|
||||
)
|
||||
return tier
|
||||
|
||||
|
||||
def current_test_tier() -> str:
|
||||
"""Return the tier the current pytest run is targeting.
|
||||
|
||||
Read from ML4T_TEST_TIER env var; defaults to per_commit so existing
|
||||
workflows that don't set it keep their current behavior (only NBs
|
||||
without a tier key — i.e., tier=per_commit — execute).
|
||||
"""
|
||||
tier = os.environ.get("ML4T_TEST_TIER") or TIER_PER_COMMIT
|
||||
if tier not in VALID_TIERS:
|
||||
raise ValueError(f"Invalid ML4T_TEST_TIER={tier!r} — must be one of {sorted(VALID_TIERS)}")
|
||||
return tier
|
||||
|
||||
|
||||
def get_reruns(overrides: dict) -> int:
|
||||
"""Return per-notebook flaky retry count (default 0).
|
||||
|
||||
Consumed by Step 2 (pytest-rerunfailures dep + collection hook adds
|
||||
@pytest.mark.flaky(reruns=N) when N > 0). Until that lands the value
|
||||
is parsed but no retries happen.
|
||||
"""
|
||||
val = overrides.get("reruns", 0)
|
||||
if not isinstance(val, int) or val < 0:
|
||||
raise ValueError(f"Invalid reruns={val!r} — must be non-negative int")
|
||||
return val
|
||||
|
||||
|
||||
def get_record_mode(overrides: dict) -> str:
|
||||
"""Return VCR cassette mode for the notebook (default: replay)."""
|
||||
mode = overrides.get("record_mode") or RECORD_REPLAY
|
||||
if mode not in VALID_RECORD_MODES:
|
||||
raise ValueError(
|
||||
f"Invalid record_mode {mode!r} — must be one of {sorted(VALID_RECORD_MODES)}"
|
||||
)
|
||||
return mode
|
||||
|
||||
|
||||
def get_overrides(notebook_key: str) -> dict:
|
||||
"""Get parameter overrides for a notebook from tests/overrides.yaml.
|
||||
|
||||
Args:
|
||||
notebook_key: Notebook path relative to repo root, no extension.
|
||||
e.g., "05_synthetic_data/02_tailgan_tail_risk"
|
||||
|
||||
Returns:
|
||||
Dict with optional keys: timeout, gpu, parameters
|
||||
"""
|
||||
global _overrides_cache
|
||||
if _overrides_cache is None:
|
||||
if OVERRIDES_PATH.exists():
|
||||
with open(OVERRIDES_PATH) as f:
|
||||
_overrides_cache = yaml.safe_load(f) or {}
|
||||
else:
|
||||
_overrides_cache = {}
|
||||
|
||||
return _overrides_cache.get(notebook_key) or {}
|
||||
|
||||
|
||||
def sync_notebook(py_path: Path) -> Path:
|
||||
"""Sync a .py notebook to a temporary .ipynb via Jupytext.
|
||||
|
||||
Writes to a temp file so the real .ipynb (which may contain pre-executed
|
||||
outputs) is never overwritten.
|
||||
|
||||
Args:
|
||||
py_path: Path to the .py source file
|
||||
|
||||
Returns:
|
||||
Path to a temporary .ipynb file (caller must clean up)
|
||||
"""
|
||||
# Write to a temp file — never touch the real .ipynb
|
||||
tmp_fd, tmp_path_str = tempfile.mkstemp(suffix=".ipynb", prefix=f"_pm_{py_path.stem}_")
|
||||
os.close(tmp_fd)
|
||||
tmp_ipynb = Path(tmp_path_str)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"jupytext",
|
||||
"--to",
|
||||
"notebook",
|
||||
"--set-kernel",
|
||||
"python3",
|
||||
"--output",
|
||||
str(tmp_ipynb),
|
||||
str(py_path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
cwd=str(REPO_ROOT),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
tmp_ipynb.unlink(missing_ok=True)
|
||||
raise RuntimeError(f"Jupytext sync failed for {py_path}: {result.stderr}")
|
||||
|
||||
if not tmp_ipynb.exists():
|
||||
raise FileNotFoundError(f"Expected temp .ipynb not found after sync: {tmp_ipynb}")
|
||||
|
||||
return tmp_ipynb
|
||||
|
||||
|
||||
def run_notebook(
|
||||
py_path: Path,
|
||||
parameters: dict | None = None,
|
||||
timeout: int = 300,
|
||||
output_dir: Path | None = None,
|
||||
data_dir: Path | None = None,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
log_path: Path | None = None,
|
||||
cwd: Path | None = None,
|
||||
) -> dict:
|
||||
"""Execute a notebook via Papermill with parameter injection.
|
||||
|
||||
This is the core test helper. It:
|
||||
1. Syncs .py -> .ipynb via Jupytext
|
||||
2. Executes via Papermill with parameter overrides
|
||||
3. Logs per-cell progress to log_path (if provided)
|
||||
4. Returns status and error info
|
||||
|
||||
Args:
|
||||
py_path: Path to the .py notebook source
|
||||
parameters: Dict of parameters to inject (overrides defaults in parameters cell)
|
||||
timeout: Per-cell timeout in seconds
|
||||
output_dir: Directory for ML4T_OUTPUT_DIR (redirects saves to temp)
|
||||
data_dir: Directory for ML4T_DATA_PATH (test data location)
|
||||
extra_env: Additional environment variables for notebook execution
|
||||
log_path: Path to progress log file (appended to)
|
||||
|
||||
Returns:
|
||||
Dict with keys: status ("ok" or "error"), error (str if failed),
|
||||
duration_s (float), n_cells (int)
|
||||
"""
|
||||
import time
|
||||
|
||||
import papermill as pm
|
||||
|
||||
start = time.time()
|
||||
nb_name = py_path.stem
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
if log_path:
|
||||
with open(log_path, "a") as f:
|
||||
f.write(f"[{time.strftime('%H:%M:%S')}] {msg}\n")
|
||||
f.flush()
|
||||
|
||||
_log(f"START {nb_name} (timeout={timeout}s)")
|
||||
|
||||
# Sync to a temp .ipynb (never overwrites the real .ipynb)
|
||||
tmp_ipynb: Path | None = None
|
||||
try:
|
||||
tmp_ipynb = sync_notebook(py_path)
|
||||
except (RuntimeError, FileNotFoundError) as e:
|
||||
_log(f"SYNC_FAIL {nb_name}: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"Jupytext sync failed: {e}",
|
||||
"duration_s": time.time() - start,
|
||||
"n_cells": 0,
|
||||
}
|
||||
|
||||
ipynb_path = tmp_ipynb
|
||||
|
||||
# Executed notebook output path
|
||||
executed_path = py_path.parent / f"_executed_{py_path.stem}.ipynb"
|
||||
|
||||
# Setup environment - Papermill's execute_notebook inherits os.environ,
|
||||
# so we temporarily set env vars and restore them after execution.
|
||||
saved_env = {}
|
||||
env_vars = {
|
||||
"MPLBACKEND": "Agg",
|
||||
"PLOTLY_RENDERER": "json",
|
||||
"DISABLE_HPO": "1",
|
||||
}
|
||||
if output_dir:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
env_vars["ML4T_OUTPUT_DIR"] = str(output_dir)
|
||||
if data_dir:
|
||||
env_vars["ML4T_DATA_PATH"] = str(data_dir)
|
||||
if extra_env:
|
||||
env_vars.update({key: str(value) for key, value in extra_env.items()})
|
||||
|
||||
# PYTHONPATH includes repo root for utils imports + notebook dir for sibling imports
|
||||
existing = os.environ.get("PYTHONPATH", "")
|
||||
nb_dir = str(py_path.parent.resolve())
|
||||
env_vars["PYTHONPATH"] = (
|
||||
f"{REPO_ROOT}:{nb_dir}:{existing}" if existing else f"{REPO_ROOT}:{nb_dir}"
|
||||
)
|
||||
|
||||
# Ensure torch's bundled CUDA libraries are found before system ones.
|
||||
# The system libcudart.so.12 may be outdated and missing symbols like
|
||||
# cudaGetDriverEntryPointByVersion that torch's bundled version provides.
|
||||
try:
|
||||
import torch
|
||||
|
||||
torch_lib = str(Path(torch.__file__).parent / "lib")
|
||||
nvidia_libs = list((Path(torch.__file__).parent.parent / "nvidia").glob("*/lib"))
|
||||
cuda_paths = [torch_lib] + [str(p) for p in nvidia_libs]
|
||||
existing_ld = os.environ.get("LD_LIBRARY_PATH", "")
|
||||
env_vars["LD_LIBRARY_PATH"] = ":".join(cuda_paths + [existing_ld])
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
remove_vars = ["TEST", "QUICK_TEST"]
|
||||
if extra_env:
|
||||
for key in extra_env:
|
||||
if key in remove_vars:
|
||||
remove_vars.remove(key)
|
||||
|
||||
# Apply environment changes
|
||||
for key, value in env_vars.items():
|
||||
saved_env[key] = os.environ.get(key)
|
||||
os.environ[key] = value
|
||||
for key in remove_vars:
|
||||
saved_env[key] = os.environ.pop(key, None)
|
||||
|
||||
# Cell-level progress — always log to /tmp/ml4t-pm-{name}.log for visibility.
|
||||
# Since request_save_on_cell_execute=True, the executed notebook is updated
|
||||
# after each cell. Monitor it with: watch -n5 'python -c "import json; ..."'
|
||||
progress_log = Path(f"/tmp/ml4t-pm-{nb_name}.log")
|
||||
n_cells = 0
|
||||
try:
|
||||
with open(progress_log, "w") as pf:
|
||||
pf.write(f"START {nb_name} timeout={timeout}s params={parameters}\n")
|
||||
|
||||
pm.execute_notebook(
|
||||
str(ipynb_path),
|
||||
str(executed_path),
|
||||
parameters=parameters or {},
|
||||
cwd=str(cwd or REPO_ROOT),
|
||||
kernel_name="python3",
|
||||
execution_timeout=timeout,
|
||||
request_save_on_cell_execute=True,
|
||||
progress_bar=False,
|
||||
log_output=True,
|
||||
)
|
||||
# Count cells in executed notebook
|
||||
try:
|
||||
import nbformat
|
||||
|
||||
nb = nbformat.read(str(executed_path), as_version=4)
|
||||
n_cells = len([c for c in nb.cells if c.cell_type == "code"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
elapsed = time.time() - start
|
||||
msg = f"OK {nb_name} ({elapsed:.1f}s, {n_cells} cells)"
|
||||
_log(msg)
|
||||
with open(progress_log, "a") as pf:
|
||||
pf.write(f"{msg}\n")
|
||||
return {"status": "ok", "error": None, "duration_s": elapsed, "n_cells": n_cells}
|
||||
except pm.PapermillExecutionError as e:
|
||||
elapsed = time.time() - start
|
||||
msg = f"FAIL {nb_name} cell {e.cell_index} ({e.ename}): {e.evalue} ({elapsed:.1f}s)"
|
||||
_log(msg)
|
||||
with open(progress_log, "a") as pf:
|
||||
pf.write(f"{msg}\n")
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"Cell {e.cell_index} ({e.ename}): {e.evalue}",
|
||||
"duration_s": elapsed,
|
||||
"n_cells": e.cell_index,
|
||||
}
|
||||
except Exception as e:
|
||||
elapsed = time.time() - start
|
||||
_log(f"FAIL {nb_name}: {e} ({elapsed:.1f}s)")
|
||||
return {"status": "error", "error": str(e), "duration_s": elapsed, "n_cells": 0}
|
||||
finally:
|
||||
# Restore environment
|
||||
for key, value in saved_env.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
# Clean up temp input notebook
|
||||
if tmp_ipynb is not None and tmp_ipynb.exists():
|
||||
tmp_ipynb.unlink()
|
||||
# Clean up executed notebook
|
||||
if executed_path.exists():
|
||||
executed_path.unlink()
|
||||
|
||||
|
||||
def collect_chapter_notebooks(repo_root: Path, chapter_range: range) -> list[Path]:
|
||||
"""Collect all teaching notebooks from chapter directories.
|
||||
|
||||
NOTE: Review repo has flat layout — notebooks live directly in
|
||||
chapter directories (e.g., 05_synthetic_data/01_timegan.py),
|
||||
NOT in code/ subdirectories.
|
||||
|
||||
Args:
|
||||
repo_root: Repository root path
|
||||
chapter_range: Range of chapter numbers to include
|
||||
|
||||
Returns:
|
||||
Sorted list of .py notebook paths
|
||||
"""
|
||||
notebooks = []
|
||||
for chapter_dir in sorted(repo_root.glob("[0-9][0-9]_*")):
|
||||
if not chapter_dir.is_dir():
|
||||
continue
|
||||
|
||||
# Extract chapter number from directory name
|
||||
try:
|
||||
ch_num = int(chapter_dir.name[:2])
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if ch_num not in chapter_range:
|
||||
continue
|
||||
|
||||
# Review repo: notebooks are directly in the chapter directory
|
||||
for notebook in sorted(chapter_dir.glob("*.py")):
|
||||
# Skip non-notebook files — use startswith to avoid false positives
|
||||
# (e.g., "test_" must not match "backtest_")
|
||||
if any(
|
||||
notebook.name.startswith(prefix)
|
||||
for prefix in [
|
||||
"test_",
|
||||
"conftest",
|
||||
"extract_book_figures",
|
||||
"export_figures",
|
||||
"batch_",
|
||||
]
|
||||
) or any(x in notebook.name for x in ["__pycache__", "__init__"]):
|
||||
continue
|
||||
|
||||
# Skip archived/draft/reserved directories
|
||||
if any(
|
||||
x in str(notebook)
|
||||
for x in ["_archive", "archived", "drafts", "inventory", "_reserved"]
|
||||
):
|
||||
continue
|
||||
|
||||
# Skip helper/utility files (start with _)
|
||||
if notebook.name.startswith("_"):
|
||||
continue
|
||||
|
||||
if not notebook.name[0].isdigit() and not notebook.with_suffix(".ipynb").exists():
|
||||
continue
|
||||
|
||||
notebooks.append(notebook)
|
||||
|
||||
return notebooks
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Sample real registry.db data into test intermediates.
|
||||
|
||||
Copies a representative subset from each case study's production registry
|
||||
into the test-data repo. This gives insight/synthesis/strategy_analysis
|
||||
notebooks real data to work with in CI.
|
||||
|
||||
Sampling strategy:
|
||||
- Model-side tables (training_runs, prediction_sets, prediction_metrics,
|
||||
fold_metrics): copied in full — small enough.
|
||||
- Backtest tables: top N per (family × stage) by Sharpe, plus ALL holdout
|
||||
backtests. Includes corresponding backtest_fold_metrics.
|
||||
|
||||
Usage:
|
||||
uv run python tests/sample_registry_for_tests.py
|
||||
|
||||
Writes to: ~/ml4t/test-data/intermediates/{cs}/run_log/registry.db
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
CODE_CS_DIR = REPO_ROOT / "case_studies"
|
||||
|
||||
TEST_DATA_ROOT = Path.home() / "ml4t" / "test-data"
|
||||
INTERMEDIATES_DIR = TEST_DATA_ROOT / "intermediates"
|
||||
|
||||
CASE_STUDY_IDS = [
|
||||
"etfs",
|
||||
"crypto_perps_funding",
|
||||
"nasdaq100_microstructure",
|
||||
"sp500_equity_option_analytics",
|
||||
"us_firm_characteristics",
|
||||
"fx_pairs",
|
||||
"cme_futures",
|
||||
"sp500_options",
|
||||
"us_equities_panel",
|
||||
]
|
||||
|
||||
# Keep top N backtests per (family, stage) by absolute Sharpe
|
||||
TOP_N_PER_GROUP = 3
|
||||
|
||||
|
||||
def _copy_rows(src, dst, table: str, rows: list) -> int:
|
||||
"""Insert rows into dst table with proper column quoting."""
|
||||
if not rows:
|
||||
return 0
|
||||
cols = [d[0] for d in src.execute(f"SELECT * FROM {table} LIMIT 1").description]
|
||||
quoted = [f'"{c}"' for c in cols]
|
||||
ph = ",".join(["?"] * len(cols))
|
||||
dst.executemany(f"INSERT OR IGNORE INTO {table} ({','.join(quoted)}) VALUES ({ph})", rows)
|
||||
return len(rows)
|
||||
|
||||
|
||||
def sample_registry(cs_id: str) -> dict:
|
||||
"""Sample from production registry into test intermediates. Returns stats."""
|
||||
src_db = CODE_CS_DIR / cs_id / "run_log" / "registry.db"
|
||||
if not src_db.exists():
|
||||
return {"status": "SKIP", "reason": "no source registry.db"}
|
||||
|
||||
dst_dir = INTERMEDIATES_DIR / cs_id / "run_log"
|
||||
dst_dir.mkdir(parents=True, exist_ok=True)
|
||||
dst_db = dst_dir / "registry.db"
|
||||
|
||||
# Remove old DB to start fresh
|
||||
dst_db.unlink(missing_ok=True)
|
||||
|
||||
src = sqlite3.connect(str(src_db))
|
||||
try:
|
||||
dst = sqlite3.connect(str(dst_db))
|
||||
try:
|
||||
return _populate_sample_db(src, dst, dst_db)
|
||||
finally:
|
||||
dst.close()
|
||||
finally:
|
||||
src.close()
|
||||
|
||||
|
||||
def _populate_sample_db(src, dst, dst_db) -> dict:
|
||||
stats: dict = {}
|
||||
|
||||
# 1. Copy schema from source (dump CREATE statements)
|
||||
schema_sql = []
|
||||
for row in src.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND sql IS NOT NULL"
|
||||
).fetchall():
|
||||
schema_sql.append(row[0])
|
||||
for sql in schema_sql:
|
||||
dst.execute(sql)
|
||||
|
||||
# Also copy indexes
|
||||
for row in src.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE type='index' AND sql IS NOT NULL"
|
||||
).fetchall():
|
||||
with contextlib.suppress(sqlite3.OperationalError):
|
||||
dst.execute(row[0])
|
||||
|
||||
# 2. Copy model-side tables in full
|
||||
for table in ["training_runs", "prediction_sets", "prediction_metrics", "fold_metrics"]:
|
||||
rows = src.execute(f"SELECT * FROM {table}").fetchall()
|
||||
n = _copy_rows(src, dst, table, rows)
|
||||
stats[table] = n
|
||||
|
||||
# 3. Sample backtests: top N per (family, stage) by |Sharpe|, plus all holdout
|
||||
# First, get sampled backtest hashes
|
||||
sampled_bt_hashes = set()
|
||||
|
||||
# 3a. Top N per family × stage (validation backtests)
|
||||
top_n_sql = """
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
b.backtest_hash,
|
||||
b.stage,
|
||||
t.family,
|
||||
bm.sharpe,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY b.stage, t.family
|
||||
ORDER BY ABS(bm.sharpe) DESC
|
||||
) AS rn
|
||||
FROM backtest_runs b
|
||||
JOIN backtest_metrics bm ON b.backtest_hash = bm.backtest_hash
|
||||
JOIN prediction_sets p ON b.prediction_hash = p.prediction_hash
|
||||
JOIN training_runs t ON p.training_hash = t.training_hash
|
||||
WHERE p.split != 'holdout'
|
||||
)
|
||||
SELECT backtest_hash FROM ranked WHERE rn <= ?
|
||||
"""
|
||||
for row in src.execute(top_n_sql, (TOP_N_PER_GROUP,)).fetchall():
|
||||
sampled_bt_hashes.add(row[0])
|
||||
|
||||
# 3b. ALL holdout backtests
|
||||
holdout_sql = """
|
||||
SELECT b.backtest_hash
|
||||
FROM backtest_runs b
|
||||
JOIN prediction_sets p ON b.prediction_hash = p.prediction_hash
|
||||
WHERE p.split = 'holdout'
|
||||
"""
|
||||
for row in src.execute(holdout_sql).fetchall():
|
||||
sampled_bt_hashes.add(row[0])
|
||||
|
||||
stats["backtest_runs_sampled"] = len(sampled_bt_hashes)
|
||||
|
||||
# 3c. Copy sampled backtest data (runs, metrics, fold_metrics)
|
||||
if sampled_bt_hashes:
|
||||
hash_list = list(sampled_bt_hashes)
|
||||
batch_size = 500
|
||||
|
||||
for table in ["backtest_runs", "backtest_metrics", "backtest_fold_metrics"]:
|
||||
count = 0
|
||||
for i in range(0, len(hash_list), batch_size):
|
||||
batch = hash_list[i : i + batch_size]
|
||||
placeholders = ",".join(["?"] * len(batch))
|
||||
rows = src.execute(
|
||||
f"SELECT * FROM {table} WHERE backtest_hash IN ({placeholders})",
|
||||
batch,
|
||||
).fetchall()
|
||||
count += _copy_rows(src, dst, table, rows)
|
||||
stats[table] = count
|
||||
|
||||
dst.commit()
|
||||
|
||||
stats["file_size_kb"] = dst_db.stat().st_size // 1024
|
||||
stats["status"] = "OK"
|
||||
return stats
|
||||
|
||||
|
||||
def main():
|
||||
print(f"Sampling registries from {CODE_CS_DIR}")
|
||||
print(f"Writing to {INTERMEDIATES_DIR}")
|
||||
print(f"Top {TOP_N_PER_GROUP} backtests per (family × stage) + all holdout\n")
|
||||
|
||||
total_size = 0
|
||||
for cs_id in CASE_STUDY_IDS:
|
||||
print(f"--- {cs_id} ---")
|
||||
stats = sample_registry(cs_id)
|
||||
if stats["status"] != "OK":
|
||||
print(f" {stats['status']}: {stats.get('reason', '')}")
|
||||
continue
|
||||
for table in [
|
||||
"training_runs",
|
||||
"prediction_sets",
|
||||
"prediction_metrics",
|
||||
"fold_metrics",
|
||||
"backtest_runs",
|
||||
"backtest_metrics",
|
||||
"backtest_fold_metrics",
|
||||
]:
|
||||
print(f" {table:30s} {stats.get(table, 0):>6}")
|
||||
print(f" {'file size (KB)':30s} {stats['file_size_kb']:>6}")
|
||||
total_size += stats["file_size_kb"]
|
||||
|
||||
print(f"\nTotal registry size: {total_size} KB ({total_size / 1024:.1f} MB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,355 @@
|
||||
"""Tests for case_studies/utils/allocation.py — portfolio weight contracts.
|
||||
|
||||
These allocators sit between model predictions and the backtest engine.
|
||||
A silent regression here would corrupt every Ch17+ result.
|
||||
|
||||
The tests pin *structural* contracts, not exact numerical values:
|
||||
|
||||
- Long-only weights sum to 1 per timestamp
|
||||
- Long-only weights are all non-negative
|
||||
- Long/short weights are dollar-neutral (net ≈ 0) with gross leverage ≈ 2
|
||||
(inverse-vol / risk-parity / HRP) or gross ≈ 1 (MVO)
|
||||
- Exactly ``top_k`` assets per side are selected when enough assets exist
|
||||
- Output columns are ``[timestamp, symbol, weight]`` in the expected order
|
||||
|
||||
Exact MVO values come from SLSQP and may vary across scipy versions, so the
|
||||
numeric pins are loose (gross, net, count) rather than per-asset weights.
|
||||
|
||||
The ``synthetic_panel`` fixture builds 8 symbols × 300 dates of random-walk
|
||||
prices. MVO needs a full lookback window (126 days); HRP needs ``vol_window``
|
||||
(63 days). The fixture gives both allocators enough runway before the
|
||||
rebalance timestamps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
from case_studies.utils.allocation import (
|
||||
compute_hrp_weights,
|
||||
compute_inverse_vol_weights,
|
||||
compute_mvo_weights,
|
||||
compute_risk_parity_weights,
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def synthetic_panel() -> tuple[pl.DataFrame, pl.DataFrame]:
|
||||
"""Return (predictions, prices) for 8 symbols × 300 days with 3 rebalance dates.
|
||||
|
||||
Prices: geometric random walk with asset-specific vol (so inverse-vol
|
||||
produces distinguishable weights). Scores are ascending by symbol id
|
||||
(S0..S7) so top_k picks deterministically.
|
||||
"""
|
||||
rng = np.random.default_rng(42)
|
||||
n_symbols = 8
|
||||
n_dates = 300
|
||||
symbols = [f"S{i}" for i in range(n_symbols)]
|
||||
ts = pl.date_range(pl.date(2023, 1, 1), pl.date(2023, 12, 31), "1d", eager=True)[:n_dates]
|
||||
|
||||
vols = 0.005 + 0.005 * np.arange(n_symbols) / n_symbols # 0.5% to ~1%
|
||||
shocks = rng.normal(0.0, vols[None, :], (n_dates, n_symbols))
|
||||
prices = 100.0 * np.exp(np.cumsum(shocks, axis=0))
|
||||
|
||||
price_rows: list[dict] = []
|
||||
for i, t in enumerate(ts):
|
||||
for j, s in enumerate(symbols):
|
||||
price_rows.append({"timestamp": t, "symbol": s, "close": float(prices[i, j])})
|
||||
prices_df = pl.DataFrame(price_rows)
|
||||
|
||||
pred_dates = ts[-3:]
|
||||
pred_rows: list[dict] = []
|
||||
for t in pred_dates:
|
||||
for j, s in enumerate(symbols):
|
||||
pred_rows.append({"timestamp": t, "symbol": s, "y_score": float(j)})
|
||||
predictions = pl.DataFrame(pred_rows)
|
||||
|
||||
return predictions, prices_df
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Shared contract checks
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _assert_output_shape(out: pl.DataFrame) -> None:
|
||||
assert set(out.columns) == {"timestamp", "symbol", "weight"}
|
||||
|
||||
|
||||
def _assert_long_only_sums_to_1(out: pl.DataFrame) -> None:
|
||||
per_date = out.group_by("timestamp").agg(pl.col("weight").sum().alias("s")).sort("timestamp")
|
||||
for s in per_date["s"].to_list():
|
||||
assert abs(s - 1.0) < 1e-6, per_date
|
||||
|
||||
|
||||
def _assert_non_negative(out: pl.DataFrame) -> None:
|
||||
assert (out["weight"] < 0).sum() == 0
|
||||
|
||||
|
||||
def _assert_dollar_neutral(out: pl.DataFrame, gross_target: float) -> None:
|
||||
per_date = (
|
||||
out.group_by("timestamp")
|
||||
.agg(
|
||||
net=pl.col("weight").sum(),
|
||||
gross=pl.col("weight").abs().sum(),
|
||||
)
|
||||
.sort("timestamp")
|
||||
)
|
||||
for net, gross in zip(per_date["net"].to_list(), per_date["gross"].to_list(), strict=True):
|
||||
assert abs(net) < 1e-6, f"long-short should net to 0, got {net}"
|
||||
assert abs(gross - gross_target) < 1e-6, f"expected gross={gross_target}, got {gross}"
|
||||
|
||||
|
||||
def _assert_top_k_selected(out: pl.DataFrame, top_k: int) -> None:
|
||||
per_date = (
|
||||
out.group_by("timestamp").agg(n=pl.col("symbol").count()).sort("timestamp")["n"].to_list()
|
||||
)
|
||||
for n in per_date:
|
||||
assert n == top_k, f"expected {top_k} selected, got {n}"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# compute_inverse_vol_weights
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_inverse_vol_long_only_contracts(synthetic_panel) -> None:
|
||||
predictions, prices = synthetic_panel
|
||||
out = compute_inverse_vol_weights(predictions, prices, top_k=4)
|
||||
_assert_output_shape(out)
|
||||
_assert_long_only_sums_to_1(out)
|
||||
_assert_non_negative(out)
|
||||
_assert_top_k_selected(out, top_k=4)
|
||||
|
||||
|
||||
def test_inverse_vol_picks_top_k_by_score(synthetic_panel) -> None:
|
||||
"""Scores ascending S0..S7 → top 4 should be S4..S7."""
|
||||
predictions, prices = synthetic_panel
|
||||
out = compute_inverse_vol_weights(predictions, prices, top_k=4)
|
||||
assert set(out["symbol"].unique().to_list()) == {"S4", "S5", "S6", "S7"}
|
||||
|
||||
|
||||
def test_inverse_vol_long_short_is_dollar_neutral(synthetic_panel) -> None:
|
||||
"""Long/short with top_k=3 → 3 longs @ +w_i, 3 shorts @ -w_j, gross≈2 (two sides of 1)."""
|
||||
predictions, prices = synthetic_panel
|
||||
out = compute_inverse_vol_weights(predictions, prices, top_k=3, long_short=True)
|
||||
_assert_dollar_neutral(out, gross_target=2.0)
|
||||
|
||||
|
||||
def test_inverse_vol_produces_nonuniform_weights(synthetic_panel) -> None:
|
||||
"""Weights are 1/σ-normalized — selected assets have heterogeneous vols,
|
||||
so weights must not collapse to equal-weight (0.25 for top_k=4).
|
||||
"""
|
||||
predictions, prices = synthetic_panel
|
||||
out = compute_inverse_vol_weights(predictions, prices, top_k=4)
|
||||
last_ts = out["timestamp"].max()
|
||||
slice_ = out.filter(pl.col("timestamp") == last_ts)
|
||||
weights = np.array(slice_["weight"].to_list())
|
||||
# Range of weights should be nontrivial (> 1% spread)
|
||||
assert weights.max() - weights.min() > 0.01
|
||||
|
||||
|
||||
def test_inverse_vol_deterministic(synthetic_panel) -> None:
|
||||
predictions, prices = synthetic_panel
|
||||
a = compute_inverse_vol_weights(predictions, prices, top_k=4)
|
||||
b = compute_inverse_vol_weights(predictions, prices, top_k=4)
|
||||
assert a.sort("timestamp", "symbol").equals(b.sort("timestamp", "symbol"))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# compute_risk_parity_weights
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_risk_parity_long_only_contracts(synthetic_panel) -> None:
|
||||
predictions, prices = synthetic_panel
|
||||
out = compute_risk_parity_weights(predictions, prices, top_k=4)
|
||||
_assert_output_shape(out)
|
||||
_assert_long_only_sums_to_1(out)
|
||||
_assert_non_negative(out)
|
||||
_assert_top_k_selected(out, top_k=4)
|
||||
|
||||
|
||||
def test_risk_parity_long_short_is_dollar_neutral(synthetic_panel) -> None:
|
||||
predictions, prices = synthetic_panel
|
||||
out = compute_risk_parity_weights(predictions, prices, top_k=3, long_short=True)
|
||||
_assert_dollar_neutral(out, gross_target=2.0)
|
||||
|
||||
|
||||
def test_risk_parity_assigns_less_to_high_vol_than_inverse_vol(synthetic_panel) -> None:
|
||||
"""Risk-parity uses 1/σ^1.5 (steeper penalty than inverse-vol's 1/σ).
|
||||
|
||||
High-vol assets should be relatively *less* weighted under risk-parity
|
||||
than under inverse-vol.
|
||||
"""
|
||||
predictions, prices = synthetic_panel
|
||||
iv = compute_inverse_vol_weights(predictions, prices, top_k=4)
|
||||
rp = compute_risk_parity_weights(predictions, prices, top_k=4)
|
||||
last_ts = iv["timestamp"].max()
|
||||
iv_weights = dict(
|
||||
zip(
|
||||
iv.filter(pl.col("timestamp") == last_ts)["symbol"].to_list(),
|
||||
iv.filter(pl.col("timestamp") == last_ts)["weight"].to_list(),
|
||||
strict=True,
|
||||
)
|
||||
)
|
||||
rp_weights = dict(
|
||||
zip(
|
||||
rp.filter(pl.col("timestamp") == last_ts)["symbol"].to_list(),
|
||||
rp.filter(pl.col("timestamp") == last_ts)["weight"].to_list(),
|
||||
strict=True,
|
||||
)
|
||||
)
|
||||
# S7 is the highest-vol asset selected — risk-parity should weight it lower than inverse-vol
|
||||
assert rp_weights["S7"] < iv_weights["S7"]
|
||||
|
||||
|
||||
def test_risk_parity_deterministic(synthetic_panel) -> None:
|
||||
predictions, prices = synthetic_panel
|
||||
a = compute_risk_parity_weights(predictions, prices, top_k=4)
|
||||
b = compute_risk_parity_weights(predictions, prices, top_k=4)
|
||||
assert a.sort("timestamp", "symbol").equals(b.sort("timestamp", "symbol"))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# compute_hrp_weights
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_hrp_long_only_contracts(synthetic_panel) -> None:
|
||||
predictions, prices = synthetic_panel
|
||||
out = compute_hrp_weights(predictions, prices, top_k=4)
|
||||
_assert_output_shape(out)
|
||||
_assert_long_only_sums_to_1(out)
|
||||
_assert_non_negative(out)
|
||||
_assert_top_k_selected(out, top_k=4)
|
||||
|
||||
|
||||
def test_hrp_long_short_is_dollar_neutral(synthetic_panel) -> None:
|
||||
predictions, prices = synthetic_panel
|
||||
out = compute_hrp_weights(predictions, prices, top_k=3, long_short=True)
|
||||
_assert_dollar_neutral(out, gross_target=2.0)
|
||||
|
||||
|
||||
def test_hrp_falls_back_to_equal_weight_on_short_history() -> None:
|
||||
"""With <20 days of history, HRP cannot form a covariance matrix → equal-weight."""
|
||||
rng = np.random.default_rng(0)
|
||||
n_dates = 10 # well under the 20-obs floor
|
||||
ts = pl.date_range(pl.date(2023, 1, 1), pl.date(2023, 1, 10), "1d", eager=True)
|
||||
price_rows = []
|
||||
for i, t in enumerate(ts):
|
||||
for j, s in enumerate(["A", "B", "C", "D"]):
|
||||
price_rows.append({"timestamp": t, "symbol": s, "close": 100.0 + float(rng.normal())})
|
||||
prices = pl.DataFrame(price_rows)
|
||||
predictions = pl.DataFrame(
|
||||
{
|
||||
"timestamp": [ts[-1]] * 4,
|
||||
"symbol": ["A", "B", "C", "D"],
|
||||
"y_score": [0.0, 1.0, 2.0, 3.0],
|
||||
}
|
||||
)
|
||||
out = compute_hrp_weights(predictions, prices, top_k=4)
|
||||
# Equal-weight = 1/4 for each
|
||||
for w in out["weight"].to_list():
|
||||
assert abs(w - 0.25) < 1e-9
|
||||
|
||||
|
||||
def test_hrp_deterministic(synthetic_panel) -> None:
|
||||
predictions, prices = synthetic_panel
|
||||
a = compute_hrp_weights(predictions, prices, top_k=4)
|
||||
b = compute_hrp_weights(predictions, prices, top_k=4)
|
||||
assert a.sort("timestamp", "symbol").equals(b.sort("timestamp", "symbol"))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# compute_mvo_weights
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_mvo_long_only_contracts(synthetic_panel) -> None:
|
||||
predictions, prices = synthetic_panel
|
||||
out = compute_mvo_weights(predictions, prices, top_k=4, max_weight=0.5)
|
||||
_assert_output_shape(out)
|
||||
_assert_non_negative(out)
|
||||
# Some assets may be dropped from the output if their optimal weight is
|
||||
# below 1e-6; don't pin the count. Weights should still sum to ~1.
|
||||
per_date = out.group_by("timestamp").agg(pl.col("weight").sum().alias("s"))
|
||||
for s in per_date["s"].to_list():
|
||||
assert abs(s - 1.0) < 1e-6
|
||||
|
||||
|
||||
def test_mvo_long_short_gross_normalized_to_1_and_dollar_neutral(synthetic_panel) -> None:
|
||||
"""MVO long/short normalizes gross to 1 and uses a dollar-neutral constraint."""
|
||||
predictions, prices = synthetic_panel
|
||||
out = compute_mvo_weights(predictions, prices, top_k=3, long_short=True, max_weight=0.5)
|
||||
_assert_dollar_neutral(out, gross_target=1.0)
|
||||
|
||||
|
||||
def test_mvo_respects_position_cap(synthetic_panel) -> None:
|
||||
"""No weight should exceed max_weight after normalization (long-only path)."""
|
||||
predictions, prices = synthetic_panel
|
||||
out = compute_mvo_weights(predictions, prices, top_k=8, max_weight=0.15)
|
||||
# Small tolerance for renormalization + float error
|
||||
assert out["weight"].max() <= 0.15 + 5e-3
|
||||
|
||||
|
||||
def test_mvo_falls_back_to_equal_weight_on_short_history() -> None:
|
||||
"""With 20 dates of history but lookback=126, MVO falls back to equal weight."""
|
||||
rng = np.random.default_rng(0)
|
||||
ts = pl.date_range(pl.date(2023, 1, 1), pl.date(2023, 1, 20), "1d", eager=True)
|
||||
price_rows = []
|
||||
for t in ts:
|
||||
for s in ["A", "B", "C", "D"]:
|
||||
price_rows.append({"timestamp": t, "symbol": s, "close": 100.0 + float(rng.normal())})
|
||||
prices = pl.DataFrame(price_rows)
|
||||
predictions = pl.DataFrame(
|
||||
{
|
||||
"timestamp": [ts[-1]] * 4,
|
||||
"symbol": ["A", "B", "C", "D"],
|
||||
"y_score": [0.0, 1.0, 2.0, 3.0],
|
||||
}
|
||||
)
|
||||
out = compute_mvo_weights(predictions, prices, top_k=4)
|
||||
for w in out["weight"].to_list():
|
||||
assert abs(w - 0.25) < 1e-9
|
||||
|
||||
|
||||
def test_mvo_returns_empty_frame_when_fewer_than_3_assets_selected() -> None:
|
||||
"""top_k=2 → <3 assets → MVO skips the date and emits an empty frame."""
|
||||
rng = np.random.default_rng(0)
|
||||
ts = pl.date_range(pl.date(2023, 1, 1), pl.date(2023, 12, 31), "1d", eager=True)[:200]
|
||||
price_rows = []
|
||||
for t in ts:
|
||||
for s in ["A", "B"]:
|
||||
price_rows.append({"timestamp": t, "symbol": s, "close": 100.0 + float(rng.normal())})
|
||||
prices = pl.DataFrame(price_rows)
|
||||
predictions = pl.DataFrame(
|
||||
{"timestamp": [ts[-1]] * 2, "symbol": ["A", "B"], "y_score": [0.0, 1.0]}
|
||||
)
|
||||
out = compute_mvo_weights(predictions, prices, top_k=2)
|
||||
assert out.height == 0
|
||||
assert set(out.columns) == {"timestamp", "symbol", "weight"}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Input flexibility: accept either 'close' or 'ret' column in prices
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_inverse_vol_accepts_ret_column_directly(synthetic_panel) -> None:
|
||||
"""If prices already carry 'ret', the allocator uses it instead of pct_change('close')."""
|
||||
predictions, prices = synthetic_panel
|
||||
ret_prices = (
|
||||
prices.sort("timestamp", "symbol")
|
||||
.with_columns(ret=pl.col("close").pct_change().over("symbol"))
|
||||
.select("timestamp", "symbol", "ret")
|
||||
)
|
||||
out = compute_inverse_vol_weights(predictions, ret_prices, top_k=4)
|
||||
_assert_long_only_sums_to_1(out)
|
||||
_assert_top_k_selected(out, top_k=4)
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from case_studies.utils.backtest_loaders import (
|
||||
get_backtest_config,
|
||||
load_backtest_prices,
|
||||
)
|
||||
from utils.modeling import load_modeling_dataset
|
||||
|
||||
|
||||
def test_us_equities_pilot_helpers_preserve_current_outputs() -> None:
|
||||
bt = get_backtest_config("us_equities_panel")
|
||||
prices = load_backtest_prices("us_equities_panel", max_symbols=2)
|
||||
mds = load_modeling_dataset("us_equities_panel", "fwd_ret_1d", max_symbols=2)
|
||||
|
||||
assert bt.primary_label == "fwd_ret_1d"
|
||||
assert bt.label_buffer == "1D"
|
||||
assert bt.calendar == "NYSE"
|
||||
assert bt.cadence == "daily_close"
|
||||
|
||||
assert prices.columns == ["symbol", "timestamp", "open", "high", "low", "close", "volume"]
|
||||
assert prices["symbol"].n_unique() == 2
|
||||
|
||||
assert mds.label_col == "fwd_ret_1d"
|
||||
assert mds.date_col == "timestamp"
|
||||
assert mds.entity_cols == ["symbol"]
|
||||
assert mds.join_cols == ["symbol", "timestamp"]
|
||||
assert len(mds.feature_names) == 72
|
||||
assert len(mds.splits) == 16
|
||||
assert mds.label_buffer == "1D"
|
||||
assert mds.task_type == "regression"
|
||||
|
||||
|
||||
def test_microstructure_pilot_helpers_preserve_current_outputs() -> None:
|
||||
bt = get_backtest_config("nasdaq100_microstructure")
|
||||
prices = load_backtest_prices("nasdaq100_microstructure", max_symbols=2)
|
||||
mds = load_modeling_dataset("nasdaq100_microstructure", "fwd_ret_15m", max_symbols=2)
|
||||
|
||||
assert bt.primary_label == "fwd_ret_15m"
|
||||
assert bt.label_buffer == "15min"
|
||||
assert bt.calendar == "NYSE"
|
||||
assert bt.cadence == "15_minute"
|
||||
|
||||
# Microstructure carries OHLCV + bid/ask OHLC so the backtest engine can
|
||||
# cost spread-aware fills.
|
||||
required_cols = ["symbol", "timestamp", "open", "high", "low", "close", "volume"]
|
||||
assert all(c in prices.columns for c in required_cols)
|
||||
assert "bid_close" in prices.columns and "ask_close" in prices.columns
|
||||
assert prices["symbol"].n_unique() == 2
|
||||
|
||||
assert mds.label_col == "fwd_ret_15m"
|
||||
assert mds.date_col == "timestamp"
|
||||
assert mds.entity_cols == ["symbol"]
|
||||
assert mds.join_cols == ["symbol", "timestamp"]
|
||||
assert len(mds.feature_names) == 88
|
||||
assert len(mds.splits) == 2
|
||||
assert mds.label_buffer == "15min"
|
||||
assert mds.task_type == "regression"
|
||||
@@ -0,0 +1,249 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import polars as pl
|
||||
|
||||
from case_studies.utils.backtest_loaders import get_backtest_config, load_backtest_prices
|
||||
from case_studies.utils.backtest_presets import (
|
||||
cost_view,
|
||||
ensure_backtest_spec,
|
||||
is_backtest_spec,
|
||||
load_backtest_preset,
|
||||
strategy_view,
|
||||
)
|
||||
from case_studies.utils.backtest_runner import normalize_prediction_columns
|
||||
from case_studies.utils.registry.specs import backtest_hash_from_parts
|
||||
from case_studies.utils.registry.store import _infer_stage
|
||||
|
||||
|
||||
def test_etf_backtest_base_preset_exists() -> None:
|
||||
preset = load_backtest_preset("etfs")
|
||||
assert preset["calendar"]["calendar"] == "NYSE"
|
||||
assert preset["commission"]["rate"] == 0.0006
|
||||
assert preset["slippage"]["rate"] == 0.0004
|
||||
|
||||
|
||||
def test_ensure_backtest_spec_builds_composite_spec() -> None:
|
||||
bt = get_backtest_config("etfs")
|
||||
prices = load_backtest_prices("etfs", max_symbols=2)
|
||||
legacy_spec = {
|
||||
"chapter": "ch18",
|
||||
"signal": {"method": "equal_weight_top_k", "top_k": 10, "long_short": False},
|
||||
"execution": {
|
||||
"mode": "engine",
|
||||
"engine_preset": "realistic",
|
||||
"cadence": bt.cadence,
|
||||
"fill_timing": bt.execution_delay.upper(),
|
||||
},
|
||||
"costs": {"commission_bps": 6.0, "slippage_bps": 4.0},
|
||||
"allocation": {"method": "risk_parity", "top_k": 10},
|
||||
}
|
||||
spec = ensure_backtest_spec(
|
||||
"etfs",
|
||||
bt,
|
||||
legacy_spec,
|
||||
prices=prices,
|
||||
prediction_hash="pred123",
|
||||
initial_cash=1_000_000.0,
|
||||
)
|
||||
|
||||
assert is_backtest_spec(spec)
|
||||
assert spec["strategy"]["signal"]["method"] == "equal_weight_top_k"
|
||||
assert spec["strategy"]["allocation"]["method"] == "risk_parity"
|
||||
assert spec["strategy"]["rebalance"]["cadence"] == bt.cadence
|
||||
assert spec["backtest_config"]["commission"]["rate"] == 0.0006
|
||||
assert spec["backtest_config"]["slippage"]["rate"] == 0.0004
|
||||
assert spec["backtest_config"]["metadata"]["prediction_hash"] == "pred123"
|
||||
assert cost_view(spec) == {"commission_bps": 6.0, "slippage_bps": 4.0}
|
||||
|
||||
|
||||
def test_backtest_hash_changes_with_resolved_config() -> None:
|
||||
bt = get_backtest_config("etfs")
|
||||
prices = load_backtest_prices("etfs", max_symbols=2)
|
||||
base = {
|
||||
"signal": {"method": "equal_weight_top_k", "top_k": 10, "long_short": False},
|
||||
"execution": {
|
||||
"mode": "engine",
|
||||
"engine_preset": "realistic",
|
||||
"cadence": bt.cadence,
|
||||
"fill_timing": bt.execution_delay.upper(),
|
||||
},
|
||||
"costs": {"commission_bps": 6.0, "slippage_bps": 4.0},
|
||||
}
|
||||
cheap = ensure_backtest_spec(
|
||||
"etfs",
|
||||
bt,
|
||||
base,
|
||||
prices=prices,
|
||||
prediction_hash="pred123",
|
||||
initial_cash=1_000_000.0,
|
||||
)
|
||||
expensive = ensure_backtest_spec(
|
||||
"etfs",
|
||||
bt,
|
||||
{**base, "costs": {"commission_bps": 10.0, "slippage_bps": 4.0}},
|
||||
prices=prices,
|
||||
prediction_hash="pred123",
|
||||
initial_cash=1_000_000.0,
|
||||
)
|
||||
|
||||
assert backtest_hash_from_parts("pred123", cheap) != backtest_hash_from_parts(
|
||||
"pred123", expensive
|
||||
)
|
||||
|
||||
|
||||
def test_stage_inference_supports_v2_specs() -> None:
|
||||
spec = {
|
||||
"version": 2,
|
||||
"chapter": "ch19",
|
||||
"strategy": {
|
||||
"signal": {"method": "equal_weight_top_k", "top_k": 10},
|
||||
"rebalance": {"mode": "engine", "cadence": "monthly_month_end"},
|
||||
"risk": {"name": "trailing", "position_rules": [{"type": "trailing_stop"}]},
|
||||
},
|
||||
"backtest_config": {"commission": {"rate": 0.0006}, "slippage": {"rate": 0.0004}},
|
||||
}
|
||||
assert _infer_stage(spec) == "risk_overlay"
|
||||
assert strategy_view(spec)["risk"]["name"] == "trailing"
|
||||
|
||||
|
||||
def test_microstructure_preset_auto_loads_quote_columns() -> None:
|
||||
prices = load_backtest_prices("nasdaq100_microstructure", max_symbols=2)
|
||||
|
||||
assert "bid_open" in prices.columns
|
||||
assert "ask_open" in prices.columns
|
||||
|
||||
|
||||
def test_sp500_options_short_only_engine_spec_preserves_explicit_feed() -> None:
|
||||
bt = get_backtest_config("sp500_options")
|
||||
prices = load_backtest_prices("sp500_options", max_symbols=2)
|
||||
legacy_spec = {
|
||||
"chapter": "ch16",
|
||||
"signal": {
|
||||
"method": "score_weighted_top_k",
|
||||
"top_k": 10,
|
||||
"long_short": False,
|
||||
"direction": "short_only",
|
||||
},
|
||||
"execution": {
|
||||
"mode": "engine",
|
||||
"engine_preset": "realistic",
|
||||
"cadence": bt.cadence,
|
||||
"fill_timing": bt.execution_delay.upper(),
|
||||
},
|
||||
"costs": {"commission_bps": bt.commission_bps, "slippage_bps": bt.slippage_bps},
|
||||
}
|
||||
spec = ensure_backtest_spec(
|
||||
"sp500_options",
|
||||
bt,
|
||||
legacy_spec,
|
||||
prices=prices,
|
||||
prediction_hash="pred123",
|
||||
initial_cash=1_000_000.0,
|
||||
)
|
||||
|
||||
cfg = spec["backtest_config"]
|
||||
assert spec["strategy"]["rebalance"]["mode"] == "engine"
|
||||
assert cfg["account"]["allow_short_selling"] is True
|
||||
assert cfg["execution"]["execution_price"] == "quote_side"
|
||||
assert cfg["execution"]["mark_price"] == "price"
|
||||
assert cfg["feed"]["price_col"] == "instr_mid"
|
||||
assert cfg["feed"]["bid_col"] == "instr_bid"
|
||||
assert cfg["feed"]["ask_col"] == "instr_ask"
|
||||
|
||||
|
||||
def test_ensure_backtest_spec_pins_enforce_sessions_for_cme_calendar() -> None:
|
||||
"""CME-calendar specs must set enforce_sessions=True at construction time.
|
||||
|
||||
Regression: without this, ensure_backtest_spec emits a runtime with
|
||||
enforce_sessions=False, but _run_engine later mutates it to True, so the
|
||||
plan-time and post-engine hashes diverge (verify fires "0/N in_registry").
|
||||
BacktestConfig.to_dict() does NOT serialize enforce_sessions; the hash
|
||||
picks it up through ``_runtime_backtest_config`` in the spec (canonical_json
|
||||
uses default=str on the dataclass repr), so the runtime is the load-bearing
|
||||
surface and what we pin here.
|
||||
|
||||
Also pins that the NYSE projection branch does NOT trip the re-serialize
|
||||
path — its original ``backtest_config`` dict and metadata are preserved.
|
||||
"""
|
||||
bt_cme = get_backtest_config("cme_futures")
|
||||
prices_cme = load_backtest_prices("cme_futures", max_symbols=2)
|
||||
|
||||
canonical_cme = {
|
||||
"version": 2,
|
||||
"chapter": "ch18",
|
||||
"strategy": {
|
||||
"signal": {"method": "equal_weight_top_k", "top_k": 5, "long_short": True},
|
||||
"rebalance": {"mode": "engine", "cadence": bt_cme.cadence},
|
||||
},
|
||||
"backtest_config": {
|
||||
"commission": {"rate": 0.0},
|
||||
"slippage": {"rate": 0.0},
|
||||
"metadata": {"chapter": "ch18", "extra": "preserved"},
|
||||
},
|
||||
}
|
||||
spec_cme = ensure_backtest_spec(
|
||||
"cme_futures",
|
||||
bt_cme,
|
||||
canonical_cme,
|
||||
prices=prices_cme,
|
||||
prediction_hash="pred_cme",
|
||||
initial_cash=1_000_000.0,
|
||||
)
|
||||
assert spec_cme["_runtime_backtest_config"].enforce_sessions is True
|
||||
# Caller-supplied metadata keys must survive the re-serialization.
|
||||
assert spec_cme["backtest_config"]["metadata"]["extra"] == "preserved"
|
||||
assert spec_cme["backtest_config"]["metadata"]["prediction_hash"] == "pred_cme"
|
||||
|
||||
# NYSE specs (etfs) must NOT trip the projection re-serialize path —
|
||||
# the original backtest_config dict should be preserved untouched.
|
||||
bt_etf = get_backtest_config("etfs")
|
||||
prices_etf = load_backtest_prices("etfs", max_symbols=2)
|
||||
canonical_etf = {
|
||||
"version": 2,
|
||||
"chapter": "ch18",
|
||||
"strategy": {
|
||||
"signal": {"method": "equal_weight_top_k", "top_k": 10, "long_short": False},
|
||||
"rebalance": {"mode": "engine", "cadence": bt_etf.cadence},
|
||||
},
|
||||
"backtest_config": {
|
||||
"commission": {"rate": 0.0006},
|
||||
"slippage": {"rate": 0.0004},
|
||||
"metadata": {"chapter": "ch18", "extra": "preserved"},
|
||||
},
|
||||
}
|
||||
spec_etf = ensure_backtest_spec(
|
||||
"etfs",
|
||||
bt_etf,
|
||||
canonical_etf,
|
||||
prices=prices_etf,
|
||||
prediction_hash="pred_etf",
|
||||
initial_cash=1_000_000.0,
|
||||
)
|
||||
assert spec_etf["_runtime_backtest_config"].enforce_sessions is False
|
||||
# Original dict preserved (we did not re-serialize) — keys the caller
|
||||
# passed in are exactly the keys present.
|
||||
assert set(spec_etf["backtest_config"].keys()) == {"commission", "slippage", "metadata"}
|
||||
assert spec_etf["backtest_config"]["metadata"]["extra"] == "preserved"
|
||||
assert spec_etf["backtest_config"]["metadata"]["prediction_hash"] == "pred_etf"
|
||||
|
||||
|
||||
def test_normalize_prediction_columns_maps_causal_and_legacy_fields() -> None:
|
||||
df = pl.DataFrame(
|
||||
{
|
||||
"timestamp": [datetime(2024, 1, 2)],
|
||||
"symbol": ["AAPL"],
|
||||
"fold": [0],
|
||||
"actual": [0.01],
|
||||
"prediction": [0.02],
|
||||
}
|
||||
)
|
||||
|
||||
normalized = normalize_prediction_columns(df)
|
||||
|
||||
assert "y_score" in normalized.columns
|
||||
assert "y_true" in normalized.columns
|
||||
assert "fold_id" in normalized.columns
|
||||
assert normalized["y_score"].to_list() == [0.02]
|
||||
assert normalized["y_true"].to_list() == [0.01]
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Regression tests for case_studies/utils/backtest_runner.py helpers.
|
||||
|
||||
Pins the P2.4 fixes from roborev jobs #2904, #2501, #2502, #2500:
|
||||
- ``_align_symbol_dtype`` surfaces case-study context on ticker-vs-id mismatches.
|
||||
- ``substitute_continuous_return_for_classification`` raises on duplicate
|
||||
(timestamp, symbol) rows in the continuous-return parquet and on left-join
|
||||
height changes.
|
||||
- ``apply_universe_filter`` collapses sub-daily timestamps to the date grain
|
||||
before computing the within-date rank.
|
||||
- ``_MAX_NULL_RATE`` constant is wired through ``max_null_rate`` parameter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
from case_studies.utils.backtest_runner import (
|
||||
_MAX_NULL_RATE,
|
||||
_align_symbol_dtype,
|
||||
apply_universe_filter,
|
||||
substitute_continuous_return_for_classification,
|
||||
)
|
||||
|
||||
|
||||
def test_max_null_rate_constant_default() -> None:
|
||||
assert _MAX_NULL_RATE == 0.10
|
||||
|
||||
|
||||
def test_align_symbol_dtype_same_dtype_passthrough() -> None:
|
||||
target = pl.DataFrame({"symbol": ["A", "B"]})
|
||||
other = pl.DataFrame({"symbol": ["C", "D"]})
|
||||
out = _align_symbol_dtype(target, other, case_study="x", target_side="t", other_side="o")
|
||||
assert out.schema["symbol"] == pl.Utf8
|
||||
# Returned frame is the original when dtypes match.
|
||||
assert out.equals(other)
|
||||
|
||||
|
||||
def test_align_symbol_dtype_int_target_numeric_string_source() -> None:
|
||||
target = pl.DataFrame({"symbol": [1, 2]}, schema={"symbol": pl.UInt32})
|
||||
other = pl.DataFrame({"symbol": ["10", "20"]})
|
||||
out = _align_symbol_dtype(
|
||||
target, other, case_study="us_firm", target_side="weights", other_side="prices"
|
||||
)
|
||||
assert out.schema["symbol"] == pl.UInt32
|
||||
assert out["symbol"].to_list() == [10, 20]
|
||||
|
||||
|
||||
def test_align_symbol_dtype_int_target_ticker_source_raises_with_context() -> None:
|
||||
target = pl.DataFrame({"symbol": [1, 2]}, schema={"symbol": pl.UInt32})
|
||||
other = pl.DataFrame({"symbol": ["AAPL", "MSFT"]})
|
||||
with pytest.raises(TypeError, match=r"case_study='broken'"):
|
||||
_align_symbol_dtype(
|
||||
target,
|
||||
other,
|
||||
case_study="broken",
|
||||
target_side="weights",
|
||||
other_side="prices",
|
||||
)
|
||||
|
||||
|
||||
def test_align_symbol_dtype_int_source_to_string_target() -> None:
|
||||
target = pl.DataFrame({"symbol": ["A"]})
|
||||
other = pl.DataFrame({"symbol": [1, 2]}, schema={"symbol": pl.UInt32})
|
||||
out = _align_symbol_dtype(target, other, case_study="x", target_side="t", other_side="o")
|
||||
assert out.schema["symbol"] == pl.Utf8
|
||||
|
||||
|
||||
def test_apply_universe_filter_collapses_intraday_to_date_grain(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Sub-daily bars share a date but rank should be within-date, not within-bar.
|
||||
|
||||
Without the date-collapse fix, two intraday bars per (date, symbol) would
|
||||
produce a denominator of 2N instead of N for the daily rank, silently
|
||||
filtering against a within-bar universe.
|
||||
"""
|
||||
cs = "sp500_options_test"
|
||||
cs_dir = tmp_path / cs / "config"
|
||||
cs_dir.mkdir(parents=True)
|
||||
(cs_dir / "setup.yaml").write_text(
|
||||
dedent(
|
||||
"""
|
||||
backtest:
|
||||
sweep:
|
||||
htm_cost_cascade:
|
||||
liquid_quantile: 0.50
|
||||
"""
|
||||
).strip()
|
||||
)
|
||||
import case_studies.utils.backtest_runner as br
|
||||
|
||||
monkeypatch.setattr(br, "CASE_STUDIES_DIR", str(tmp_path), raising=False)
|
||||
# ``CASE_STUDIES_DIR`` is imported lazily inside the function, so also
|
||||
# patch the source module ``utils`` so the rebinding wins.
|
||||
import utils as _utils # type: ignore
|
||||
|
||||
monkeypatch.setattr(_utils, "CASE_STUDIES_DIR", str(tmp_path), raising=False)
|
||||
|
||||
# Two intraday bars per (date, symbol). Without date-collapse, rank
|
||||
# denominator would be 4 (two bars × two symbols) and both symbols would
|
||||
# land at the 0.50 quantile; with date-collapse, denominator is 2 (two
|
||||
# symbols), and the tighter-spread symbol (A) is the unique survivor.
|
||||
d1 = datetime(2024, 1, 2)
|
||||
bar_open = datetime(2024, 1, 2, 9, 30)
|
||||
bar_close = datetime(2024, 1, 2, 16, 0)
|
||||
prices = pl.DataFrame(
|
||||
{
|
||||
"timestamp": [bar_open, bar_close, bar_open, bar_close],
|
||||
"symbol": ["A", "A", "B", "B"],
|
||||
"instr_rel_spread": [0.01, 0.012, 0.05, 0.06],
|
||||
}
|
||||
)
|
||||
predictions = pl.DataFrame(
|
||||
{
|
||||
"timestamp": [d1, d1],
|
||||
"symbol": ["A", "B"],
|
||||
}
|
||||
)
|
||||
out = apply_universe_filter(
|
||||
predictions, prices, case_study=cs, signal_config={"universe_filter": "liquid"}
|
||||
)
|
||||
# Only the tighter-spread symbol (A) survives the 0.50 quantile.
|
||||
assert out["symbol"].to_list() == ["A"]
|
||||
|
||||
|
||||
def test_substitute_continuous_return_dedupe_assertion(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
cs = "test_cs"
|
||||
cs_dir = tmp_path / cs
|
||||
(cs_dir / "config").mkdir(parents=True)
|
||||
(cs_dir / "labels").mkdir()
|
||||
(cs_dir / "config" / "setup.yaml").write_text(
|
||||
dedent(
|
||||
"""
|
||||
labels:
|
||||
classification_eval_label:
|
||||
fwd_dir_1d: fwd_ret_1d
|
||||
"""
|
||||
).strip()
|
||||
)
|
||||
# Continuous-return parquet with a duplicate (timestamp, symbol) row.
|
||||
d1 = datetime(2024, 1, 2)
|
||||
eval_df = pl.DataFrame(
|
||||
{
|
||||
"timestamp": [d1, d1, d1], # 2× (d1, "A") — duplicate!
|
||||
"symbol": ["A", "A", "B"],
|
||||
"fwd_ret_1d": [0.01, 0.02, 0.03],
|
||||
}
|
||||
)
|
||||
eval_df.write_parquet(cs_dir / "labels" / "fwd_ret_1d.parquet")
|
||||
|
||||
predictions = pl.DataFrame(
|
||||
{
|
||||
"timestamp": [d1, d1],
|
||||
"symbol": ["A", "B"],
|
||||
"y_score": [0.1, 0.2],
|
||||
"y_true": [1, 0],
|
||||
}
|
||||
)
|
||||
|
||||
import case_studies.utils.backtest_runner as br
|
||||
import utils as _utils # type: ignore
|
||||
|
||||
monkeypatch.setattr(_utils, "CASE_STUDIES_DIR", str(tmp_path), raising=False)
|
||||
monkeypatch.setattr(br, "CASE_STUDIES_DIR", str(tmp_path), raising=False)
|
||||
|
||||
with pytest.raises(ValueError, match=r"duplicate \(timestamp, symbol\)"):
|
||||
substitute_continuous_return_for_classification(
|
||||
predictions, case_study=cs, label="fwd_dir_1d"
|
||||
)
|
||||
|
||||
|
||||
def test_substitute_continuous_return_max_null_rate_param(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Passing ``max_null_rate=1.0`` allows callers in a legitimately high-null regime."""
|
||||
cs = "test_cs_nulls"
|
||||
cs_dir = tmp_path / cs
|
||||
(cs_dir / "config").mkdir(parents=True)
|
||||
(cs_dir / "labels").mkdir()
|
||||
(cs_dir / "config" / "setup.yaml").write_text(
|
||||
dedent(
|
||||
"""
|
||||
labels:
|
||||
classification_eval_label:
|
||||
fwd_dir_1d: fwd_ret_1d
|
||||
"""
|
||||
).strip()
|
||||
)
|
||||
d1 = datetime(2024, 1, 2)
|
||||
d2 = datetime(2024, 1, 3)
|
||||
# Eval parquet only covers d1, not d2 — predictions on d2 will null-match.
|
||||
eval_df = pl.DataFrame({"timestamp": [d1], "symbol": ["A"], "fwd_ret_1d": [0.01]})
|
||||
eval_df.write_parquet(cs_dir / "labels" / "fwd_ret_1d.parquet")
|
||||
|
||||
predictions = pl.DataFrame(
|
||||
{
|
||||
"timestamp": [d1, d2, d2, d2],
|
||||
"symbol": ["A", "A", "B", "C"],
|
||||
"y_score": [0.1, 0.2, 0.3, 0.4],
|
||||
"y_true": [1, 0, 1, 0],
|
||||
}
|
||||
)
|
||||
|
||||
import case_studies.utils.backtest_runner as br
|
||||
import utils as _utils # type: ignore
|
||||
|
||||
monkeypatch.setattr(_utils, "CASE_STUDIES_DIR", str(tmp_path), raising=False)
|
||||
monkeypatch.setattr(br, "CASE_STUDIES_DIR", str(tmp_path), raising=False)
|
||||
|
||||
# Default cap (10%) raises: 3/4 = 75% null rate.
|
||||
with pytest.raises(ValueError, match=r"exceeds max_null_rate"):
|
||||
substitute_continuous_return_for_classification(
|
||||
predictions, case_study=cs, label="fwd_dir_1d"
|
||||
)
|
||||
# Override loosens the cap; missing rows are dropped instead of raised.
|
||||
out = substitute_continuous_return_for_classification(
|
||||
predictions, case_study=cs, label="fwd_dir_1d", max_null_rate=1.0
|
||||
)
|
||||
assert out.height == 1
|
||||
assert out["y_true"].to_list() == [0.01]
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Tests for calendar-aware backtest schedule resolution and execution delay mapping.
|
||||
|
||||
These tests validate the fixes for:
|
||||
- Finding 1: Calendar-named cadences must use actual calendar dates, not elapsed time
|
||||
- Finding 2: Execution delay mapping must be explicit, not substring-based
|
||||
- Finding 3: Session enforcement must be enabled for CME
|
||||
- Finding 4: Vectorized path must use resolved schedule, not gather_every
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_rebalance_timestamps tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_weekday_series(start: str, end: str) -> pl.Series:
|
||||
"""Create a daily timestamp series (weekdays only, simulating trading days)."""
|
||||
dates = pl.date_range(
|
||||
pl.lit(datetime.strptime(start, "%Y-%m-%d")),
|
||||
pl.lit(datetime.strptime(end, "%Y-%m-%d")),
|
||||
interval="1d",
|
||||
eager=True,
|
||||
)
|
||||
# Filter to weekdays (Mon=1..Fri=5 in Polars)
|
||||
df = pl.DataFrame({"ts": dates}).filter(pl.col("ts").dt.weekday() <= 5)
|
||||
return df["ts"].sort()
|
||||
|
||||
|
||||
class TestResolveRebalanceTimestamps:
|
||||
"""Test calendar-aware schedule resolution."""
|
||||
|
||||
def test_monthly_month_end_returns_actual_month_ends(self):
|
||||
"""ETF scenario: monthly_month_end must return last session of each month."""
|
||||
from case_studies.utils.backtest_loaders import resolve_rebalance_timestamps
|
||||
|
||||
ts = _make_weekday_series("2016-01-01", "2016-06-30")
|
||||
result = resolve_rebalance_timestamps(ts, "monthly_month_end")
|
||||
all_dates = ts.to_list()
|
||||
|
||||
for dt in result.to_list():
|
||||
month, year = dt.month, dt.year
|
||||
later = [d for d in all_dates if d.year == year and d.month == month and d > dt]
|
||||
assert len(later) == 0, f"Month-end {dt} is not the last session in {year}-{month:02d}"
|
||||
|
||||
def test_monthly_month_end_not_every_21_days(self):
|
||||
"""Regression: month-end should NOT produce evenly-spaced 21-day intervals."""
|
||||
from case_studies.utils.backtest_loaders import resolve_rebalance_timestamps
|
||||
|
||||
ts = _make_weekday_series("2016-01-01", "2016-12-31")
|
||||
result = resolve_rebalance_timestamps(ts, "monthly_month_end")
|
||||
|
||||
assert 11 <= len(result) <= 12
|
||||
|
||||
gaps = result.diff().drop_nulls().dt.total_seconds() / 86400
|
||||
gap_values = set(int(g) for g in gaps.to_list())
|
||||
assert len(gap_values) > 1, "Month-end gaps should not all be identical"
|
||||
|
||||
def test_weekly_friday_returns_end_of_week(self):
|
||||
"""CME/SP500 scenario: weekly_friday_close returns last session per ISO week."""
|
||||
from case_studies.utils.backtest_loaders import resolve_rebalance_timestamps
|
||||
|
||||
ts = _make_weekday_series("2018-08-01", "2018-10-31")
|
||||
result = resolve_rebalance_timestamps(ts, "weekly_friday_close")
|
||||
|
||||
# Exclude last week (may be incomplete if date range ends mid-week)
|
||||
dates = result.to_list()
|
||||
# All complete weeks should end on Friday
|
||||
for dt in dates[:-1]:
|
||||
assert dt.weekday() == 4, f"Expected Friday, got {dt} (weekday={dt.weekday()})"
|
||||
|
||||
def test_weekly_friday_holiday_fallback(self):
|
||||
"""If Friday is missing (holiday), should take Thursday of that week."""
|
||||
from datetime import date
|
||||
|
||||
from case_studies.utils.backtest_loaders import resolve_rebalance_timestamps
|
||||
|
||||
ts = _make_weekday_series("2018-08-27", "2018-09-14")
|
||||
# Remove Friday 2018-09-07 (simulate holiday)
|
||||
# ts contains date objects, so compare with date
|
||||
friday_to_remove = date(2018, 9, 7)
|
||||
ts_list = [d for d in ts.to_list() if d != friday_to_remove]
|
||||
ts_filtered = pl.Series("ts", ts_list)
|
||||
|
||||
result = resolve_rebalance_timestamps(ts_filtered, "weekly_friday_close")
|
||||
|
||||
# The week of Sep 3-7 should still have a rebalance, but on Thursday Sep 6
|
||||
week_36_dates = [dt for dt in result.to_list() if dt.isocalendar()[1] == 36]
|
||||
assert len(week_36_dates) == 1
|
||||
assert week_36_dates[0] == date(2018, 9, 6), (
|
||||
f"Expected Thursday fallback, got {week_36_dates[0]}"
|
||||
)
|
||||
|
||||
def test_daily_returns_all_timestamps(self):
|
||||
"""Daily cadence should return every available timestamp."""
|
||||
from case_studies.utils.backtest_loaders import resolve_rebalance_timestamps
|
||||
|
||||
ts = _make_weekday_series("2020-01-01", "2020-01-31")
|
||||
for cadence in ("daily", "daily_close", "daily_ny_close"):
|
||||
result = resolve_rebalance_timestamps(ts, cadence)
|
||||
assert len(result) == len(ts), f"{cadence}: expected all {len(ts)} dates"
|
||||
|
||||
def test_eight_hour_returns_all_timestamps(self):
|
||||
"""8-hour funding cadence should return all timestamps."""
|
||||
from case_studies.utils.backtest_loaders import resolve_rebalance_timestamps
|
||||
|
||||
dates = pl.datetime_range(
|
||||
datetime(2020, 1, 1),
|
||||
datetime(2020, 1, 31),
|
||||
interval="8h",
|
||||
eager=True,
|
||||
)
|
||||
result = resolve_rebalance_timestamps(dates, "8_hour_funding_aligned")
|
||||
assert len(result) == len(dates.unique())
|
||||
|
||||
def test_empty_series(self):
|
||||
"""Empty input should return empty output."""
|
||||
from case_studies.utils.backtest_loaders import resolve_rebalance_timestamps
|
||||
|
||||
ts = pl.Series("ts", [], dtype=pl.Datetime("us"))
|
||||
result = resolve_rebalance_timestamps(ts, "monthly_month_end")
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Execution delay mapping tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExecutionDelayMapping:
|
||||
"""Test explicit execution delay -> ExecutionMode mapping."""
|
||||
|
||||
def test_next_bar_open_maps_to_next_bar(self):
|
||||
from ml4t.backtest import ExecutionMode
|
||||
|
||||
from case_studies.utils.backtest_presets import (
|
||||
resolve_execution_mode as _resolve_execution_mode,
|
||||
)
|
||||
|
||||
assert _resolve_execution_mode("NEXT_BAR_OPEN") == ExecutionMode.NEXT_BAR
|
||||
assert _resolve_execution_mode("next_bar_open") == ExecutionMode.NEXT_BAR
|
||||
|
||||
def test_monday_open_maps_to_next_bar(self):
|
||||
"""Regression: monday_open must NOT fall through to SAME_BAR."""
|
||||
from ml4t.backtest import ExecutionMode
|
||||
|
||||
from case_studies.utils.backtest_presets import (
|
||||
resolve_execution_mode as _resolve_execution_mode,
|
||||
)
|
||||
|
||||
assert _resolve_execution_mode("MONDAY_OPEN") == ExecutionMode.NEXT_BAR
|
||||
assert _resolve_execution_mode("monday_open") == ExecutionMode.NEXT_BAR
|
||||
|
||||
def test_1_bar_maps_to_next_bar(self):
|
||||
from ml4t.backtest import ExecutionMode
|
||||
|
||||
from case_studies.utils.backtest_presets import (
|
||||
resolve_execution_mode as _resolve_execution_mode,
|
||||
)
|
||||
|
||||
assert _resolve_execution_mode("1_BAR") == ExecutionMode.NEXT_BAR
|
||||
assert _resolve_execution_mode("1_bar") == ExecutionMode.NEXT_BAR
|
||||
|
||||
def test_at_funding_timestamp_maps_to_same_bar(self):
|
||||
from ml4t.backtest import ExecutionMode
|
||||
|
||||
from case_studies.utils.backtest_presets import (
|
||||
resolve_execution_mode as _resolve_execution_mode,
|
||||
)
|
||||
|
||||
assert _resolve_execution_mode("AT_FUNDING_TIMESTAMP") == ExecutionMode.SAME_BAR
|
||||
|
||||
def test_unknown_token_raises_value_error(self):
|
||||
"""Unknown execution delay must raise, not silently degrade."""
|
||||
from case_studies.utils.backtest_presets import (
|
||||
resolve_execution_mode as _resolve_execution_mode,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Unknown execution delay"):
|
||||
_resolve_execution_mode("SOME_RANDOM_TOKEN")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vectorized thinning tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestThinToRebalanceDates:
|
||||
"""Test that vectorized thinning uses calendar-aware schedule."""
|
||||
|
||||
def test_weekly_friday_keeps_fridays_not_every_5th(self):
|
||||
"""Regression: sp500_options weekly_friday must keep actual Fridays."""
|
||||
from case_studies.utils.backtest_loaders import thin_to_rebalance_dates
|
||||
|
||||
dates = _make_weekday_series("2020-01-01", "2020-02-28")
|
||||
|
||||
preds = pl.DataFrame(
|
||||
{
|
||||
"timestamp": dates,
|
||||
"symbol": ["SPY"] * len(dates),
|
||||
"y_score": [0.1] * len(dates),
|
||||
"y_true": [0.01] * len(dates),
|
||||
}
|
||||
)
|
||||
|
||||
# fwd_ret_5d on weekly_friday schedule: step=1 (5d horizon <= 7d gap)
|
||||
result = thin_to_rebalance_dates(preds, cadence="weekly_friday", step=1)
|
||||
|
||||
unique_dates = result["timestamp"].unique().sort()
|
||||
# Exclude last date (may be incomplete week)
|
||||
for dt in unique_dates.to_list()[:-1]:
|
||||
assert dt.weekday() in (3, 4), f"Expected Thu/Fri, got {dt} (weekday={dt.weekday()})"
|
||||
|
||||
def test_monthly_month_end_keeps_month_ends(self):
|
||||
"""Regression: ETFs monthly_month_end must keep actual month-end sessions."""
|
||||
from case_studies.utils.backtest_loaders import thin_to_rebalance_dates
|
||||
|
||||
dates = _make_weekday_series("2016-01-01", "2016-06-30")
|
||||
all_dates = dates.to_list()
|
||||
|
||||
preds = pl.DataFrame(
|
||||
{
|
||||
"timestamp": dates,
|
||||
"symbol": ["SPY"] * len(dates),
|
||||
"y_score": [0.1] * len(dates),
|
||||
"y_true": [0.01] * len(dates),
|
||||
}
|
||||
)
|
||||
|
||||
# fwd_ret_21d on monthly_month_end schedule: step=1 (21d horizon <= 30d gap)
|
||||
result = thin_to_rebalance_dates(preds, cadence="monthly_month_end", step=1)
|
||||
|
||||
unique_dates = result["timestamp"].unique().sort()
|
||||
assert 5 <= len(unique_dates) <= 6
|
||||
|
||||
for dt in unique_dates.to_list():
|
||||
month, year = dt.month, dt.year
|
||||
later = [d for d in all_dates if d.year == year and d.month == month and d > dt]
|
||||
assert len(later) == 0, f"{dt} is not the last trading day of {year}-{month:02d}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rebalance-step lookup (declared in each case study's setup.yaml)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetRebalanceStep:
|
||||
"""Verify that per-label thinning steps are read from setup.yaml.
|
||||
|
||||
Replaces the legacy regex-based implementation which silently returned
|
||||
step=1 for any label without a digit-unit token (e.g., ``ret_to_expiry``),
|
||||
producing 4-5× inflated Sharpe for overlapping-cohort strategies.
|
||||
|
||||
The step is now a design-time constant declared under
|
||||
``labels.rebalance_step`` in each case study's setup.yaml.
|
||||
"""
|
||||
|
||||
def test_sp500_options_ret_to_expiry_is_5(self):
|
||||
"""Regression: ret_to_expiry must thin weekly_friday cohorts by 5.
|
||||
|
||||
30-day DTE on a 7-day schedule -> ceil(30/7) = 5. Pre-fix this
|
||||
silently returned 1 (overlapping 5-cohort double-counting).
|
||||
"""
|
||||
from case_studies.utils.backtest_loaders import get_rebalance_step
|
||||
|
||||
assert get_rebalance_step("sp500_options", "ret_to_expiry") == 5
|
||||
|
||||
def test_cme_fwd_ret_21d_is_3(self):
|
||||
"""cme_futures fwd_ret_21d on weekly_friday -> ceil(21/7) = 3."""
|
||||
from case_studies.utils.backtest_loaders import get_rebalance_step
|
||||
|
||||
assert get_rebalance_step("cme_futures", "fwd_ret_21d") == 3
|
||||
|
||||
def test_us_firm_fwd_ret_1m_is_1(self):
|
||||
"""Monthly label on monthly schedule -> step=1."""
|
||||
from case_studies.utils.backtest_loaders import get_rebalance_step
|
||||
|
||||
assert get_rebalance_step("us_firm_characteristics", "fwd_ret_1m") == 1
|
||||
|
||||
def test_nasdaq100_fwd_ret_60m_is_4(self):
|
||||
"""nasdaq100 fwd_ret_60m on 15-minute schedule -> ceil(60/15) = 4."""
|
||||
from case_studies.utils.backtest_loaders import get_rebalance_step
|
||||
|
||||
assert get_rebalance_step("nasdaq100_microstructure", "fwd_ret_60m") == 4
|
||||
|
||||
def test_nasdaq100_fwd_ret_5m_is_1(self):
|
||||
"""Regression: fwd_ret_5m on 15-minute schedule must stay at 1.
|
||||
|
||||
Pre-fix, the regex matched `(5, m)` and the old `n <= 12` branch
|
||||
mis-read it as 5 MONTHS, computing step ~10,000 and collapsing
|
||||
backtests to a handful of points.
|
||||
"""
|
||||
from case_studies.utils.backtest_loaders import get_rebalance_step
|
||||
|
||||
assert get_rebalance_step("nasdaq100_microstructure", "fwd_ret_5m") == 1
|
||||
|
||||
def test_crypto_fwd_ret_24h_is_3(self):
|
||||
"""crypto 24h label on 8h schedule -> ceil(24/8) = 3."""
|
||||
from case_studies.utils.backtest_loaders import get_rebalance_step
|
||||
|
||||
assert get_rebalance_step("crypto_perps_funding", "fwd_ret_24h") == 3
|
||||
|
||||
def test_unknown_label_raises(self):
|
||||
"""Unknown label must raise KeyError pointing at setup.yaml."""
|
||||
from case_studies.utils.backtest_loaders import get_rebalance_step
|
||||
|
||||
with pytest.raises(KeyError, match="rebalance_step"):
|
||||
get_rebalance_step("sp500_options", "fwd_ret_unknown_label")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: engine schedule set membership
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEngineScheduleIntegration:
|
||||
"""Verify the engine path builds correct schedule sets."""
|
||||
|
||||
def test_etf_monthly_schedule_matches_month_ends(self):
|
||||
"""ETF backtest should rebalance on actual month-end sessions."""
|
||||
from case_studies.utils.backtest_loaders import resolve_rebalance_timestamps
|
||||
|
||||
dates = _make_weekday_series("2015-12-01", "2016-04-30")
|
||||
schedule = resolve_rebalance_timestamps(dates, "monthly_month_end")
|
||||
all_dates = dates.to_list()
|
||||
|
||||
for dt in schedule.to_list():
|
||||
month, year = dt.month, dt.year
|
||||
later = [d for d in all_dates if d.month == month and d.year == year and d > dt]
|
||||
assert len(later) == 0, f"Rebalance {dt} is not month-end: later dates {later[:3]}"
|
||||
|
||||
def test_cme_weekly_schedule_matches_fridays(self):
|
||||
"""CME backtest should rebalance on Friday sessions."""
|
||||
from case_studies.utils.backtest_loaders import resolve_rebalance_timestamps
|
||||
|
||||
dates = _make_weekday_series("2018-08-01", "2018-10-26") # End on a Friday
|
||||
schedule = resolve_rebalance_timestamps(dates, "weekly_friday_close")
|
||||
|
||||
for dt in schedule.to_list():
|
||||
assert dt.weekday() == 4, (
|
||||
f"CME rebalance {dt} should be Friday, got weekday={dt.weekday()}"
|
||||
)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Test case study pipeline notebooks via Papermill parameter injection.
|
||||
|
||||
Each case study notebook runs independently against pre-generated intermediates
|
||||
(labels, features, predictions, registries) stored in the test-data repo.
|
||||
A failure in one notebook does NOT cascade to skip later notebooks.
|
||||
|
||||
Stages are auto-discovered: any [0-9][0-9]_*.py file in a case study
|
||||
directory is treated as a pipeline stage.
|
||||
|
||||
Usage:
|
||||
# All case studies
|
||||
pytest tests/test_case_studies.py -v
|
||||
|
||||
# Specific case study
|
||||
pytest tests/test_case_studies.py -v -k "etfs"
|
||||
|
||||
# Specific stage
|
||||
pytest tests/test_case_studies.py -v -k "03_features"
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.pm_helpers import current_test_tier, get_overrides, get_tier, run_notebook
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
|
||||
# All case studies
|
||||
CASE_STUDIES = [
|
||||
"etfs",
|
||||
"crypto_perps_funding",
|
||||
"nasdaq100_microstructure",
|
||||
"sp500_equity_option_analytics",
|
||||
"us_firm_characteristics",
|
||||
"fx_pairs",
|
||||
"cme_futures",
|
||||
"sp500_options",
|
||||
"us_equities_panel",
|
||||
]
|
||||
|
||||
# Pattern for numbered pipeline stages — allows optional single-letter suffix
|
||||
# (e.g., 10a_pca, 11b_ipca) for per-estimator notebook splits.
|
||||
_STAGE_RE = re.compile(r"^\d{2}[a-z]?_")
|
||||
|
||||
|
||||
def _collect_case_study_tests():
|
||||
"""Collect all case study pipeline notebooks as (case_study, stage, path) tuples.
|
||||
|
||||
Auto-discovers files matching ^\\d{2}[a-z]?_ in each case study directory,
|
||||
sorted numerically. Skips helper files (starting with _).
|
||||
"""
|
||||
tests = []
|
||||
for cs in CASE_STUDIES:
|
||||
cs_dir = REPO_ROOT / "case_studies" / cs
|
||||
if not cs_dir.exists():
|
||||
continue
|
||||
|
||||
for notebook in sorted(cs_dir.glob("[0-9][0-9]*.py")):
|
||||
if notebook.name.startswith("_"):
|
||||
continue
|
||||
if not _STAGE_RE.match(notebook.name):
|
||||
continue
|
||||
stage = notebook.stem # e.g., "06_linear" or "11a_pca"
|
||||
tests.append((cs, stage, notebook))
|
||||
|
||||
return tests
|
||||
|
||||
|
||||
CASE_STUDY_TESTS = _collect_case_study_tests()
|
||||
|
||||
print(f"Found {len(CASE_STUDY_TESTS)} case study pipeline notebooks to test")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case_study,stage,notebook_path",
|
||||
CASE_STUDY_TESTS,
|
||||
ids=lambda *args: None, # Custom IDs below
|
||||
)
|
||||
def test_case_study_pipeline(
|
||||
case_study, stage, notebook_path, populated_data_dir, seeded_output_dir
|
||||
):
|
||||
"""Execute a case study pipeline stage via Papermill.
|
||||
|
||||
Each notebook runs independently — intermediates (labels, features,
|
||||
predictions, registries) are pre-generated in the test-data repo.
|
||||
"""
|
||||
# Check case-study-level skip (e.g., "case_studies/nasdaq100_microstructure")
|
||||
cs_key = f"case_studies/{case_study}"
|
||||
cs_overrides = get_overrides(cs_key)
|
||||
if cs_overrides.get("skip"):
|
||||
pytest.skip(f"Skipped: {cs_overrides.get('skip_reason', 'case study skipped')}")
|
||||
|
||||
rel_path = notebook_path.relative_to(REPO_ROOT).with_suffix("")
|
||||
overrides = get_overrides(str(rel_path))
|
||||
|
||||
# Tier routing: skip when NB tier doesn't match the current run tier.
|
||||
nb_tier = get_tier(overrides)
|
||||
run_tier = current_test_tier()
|
||||
if nb_tier != run_tier:
|
||||
pytest.skip(f"Tier {nb_tier} — current run tier is {run_tier}")
|
||||
|
||||
# Skip if overrides say so
|
||||
if overrides.get("skip"):
|
||||
reason = overrides.get("skip_reason", "marked skip in overrides")
|
||||
pytest.skip(f"Skipped: {reason}")
|
||||
|
||||
# Check required imports (e.g., gensim, signatory, duckdb)
|
||||
requires = overrides.get("requires_import")
|
||||
if requires:
|
||||
pkg = requires if isinstance(requires, str) else requires[0]
|
||||
try:
|
||||
__import__(pkg)
|
||||
except ImportError:
|
||||
pytest.skip(f"Requires {pkg} (not installed in this Docker image)")
|
||||
|
||||
# Check GPU requirement
|
||||
if overrides.get("gpu"):
|
||||
try:
|
||||
import torch
|
||||
|
||||
if not torch.cuda.is_available():
|
||||
pytest.skip("GPU required but not available")
|
||||
except ImportError:
|
||||
pytest.skip("GPU required but torch not installed")
|
||||
|
||||
timeout = overrides.get("timeout", 300)
|
||||
parameters = overrides.get("parameters", {})
|
||||
|
||||
result = run_notebook(
|
||||
py_path=notebook_path,
|
||||
parameters=parameters,
|
||||
timeout=timeout,
|
||||
output_dir=seeded_output_dir,
|
||||
data_dir=populated_data_dir,
|
||||
)
|
||||
|
||||
if result["status"] == "error":
|
||||
pytest.fail(
|
||||
f"\n{'=' * 70}\n"
|
||||
f"Pipeline failed: {case_study}::{stage}\n"
|
||||
f"{'=' * 70}\n"
|
||||
f"Error: {result['error']}\n"
|
||||
f"{'=' * 70}\n"
|
||||
)
|
||||
|
||||
|
||||
# Custom test IDs
|
||||
def pytest_collection_modifyitems(items):
|
||||
"""Set readable test IDs for case study tests."""
|
||||
for item in items:
|
||||
if "test_case_study_pipeline" in item.name and hasattr(item, "callspec"):
|
||||
cs = item.callspec.params.get("case_study", "")
|
||||
stage = item.callspec.params.get("stage", "")
|
||||
item._nodeid = f"{item.parent.nodeid}::{cs}::{stage}"
|
||||
@@ -0,0 +1,495 @@
|
||||
"""Tests for case_studies/utils/analytics.py — registry query contracts.
|
||||
|
||||
Per memory rule ``feedback_results_json``: "Registry only, never JSONs."
|
||||
This module is the canonical path Ch6–Ch20 insights notebooks take to pull
|
||||
IC / AUC / Sharpe numbers into the book. A silent regression in any of
|
||||
these queries would corrupt every cross-chapter summary table.
|
||||
|
||||
The tests pin three layers:
|
||||
|
||||
1. **Metadata invariants** — the handful of dicts that declare the 9
|
||||
case-study IDs must agree on keys, so a new case study can't be added
|
||||
to one dict and forgotten in another.
|
||||
|
||||
2. **Path resolution** — ``_cs_dir`` / ``_registry_path`` honor
|
||||
``ML4T_OUTPUT_DIR`` for test isolation.
|
||||
|
||||
3. **Query contracts** — against a seeded SQLite registry:
|
||||
- ``load_model_ic`` filters by family, split, case_studies list and
|
||||
returns the expected rows with a ``case_study`` label column.
|
||||
- ``load_classification_metrics`` requires ``task_type = 'classification'``.
|
||||
- ``load_best_ic_per_family`` picks max IC per (case_study, family)
|
||||
pair and optionally restricts to the primary label.
|
||||
- ``load_chapter_backtests("ch16")`` maps to ``stage="signal"`` and
|
||||
joins backtest_runs × backtest_metrics × prediction_sets ×
|
||||
training_runs.
|
||||
- Spec helpers: ``extract_cost_bps`` sums commission + slippage
|
||||
from either v1 or v2 backtest specs; ``extract_allocator`` reads
|
||||
strategy.allocation.method.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
from case_studies.utils import analytics
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Metadata invariants
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_case_study_ids_match_metadata_keys() -> None:
|
||||
assert list(analytics.CASE_STUDY_META.keys()) == analytics.CASE_STUDY_IDS
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dict_name",
|
||||
["PRIMARY_LABELS", "SHORT_NAMES", "DATASET_META", "CADENCE_MAP", "DISPLAY_NAMES"],
|
||||
)
|
||||
def test_metadata_dict_keys_match_case_study_ids(dict_name) -> None:
|
||||
"""Every metadata dict must enumerate the same 9 case studies — otherwise
|
||||
cross-dict joins in load_best_ic_per_family / load_chapter_backtests drop
|
||||
rows silently.
|
||||
"""
|
||||
d = getattr(analytics, dict_name)
|
||||
assert set(d.keys()) == set(analytics.CASE_STUDY_IDS), (
|
||||
f"{dict_name} keys differ: missing {set(analytics.CASE_STUDY_IDS) - set(d.keys())}, "
|
||||
f"extra {set(d.keys()) - set(analytics.CASE_STUDY_IDS)}"
|
||||
)
|
||||
|
||||
|
||||
def test_primary_labels_are_non_empty_strings() -> None:
|
||||
for cs, lbl in analytics.PRIMARY_LABELS.items():
|
||||
assert isinstance(lbl, str) and lbl, f"{cs}: empty/non-string primary label"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Path resolution
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cs_dir_production_path(monkeypatch) -> None:
|
||||
"""With no ML4T_OUTPUT_DIR, _cs_dir falls back to REPO_ROOT/case_studies."""
|
||||
monkeypatch.delenv("ML4T_OUTPUT_DIR", raising=False)
|
||||
from utils.paths import REPO_ROOT
|
||||
|
||||
assert analytics._cs_dir() == REPO_ROOT / "case_studies"
|
||||
|
||||
|
||||
def test_cs_dir_redirects_to_output_dir_when_registry_present(tmp_path, monkeypatch) -> None:
|
||||
"""With ML4T_OUTPUT_DIR set AND a registry.db present under tmp, _cs_dir
|
||||
returns the tmp root instead of the production case_studies path.
|
||||
"""
|
||||
monkeypatch.setenv("ML4T_OUTPUT_DIR", str(tmp_path))
|
||||
(tmp_path / "etfs" / "run_log").mkdir(parents=True)
|
||||
(tmp_path / "etfs" / "run_log" / "registry.db").touch()
|
||||
assert analytics._cs_dir("etfs") == tmp_path
|
||||
|
||||
|
||||
def test_cs_dir_falls_back_when_registry_missing_under_output_dir(tmp_path, monkeypatch) -> None:
|
||||
"""ML4T_OUTPUT_DIR set but no registry.db under it → fall back to production."""
|
||||
monkeypatch.setenv("ML4T_OUTPUT_DIR", str(tmp_path))
|
||||
from utils.paths import REPO_ROOT
|
||||
|
||||
assert analytics._cs_dir("etfs") == REPO_ROOT / "case_studies"
|
||||
|
||||
|
||||
def test_registry_path_is_three_levels_deep(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("ML4T_OUTPUT_DIR", str(tmp_path))
|
||||
(tmp_path / "etfs" / "run_log").mkdir(parents=True)
|
||||
(tmp_path / "etfs" / "run_log" / "registry.db").touch()
|
||||
p = analytics._registry_path("etfs")
|
||||
assert p == tmp_path / "etfs" / "run_log" / "registry.db"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# _query behavior on empty / missing databases
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_query_returns_empty_df_when_path_missing(tmp_path) -> None:
|
||||
missing = tmp_path / "nope.db"
|
||||
out = analytics._query(missing, "SELECT 1")
|
||||
assert isinstance(out, pl.DataFrame)
|
||||
assert out.is_empty()
|
||||
|
||||
|
||||
def test_query_returns_empty_df_when_no_rows(tmp_path) -> None:
|
||||
db = tmp_path / "empty.db"
|
||||
conn = sqlite3.connect(str(db))
|
||||
conn.execute("CREATE TABLE t (x INTEGER)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
out = analytics._query(db, "SELECT * FROM t")
|
||||
assert out.is_empty()
|
||||
|
||||
|
||||
def test_query_returns_populated_df(tmp_path) -> None:
|
||||
db = tmp_path / "rows.db"
|
||||
conn = sqlite3.connect(str(db))
|
||||
conn.execute("CREATE TABLE t (x INTEGER, y TEXT)")
|
||||
conn.executemany("INSERT INTO t VALUES (?, ?)", [(1, "a"), (2, "b")])
|
||||
conn.commit()
|
||||
conn.close()
|
||||
out = analytics._query(db, "SELECT * FROM t ORDER BY x")
|
||||
assert out.to_dicts() == [{"x": 1, "y": "a"}, {"x": 2, "y": "b"}]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Seeded-registry fixture: builds the schema + minimal rows
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_registry_schema(conn: sqlite3.Connection) -> None:
|
||||
"""Create the subset of the registry schema the analytics queries touch."""
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE training_runs (
|
||||
training_hash TEXT PRIMARY KEY,
|
||||
family TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
config_name TEXT,
|
||||
spec_json TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE prediction_sets (
|
||||
prediction_hash TEXT PRIMARY KEY,
|
||||
training_hash TEXT NOT NULL,
|
||||
checkpoint_value INTEGER,
|
||||
checkpoint_kind TEXT,
|
||||
split TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE prediction_metrics (
|
||||
prediction_hash TEXT PRIMARY KEY,
|
||||
computed_at TEXT NOT NULL,
|
||||
ic_mean REAL,
|
||||
ic_std REAL,
|
||||
ic_t REAL,
|
||||
n_folds REAL,
|
||||
pct_positive REAL,
|
||||
task_type TEXT,
|
||||
accuracy REAL,
|
||||
balanced_accuracy REAL,
|
||||
auc_roc REAL,
|
||||
auc_pr REAL,
|
||||
log_loss REAL,
|
||||
brier_score REAL
|
||||
);
|
||||
CREATE TABLE backtest_runs (
|
||||
backtest_hash TEXT PRIMARY KEY,
|
||||
prediction_hash TEXT NOT NULL,
|
||||
spec_json TEXT,
|
||||
stage TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE backtest_metrics (
|
||||
backtest_hash TEXT PRIMARY KEY,
|
||||
computed_at TEXT NOT NULL,
|
||||
sharpe REAL,
|
||||
sortino REAL,
|
||||
total_return REAL,
|
||||
max_drawdown REAL
|
||||
);
|
||||
CREATE TABLE fold_metrics (
|
||||
prediction_hash TEXT NOT NULL,
|
||||
fold_id INTEGER NOT NULL,
|
||||
computed_at TEXT NOT NULL,
|
||||
ic REAL,
|
||||
PRIMARY KEY (prediction_hash, fold_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _seed_registry(db_path: Path) -> None:
|
||||
"""Populate a registry with 4 training runs + predictions + 2 backtests.
|
||||
|
||||
Layout (etfs):
|
||||
linear_a / fwd_ret_21d / validation / ic=0.05 / regression
|
||||
linear_b / fwd_ret_21d / validation / ic=0.08 / regression <- best linear
|
||||
gbm_a / fwd_ret_21d / validation / ic=0.10 / regression <- best gbm (primary)
|
||||
gbm_a / fwd_ret_21d / holdout / ic=0.03 / regression
|
||||
linear_c / fwd_dir_5d / validation / ic=0.04 / classification (task_type='classification')
|
||||
Plus 1 ch16 (signal) backtest and 1 ch17 (allocation) backtest on the
|
||||
same gbm prediction_hash.
|
||||
"""
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
_create_registry_schema(conn)
|
||||
|
||||
# training_runs
|
||||
conn.executemany(
|
||||
"INSERT INTO training_runs VALUES (?, ?, ?, ?, ?, ?)",
|
||||
[
|
||||
("th_lin_a", "linear", "fwd_ret_21d", "ridge_a100", None, "2024-01-01T00:00:00"),
|
||||
("th_lin_b", "linear", "fwd_ret_21d", "ridge_b100", None, "2024-01-01T00:00:00"),
|
||||
("th_gbm_a", "gbm", "fwd_ret_21d", "default", None, "2024-01-01T00:00:00"),
|
||||
("th_lin_c", "linear", "fwd_dir_5d", "logistic", None, "2024-01-01T00:00:00"),
|
||||
],
|
||||
)
|
||||
# prediction_sets
|
||||
conn.executemany(
|
||||
"INSERT INTO prediction_sets VALUES (?, ?, ?, ?, ?, ?)",
|
||||
[
|
||||
("ph_lin_a_val", "th_lin_a", 0, "final", "validation", "2024-01-02T00:00:00"),
|
||||
("ph_lin_b_val", "th_lin_b", 0, "final", "validation", "2024-01-02T00:00:00"),
|
||||
("ph_gbm_a_val", "th_gbm_a", 100, "final", "validation", "2024-01-02T00:00:00"),
|
||||
("ph_gbm_a_hol", "th_gbm_a", 100, "final", "holdout", "2024-01-02T00:00:00"),
|
||||
("ph_lin_c_val", "th_lin_c", 0, "final", "validation", "2024-01-02T00:00:00"),
|
||||
],
|
||||
)
|
||||
# prediction_metrics — regression and classification
|
||||
conn.executemany(
|
||||
"INSERT INTO prediction_metrics (prediction_hash, computed_at, ic_mean, task_type, "
|
||||
"auc_roc, accuracy) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
[
|
||||
("ph_lin_a_val", "2024-01-03", 0.05, "regression", None, None),
|
||||
("ph_lin_b_val", "2024-01-03", 0.08, "regression", None, None),
|
||||
("ph_gbm_a_val", "2024-01-03", 0.10, "regression", None, None),
|
||||
("ph_gbm_a_hol", "2024-01-03", 0.03, "regression", None, None),
|
||||
("ph_lin_c_val", "2024-01-03", 0.04, "classification", 0.62, 0.55),
|
||||
],
|
||||
)
|
||||
# backtest_runs — one signal (ch16) + one allocation (ch17) on the gbm prediction
|
||||
spec_v2 = {
|
||||
"version": 2,
|
||||
"strategy": {"allocation": {"method": "inverse_vol"}},
|
||||
"backtest_config": {
|
||||
"commission": {"rate": 0.0005}, # 5 bps
|
||||
"slippage": {"rate": 0.0003}, # 3 bps
|
||||
},
|
||||
}
|
||||
conn.executemany(
|
||||
"INSERT INTO backtest_runs VALUES (?, ?, ?, ?, ?)",
|
||||
[
|
||||
("bh_sig", "ph_gbm_a_val", json.dumps(spec_v2), "signal", "2024-01-04"),
|
||||
("bh_alloc", "ph_gbm_a_val", json.dumps(spec_v2), "allocation", "2024-01-04"),
|
||||
],
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO backtest_metrics (backtest_hash, computed_at, sharpe, sortino, "
|
||||
"total_return, max_drawdown) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
[
|
||||
("bh_sig", "2024-01-05", 1.2, 1.8, 0.35, -0.10),
|
||||
("bh_alloc", "2024-01-05", 1.5, 2.2, 0.45, -0.08),
|
||||
],
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seeded_registries(tmp_path, monkeypatch) -> Path:
|
||||
"""Build registries for etfs + crypto_perps_funding under a temp output dir.
|
||||
|
||||
The second case study (crypto) is intentionally empty (only schema) so
|
||||
multi-case-study queries have a no-op partition to merge against.
|
||||
"""
|
||||
monkeypatch.setenv("ML4T_OUTPUT_DIR", str(tmp_path))
|
||||
_seed_registry(tmp_path / "etfs" / "run_log" / "registry.db")
|
||||
|
||||
# crypto: schema but no rows
|
||||
crypto_db = tmp_path / "crypto_perps_funding" / "run_log" / "registry.db"
|
||||
crypto_db.parent.mkdir(parents=True)
|
||||
conn = sqlite3.connect(str(crypto_db))
|
||||
_create_registry_schema(conn)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# load_model_ic
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_load_model_ic_returns_all_families_by_default(seeded_registries) -> None:
|
||||
df = analytics.load_model_ic(case_studies=["etfs", "crypto_perps_funding"], split="validation")
|
||||
# etfs: 3 validation rows (lin_a, lin_b, gbm_a) with regression task_type;
|
||||
# lin_c is also validation but the query doesn't filter task_type here.
|
||||
assert df.height == 4
|
||||
assert set(df["case_study"].unique().to_list()) == {"etfs"}
|
||||
assert set(df["family"].unique().to_list()) == {"linear", "gbm"}
|
||||
|
||||
|
||||
def test_load_model_ic_filters_by_family(seeded_registries) -> None:
|
||||
df = analytics.load_model_ic(families="gbm", case_studies=["etfs"], split="validation")
|
||||
assert df["family"].unique().to_list() == ["gbm"]
|
||||
assert df.height == 1
|
||||
|
||||
|
||||
def test_load_model_ic_filters_by_split_holdout(seeded_registries) -> None:
|
||||
df = analytics.load_model_ic(case_studies=["etfs"], split="holdout")
|
||||
# only gbm_a has a holdout prediction
|
||||
assert df.height == 1
|
||||
assert df["split"].to_list() == ["holdout"]
|
||||
assert df["ic_mean"].to_list() == [0.03]
|
||||
|
||||
|
||||
def test_load_model_ic_returns_empty_when_no_case_study_has_data(seeded_registries) -> None:
|
||||
df = analytics.load_model_ic(case_studies=["crypto_perps_funding"], split="validation")
|
||||
assert df.is_empty()
|
||||
|
||||
|
||||
def test_load_model_ic_has_case_study_label_column(seeded_registries) -> None:
|
||||
df = analytics.load_model_ic(case_studies=["etfs"], split="validation")
|
||||
assert "case_study" in df.columns
|
||||
assert df["case_study"].unique().to_list() == ["etfs"]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# load_classification_metrics
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_load_classification_metrics_filters_task_type_eq_1(seeded_registries) -> None:
|
||||
"""Only the linear_c row (task_type='classification') should come back."""
|
||||
df = analytics.load_classification_metrics(case_studies=["etfs"], split="validation")
|
||||
assert df.height == 1
|
||||
assert df["family"].to_list() == ["linear"]
|
||||
assert df["auc_roc"].to_list() == [0.62]
|
||||
assert df["task_type"].to_list() == ["classification"]
|
||||
|
||||
|
||||
def test_load_classification_metrics_excludes_regression_rows(seeded_registries) -> None:
|
||||
"""Regression rows (task_type='regression') must not leak into the classification view."""
|
||||
df = analytics.load_classification_metrics(case_studies=["etfs"], split="validation")
|
||||
# Spec: no rows with null AUC should appear
|
||||
assert df.filter(pl.col("auc_roc").is_null()).is_empty()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# load_best_ic_per_family
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_load_best_ic_per_family_picks_max_per_pair(seeded_registries) -> None:
|
||||
"""Primary label for etfs is fwd_ret_21d. Among linear runs on that label,
|
||||
lin_b (IC=0.08) beats lin_a (IC=0.05). gbm has only one run (IC=0.10).
|
||||
"""
|
||||
best = analytics.load_best_ic_per_family(case_studies=["etfs"], split="validation")
|
||||
rows = {(r["case_study"], r["family"]): r for r in best.to_dicts()}
|
||||
assert rows[("etfs", "linear")]["ic_mean"] == 0.08
|
||||
assert rows[("etfs", "linear")]["config_name"] == "ridge_b100"
|
||||
assert rows[("etfs", "gbm")]["ic_mean"] == 0.10
|
||||
|
||||
|
||||
def test_load_best_ic_per_family_primary_label_excludes_other_labels(
|
||||
seeded_registries,
|
||||
) -> None:
|
||||
"""With use_primary_label=True (default), the linear_c run on fwd_dir_5d must
|
||||
not appear — only rows with label == primary are kept.
|
||||
"""
|
||||
best = analytics.load_best_ic_per_family(case_studies=["etfs"], split="validation")
|
||||
assert all(row["label"] == "fwd_ret_21d" for row in best.to_dicts())
|
||||
|
||||
|
||||
def test_load_best_ic_per_family_use_primary_label_false_includes_all(
|
||||
seeded_registries,
|
||||
) -> None:
|
||||
"""With use_primary_label=False, the fwd_dir_5d row competes for best linear."""
|
||||
best = analytics.load_best_ic_per_family(
|
||||
case_studies=["etfs"], split="validation", use_primary_label=False
|
||||
)
|
||||
# linear: best of {lin_a 0.05, lin_b 0.08, lin_c 0.04} is lin_b
|
||||
linear_row = next(r for r in best.to_dicts() if r["family"] == "linear")
|
||||
assert linear_row["ic_mean"] == 0.08
|
||||
|
||||
|
||||
def test_load_best_ic_per_family_adds_display_name(seeded_registries) -> None:
|
||||
best = analytics.load_best_ic_per_family(case_studies=["etfs"], split="validation")
|
||||
assert "display_name" in best.columns
|
||||
assert best["display_name"].unique().to_list() == ["ETFs"]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# load_chapter_backtests
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_load_chapter_backtests_ch16_maps_to_signal_stage(seeded_registries) -> None:
|
||||
"""chapter='ch16' → stage='signal' → returns the bh_sig backtest row."""
|
||||
df = analytics.load_chapter_backtests("ch16", case_studies=["etfs"])
|
||||
assert df.height == 1
|
||||
assert df["backtest_hash"].to_list() == ["bh_sig"]
|
||||
|
||||
|
||||
def test_load_chapter_backtests_explicit_stage_overrides_chapter(seeded_registries) -> None:
|
||||
df = analytics.load_chapter_backtests("ch16", stage="allocation", case_studies=["etfs"])
|
||||
assert df["backtest_hash"].to_list() == ["bh_alloc"]
|
||||
|
||||
|
||||
def test_load_chapter_backtests_joins_sharpe_and_training_columns(seeded_registries) -> None:
|
||||
df = analytics.load_chapter_backtests("ch17", case_studies=["etfs"])
|
||||
assert df.height == 1
|
||||
row = df.to_dicts()[0]
|
||||
assert row["sharpe"] == 1.5
|
||||
assert row["family"] == "gbm"
|
||||
assert row["config_name"] == "default"
|
||||
|
||||
|
||||
def test_load_chapter_backtests_metrics_filter_selects_columns(seeded_registries) -> None:
|
||||
df = analytics.load_chapter_backtests("ch17", case_studies=["etfs"], metrics=["sharpe"])
|
||||
# Only meta columns + sharpe; sortino/total_return/max_drawdown excluded
|
||||
assert "sharpe" in df.columns
|
||||
assert "sortino" not in df.columns
|
||||
assert "total_return" not in df.columns
|
||||
|
||||
|
||||
def test_load_chapter_backtests_returns_empty_for_unused_stage(seeded_registries) -> None:
|
||||
df = analytics.load_chapter_backtests("ch18", case_studies=["etfs"])
|
||||
assert df.is_empty()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Spec helpers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_backtest_spec_round_trips_json() -> None:
|
||||
spec = {"version": 2, "strategy": {"foo": "bar"}, "backtest_config": {}}
|
||||
assert analytics.parse_backtest_spec(json.dumps(spec)) == spec
|
||||
|
||||
|
||||
def test_extract_cost_bps_sums_commission_and_slippage_from_v2_spec() -> None:
|
||||
spec_json = json.dumps(
|
||||
{
|
||||
"version": 2,
|
||||
"strategy": {},
|
||||
"backtest_config": {
|
||||
"commission": {"rate": 0.0005}, # 5 bps
|
||||
"slippage": {"rate": 0.0003}, # 3 bps
|
||||
},
|
||||
}
|
||||
)
|
||||
assert analytics.extract_cost_bps(spec_json) == pytest.approx(8.0)
|
||||
|
||||
|
||||
def test_extract_cost_bps_handles_v1_spec() -> None:
|
||||
"""v1 specs store costs in a flat ``costs`` dict; cost_view falls back to it."""
|
||||
spec_json = json.dumps({"costs": {"commission_bps": 2.0, "slippage_bps": 1.5}})
|
||||
assert analytics.extract_cost_bps(spec_json) == pytest.approx(3.5)
|
||||
|
||||
|
||||
def test_extract_allocator_reads_strategy_allocation_method() -> None:
|
||||
spec_json = json.dumps(
|
||||
{
|
||||
"version": 2,
|
||||
"strategy": {"allocation": {"method": "risk_parity"}},
|
||||
"backtest_config": {},
|
||||
}
|
||||
)
|
||||
assert analytics.extract_allocator(spec_json) == "risk_parity"
|
||||
|
||||
|
||||
def test_extract_allocator_defaults_to_unknown_when_missing() -> None:
|
||||
spec_json = json.dumps({"version": 2, "strategy": {}, "backtest_config": {}})
|
||||
assert analytics.extract_allocator(spec_json) == "unknown"
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Guard: per-chapter helper modules import from the repo root.
|
||||
|
||||
Chapter directories are number-prefixed (``25_live_trading``), so they are not
|
||||
Python packages and their helper modules (``async_utils`` etc.) are only
|
||||
importable when the chapter directory is on ``sys.path``. ``sitecustomize.py``
|
||||
(declared as a top-level py-module in pyproject) arranges that at interpreter
|
||||
startup in every environment. This test pins that contract: a bare
|
||||
``import async_utils`` must succeed in a fresh interpreter started from the
|
||||
repo root, with no chapter directory injected onto the path.
|
||||
|
||||
If this fails, ``sitecustomize.py`` or its ``[tool.setuptools] py-modules``
|
||||
declaration was likely removed, or the package needs reinstalling
|
||||
(``uv pip install -e .``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
_CHAPTER_DIR = re.compile(r"/\d\d_[^/]+/?$")
|
||||
|
||||
|
||||
def test_chapter_helper_imports_from_repo_root() -> None:
|
||||
# Inherit the real environment (so PYTHONPATH=/app survives in Docker CI),
|
||||
# but strip any pre-injected chapter directory so the test genuinely
|
||||
# exercises the sitecustomize hook rather than a path the harness added.
|
||||
env = dict(os.environ)
|
||||
if pp := env.get("PYTHONPATH"):
|
||||
kept = [p for p in pp.split(os.pathsep) if not _CHAPTER_DIR.search(p)]
|
||||
env["PYTHONPATH"] = os.pathsep.join(kept)
|
||||
|
||||
# Representative sibling helpers from number-prefixed chapter dirs.
|
||||
code = "import async_utils, limit_orderbook; print('ok')"
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
"Bare chapter-helper import failed from the repo root — the "
|
||||
"sitecustomize path hook is not active.\n"
|
||||
f"stdout: {result.stdout}\nstderr: {result.stderr}"
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Test chapter teaching notebooks via Papermill parameter injection.
|
||||
|
||||
Instead of the legacy TEST=1 environment variable (which creates divergent code paths),
|
||||
this module uses Papermill to inject medium-scale parameter overrides into notebooks.
|
||||
The same code path always runs; only the scale differs.
|
||||
|
||||
When ML4T_OUTPUT_DIR is set and contains pre-generated intermediates (from
|
||||
generate_intermediates.py), chapter notebooks that depend on case study artifacts
|
||||
(labels, features, predictions) will find them. This is seeded in CI by copying
|
||||
intermediates from the test-data repo into ML4T_OUTPUT_DIR before running tests.
|
||||
|
||||
Usage:
|
||||
# All chapters
|
||||
pytest tests/test_chapter_notebooks.py -v
|
||||
|
||||
# Specific chapter
|
||||
pytest tests/test_chapter_notebooks.py -v -k "ch05"
|
||||
|
||||
# Specific notebook
|
||||
pytest tests/test_chapter_notebooks.py -v -k "tailgan"
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.pm_helpers import (
|
||||
collect_chapter_notebooks,
|
||||
current_test_tier,
|
||||
get_overrides,
|
||||
get_tier,
|
||||
run_notebook,
|
||||
)
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
|
||||
# Collect all chapter teaching notebooks (Ch01-Ch26)
|
||||
CHAPTER_RANGE = range(1, 27)
|
||||
CHAPTER_NOTEBOOKS = collect_chapter_notebooks(REPO_ROOT, CHAPTER_RANGE)
|
||||
|
||||
# Also collect per-dataset card notebooks (data/*/dataset_card.py, data/*/*/dataset_card.py)
|
||||
for notebook in sorted(REPO_ROOT.glob("data/**/dataset_card.py")):
|
||||
CHAPTER_NOTEBOOKS.append(notebook)
|
||||
|
||||
print(f"Found {len(CHAPTER_NOTEBOOKS)} chapter notebooks to test")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"notebook_path",
|
||||
CHAPTER_NOTEBOOKS,
|
||||
ids=lambda p: p.relative_to(REPO_ROOT).as_posix().replace("/", "::"),
|
||||
)
|
||||
def test_chapter_notebook(notebook_path, populated_data_dir, seeded_output_dir):
|
||||
"""Execute a chapter notebook via Papermill with medium-scale overrides.
|
||||
|
||||
Each notebook runs with:
|
||||
- Production defaults (what readers see)
|
||||
- Papermill-injected overrides from tests/overrides.yaml (medium scale)
|
||||
- ML4T_OUTPUT_DIR set to seeded output dir (has case study configs)
|
||||
- MPLBACKEND=Agg, PLOTLY_RENDERER=json (headless rendering)
|
||||
|
||||
Markers (applied at collection time via conftest.py):
|
||||
- ``pytest -m gpu`` — run only GPU-requiring notebooks
|
||||
- ``pytest -m "not gpu"`` — run only CPU notebooks
|
||||
"""
|
||||
rel_path = notebook_path.relative_to(REPO_ROOT).with_suffix("")
|
||||
overrides = get_overrides(str(rel_path))
|
||||
|
||||
# Tier routing: skip when NB tier doesn't match the current run tier.
|
||||
# Default tier is per_commit; weekly/on_demand NBs require their dedicated
|
||||
# workflow to set ML4T_TEST_TIER explicitly.
|
||||
nb_tier = get_tier(overrides)
|
||||
run_tier = current_test_tier()
|
||||
if nb_tier != run_tier:
|
||||
pytest.skip(f"Tier {nb_tier} — current run tier is {run_tier}")
|
||||
|
||||
# Skip if overrides say so (e.g., missing test data)
|
||||
if overrides.get("skip"):
|
||||
pytest.skip(f"Skipped: {overrides.get('skip_reason', 'marked skip in overrides')}")
|
||||
|
||||
# Check required imports (e.g., gensim, signatory, duckdb)
|
||||
requires = overrides.get("requires_import")
|
||||
if requires:
|
||||
pkg = requires if isinstance(requires, str) else requires[0]
|
||||
try:
|
||||
__import__(pkg)
|
||||
except ImportError:
|
||||
pytest.skip(f"Requires {pkg} (not installed in this Docker image)")
|
||||
|
||||
# Check GPU requirement
|
||||
if overrides.get("gpu"):
|
||||
try:
|
||||
import torch
|
||||
|
||||
if not torch.cuda.is_available():
|
||||
pytest.skip("GPU required but not available")
|
||||
except ImportError:
|
||||
pytest.skip("GPU required but torch not installed")
|
||||
|
||||
timeout = overrides.get("timeout", 300)
|
||||
parameters = overrides.get("parameters", {})
|
||||
|
||||
# Data layer notebooks expect to run from their own directory (for config.yaml)
|
||||
notebook_cwd = notebook_path.parent if "data/" in str(rel_path) else None
|
||||
|
||||
result = run_notebook(
|
||||
py_path=notebook_path,
|
||||
parameters=parameters,
|
||||
timeout=timeout,
|
||||
output_dir=seeded_output_dir,
|
||||
data_dir=populated_data_dir,
|
||||
cwd=notebook_cwd,
|
||||
)
|
||||
|
||||
if result["status"] == "error":
|
||||
pytest.fail(
|
||||
f"\n{'=' * 70}\n"
|
||||
f"Notebook failed: {rel_path}\n"
|
||||
f"{'=' * 70}\n"
|
||||
f"Error: {result['error']}\n"
|
||||
f"{'=' * 70}\n"
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Phase 2a — deterministic compose-mount contract (regression-lock for #361).
|
||||
|
||||
The in-container free-data download bug (#361) was that ``docker-compose.yml``
|
||||
mounted the reader's data directory **read-only** (``:ro``), so every downloader
|
||||
failed with ``Read-only file system`` the moment it tried to write to
|
||||
``/data``. The functional container smoke (Phase 2b, ``container-smoke.yml``)
|
||||
catches that end-to-end, but a full 12 GB image pull is too heavy to run per PR.
|
||||
|
||||
This test pins the exact compose contract behind that fix, statically, in
|
||||
milliseconds: the data volume is writable and ``ML4T_DATA_PATH`` points at the
|
||||
mount. No Docker, no network — just parse the compose file. If someone flips the
|
||||
mount back to ``:ro`` or repoints ``ML4T_DATA_PATH``, this fails on the PR that
|
||||
does it, not weeks later in a reader's terminal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
COMPOSE_FILE = REPO_ROOT / "docker-compose.yml"
|
||||
|
||||
# Services a reader actually runs the download workflow in. Benchmark/db-only
|
||||
# services don't mount /data for writes and are out of scope here.
|
||||
_WRITE_SERVICES = ("ml4t", "ml4t-gpu")
|
||||
|
||||
|
||||
def _load_compose() -> dict:
|
||||
# SafeLoader resolves YAML anchors/aliases and ``<<`` merge keys, so each
|
||||
# service dict already carries the merged ``x-common`` volumes/environment.
|
||||
return yaml.safe_load(COMPOSE_FILE.read_text())
|
||||
|
||||
|
||||
# Container target ``/data`` with an optional trailing mode. Anchored at the end
|
||||
# so an interpolated source like ``${ML4T_DATA_PATH:-./data}`` — which itself
|
||||
# contains ``:`` — can't be mistaken for the target/mode.
|
||||
_DATA_MOUNT_RE = re.compile(r":(?P<target>/data)(?::(?P<mode>[a-zA-Z]+))?$")
|
||||
|
||||
|
||||
def _data_mounts(service: dict) -> list[tuple[str, str]]:
|
||||
"""``(volume_string, mode)`` for every mount whose container target is ``/data``.
|
||||
|
||||
``mode`` is ``""`` when the entry omits an explicit rw/ro suffix.
|
||||
"""
|
||||
mounts = []
|
||||
for vol in service.get("volumes", []):
|
||||
if not isinstance(vol, str):
|
||||
continue # long-form mounts not used for /data here
|
||||
m = _DATA_MOUNT_RE.search(vol)
|
||||
if m:
|
||||
mounts.append((vol, m.group("mode") or ""))
|
||||
return mounts
|
||||
|
||||
|
||||
def test_compose_file_exists():
|
||||
assert COMPOSE_FILE.exists(), f"missing {COMPOSE_FILE}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("service_name", _WRITE_SERVICES)
|
||||
def test_data_mount_is_writable(service_name):
|
||||
"""The reader's /data mount must be read-write (the #361 fix)."""
|
||||
compose = _load_compose()
|
||||
service = compose["services"][service_name]
|
||||
mounts = _data_mounts(service)
|
||||
assert mounts, f"{service_name}: no /data volume mount found"
|
||||
for vol, mode in mounts:
|
||||
assert mode != "ro", (
|
||||
f"{service_name}: /data is mounted read-only ('{vol}') — this is the "
|
||||
f"#361 bug; the in-container download workflow cannot write to /data"
|
||||
)
|
||||
assert mode == "rw", (
|
||||
f"{service_name}: /data mount '{vol}' must be explicitly ':rw' so the "
|
||||
f"download workflow can populate it"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("service_name", _WRITE_SERVICES)
|
||||
def test_data_path_env_points_at_mount(service_name):
|
||||
"""ML4T_DATA_PATH inside the container must be the /data mount target.
|
||||
|
||||
Every downloader resolves its output root from ML4T_DATA_PATH; if it doesn't
|
||||
equal the writable mount, files land somewhere the host mount can't see
|
||||
(the ETF wrong-dir class of bug).
|
||||
"""
|
||||
compose = _load_compose()
|
||||
env = compose["services"][service_name].get("environment", [])
|
||||
# environment is a list of "KEY=VALUE" strings in this compose file.
|
||||
env_map = {}
|
||||
for item in env:
|
||||
if isinstance(item, str) and "=" in item:
|
||||
key, _, value = item.partition("=")
|
||||
env_map[key] = value
|
||||
assert env_map.get("ML4T_DATA_PATH") == "/data", (
|
||||
f"{service_name}: ML4T_DATA_PATH is {env_map.get('ML4T_DATA_PATH')!r}, "
|
||||
f"expected '/data' (the writable mount target)"
|
||||
)
|
||||
|
||||
|
||||
def test_no_service_mounts_data_readonly():
|
||||
"""Defensive: no service anywhere reintroduces a read-only /data mount."""
|
||||
compose = _load_compose()
|
||||
offenders = []
|
||||
for name, service in compose.get("services", {}).items():
|
||||
if not isinstance(service, dict):
|
||||
continue
|
||||
for vol, mode in _data_mounts(service):
|
||||
if mode == "ro":
|
||||
offenders.append(f"{name} -> {vol}")
|
||||
assert not offenders, f"read-only /data mount(s) found: {offenders}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([__file__, "-v"]))
|
||||
@@ -0,0 +1,361 @@
|
||||
"""Tests for utils/cv_splits.py — walk-forward split generation.
|
||||
|
||||
Pins the invariants that every Ch11+ pipeline depends on:
|
||||
|
||||
- Pure duration/calendar normalization (regex-based, hermetic).
|
||||
- load_evaluation_config reads setup.yaml's ``evaluation`` block and merges
|
||||
the market_data semantics calendar.
|
||||
- generate_cv_splits produces n_splits folds with the correct chronology,
|
||||
backward walk-forward direction, embargo gap (label_buffer), and respects
|
||||
the holdout_start boundary.
|
||||
- make_walk_forward_config returns int label_horizon for calendar-aware
|
||||
case studies (trading days) and Timedelta for 24/7 crypto.
|
||||
|
||||
Uses the real etfs and crypto_perps_funding setup.yaml files as ground
|
||||
truth so the tests double as regression guards on those configs — if the
|
||||
n_splits / train_size / val_size values are reordered, these tests will
|
||||
flag it before a sweep wastes GPU time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
import polars as pl
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from utils.cv_splits import (
|
||||
_map_calendar_id,
|
||||
_normalize_duration,
|
||||
_normalize_label_buffer,
|
||||
generate_cv_splits,
|
||||
load_evaluation_config,
|
||||
make_walk_forward_config,
|
||||
make_wf_config,
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Pure: _map_calendar_id
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"setup_name, expected",
|
||||
[
|
||||
(None, None),
|
||||
("NYSE", "NYSE"),
|
||||
("CME", "CME_Equity"),
|
||||
("FX", "CME_FX"),
|
||||
("crypto", None), # 24/7 → disable calendar-aware splitting
|
||||
("LSE", "LSE"), # unknown → pass through
|
||||
],
|
||||
)
|
||||
def test_map_calendar_id(setup_name, expected) -> None:
|
||||
assert _map_calendar_id(setup_name) == expected
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Pure: _normalize_duration (ISO 8601 stripping + unit aliasing)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw, normalized",
|
||||
[
|
||||
("P5Y", "5Y"),
|
||||
("P1Y", "1Y"),
|
||||
("1Y", "1Y"),
|
||||
("PT8H", "8h"),
|
||||
("8H", "8h"), # H → h for pd.Timedelta compatibility
|
||||
("21D", "21D"),
|
||||
("15T", "15min"), # T is a legacy pandas minute alias
|
||||
],
|
||||
)
|
||||
def test_normalize_duration(raw, normalized) -> None:
|
||||
assert _normalize_duration(raw) == normalized
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Pure: _normalize_label_buffer (inherits normalization + M → days)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw, normalized",
|
||||
[
|
||||
("21D", "21D"),
|
||||
("PT8H", "8h"),
|
||||
("1M", "30D"), # month → 30 days (pd.Timedelta rejects raw M)
|
||||
("3M", "90D"),
|
||||
("P6M", "180D"),
|
||||
],
|
||||
)
|
||||
def test_normalize_label_buffer(raw, normalized) -> None:
|
||||
assert _normalize_label_buffer(raw) == normalized
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# load_evaluation_config
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_load_evaluation_config_etfs_keys_and_values() -> None:
|
||||
"""etfs is NYSE / 10Y train / 1Y val / 8 splits / backward (ground truth)."""
|
||||
cfg = load_evaluation_config("etfs")
|
||||
assert cfg["n_splits"] == 8
|
||||
assert cfg["train_size"] == "10Y"
|
||||
assert cfg["val_size"] == "1Y"
|
||||
assert cfg["holdout_start"] == "2024-01-01"
|
||||
assert cfg["holdout_end"] == "2025-12-31"
|
||||
assert cfg["calendar"] == "NYSE"
|
||||
|
||||
|
||||
def test_load_evaluation_config_crypto_keeps_24_7_calendar() -> None:
|
||||
"""crypto sets calendar: crypto (24/7); preserved in the returned config."""
|
||||
cfg = load_evaluation_config("crypto_perps_funding")
|
||||
assert cfg["calendar"] == "crypto"
|
||||
|
||||
|
||||
def test_load_evaluation_config_raises_on_missing_section(tmp_path, monkeypatch) -> None:
|
||||
"""A setup.yaml without an ``evaluation`` section raises KeyError.
|
||||
|
||||
We spoof the case-study dir via ML4T_OUTPUT_DIR. The fallback path
|
||||
(re-read from source) won't find the fake id either, so the outer
|
||||
check raises.
|
||||
"""
|
||||
cs_id = "_cv_splits_test_missing_evaluation"
|
||||
monkeypatch.setenv("ML4T_OUTPUT_DIR", str(tmp_path))
|
||||
|
||||
cfg_dir = tmp_path / cs_id / "config"
|
||||
cfg_dir.mkdir(parents=True)
|
||||
(cfg_dir / "setup.yaml").write_text(yaml.safe_dump({"labels": {"primary": "x"}}))
|
||||
|
||||
with pytest.raises(KeyError, match="evaluation"):
|
||||
load_evaluation_config(cs_id)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# generate_cv_splits — uses real etfs config (NYSE, 10Y/1Y, 8 splits, backward)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def etfs_daily_frame() -> pl.DataFrame:
|
||||
"""~24 years of business days — enough for 8 backward folds of 10+1 years."""
|
||||
ts = pd.date_range("1999-01-01", "2023-12-31", freq="B")
|
||||
return pl.DataFrame({"timestamp": pl.Series(ts)})
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def etfs_splits(etfs_daily_frame) -> list[dict]:
|
||||
return generate_cv_splits(etfs_daily_frame, case_study_id="etfs", label_buffer="21D")
|
||||
|
||||
|
||||
def test_generate_cv_splits_etfs_returns_n_splits_folds(etfs_splits) -> None:
|
||||
assert len(etfs_splits) == 8
|
||||
|
||||
|
||||
def test_generate_cv_splits_etfs_fold_ids_are_0_through_n_minus_1(etfs_splits) -> None:
|
||||
assert [s["fold"] for s in etfs_splits] == list(range(len(etfs_splits)))
|
||||
|
||||
|
||||
def test_generate_cv_splits_etfs_folds_have_required_keys(etfs_splits) -> None:
|
||||
required = {"fold", "train_start", "train_end", "val_start", "val_end"}
|
||||
for s in etfs_splits:
|
||||
assert required <= set(s)
|
||||
|
||||
|
||||
def test_generate_cv_splits_etfs_intra_fold_chronology(etfs_splits) -> None:
|
||||
"""Within each fold: train_start ≤ train_end < val_start ≤ val_end."""
|
||||
for s in etfs_splits:
|
||||
assert s["train_start"] <= s["train_end"]
|
||||
assert s["train_end"] < s["val_start"]
|
||||
assert s["val_start"] <= s["val_end"]
|
||||
|
||||
|
||||
def test_generate_cv_splits_etfs_backward_walk_forward(etfs_splits) -> None:
|
||||
"""fold_direction=backward → fold 0 is the most recent, folds step back."""
|
||||
for i in range(len(etfs_splits) - 1):
|
||||
assert etfs_splits[i]["val_start"] > etfs_splits[i + 1]["val_start"]
|
||||
|
||||
|
||||
def test_generate_cv_splits_etfs_embargo_respects_label_buffer(etfs_splits) -> None:
|
||||
"""The gap between train_end and val_start covers the 21-trading-day label
|
||||
horizon. On NYSE that is roughly 29-32 calendar days; allow a generous
|
||||
lower bound to avoid flaking on holiday spacing.
|
||||
"""
|
||||
for s in etfs_splits:
|
||||
gap = s["val_start"] - s["train_end"]
|
||||
assert gap >= pd.Timedelta(days=21), s # at minimum 21 calendar days
|
||||
|
||||
|
||||
def test_generate_cv_splits_etfs_val_before_holdout(etfs_splits) -> None:
|
||||
"""All validation windows end strictly before the holdout_start (2024-01-01)."""
|
||||
holdout_start = pd.Timestamp("2024-01-01")
|
||||
for s in etfs_splits:
|
||||
assert s["val_end"] < holdout_start, s
|
||||
|
||||
|
||||
def test_generate_cv_splits_etfs_train_size_10y(etfs_splits) -> None:
|
||||
"""10Y train_size — span should be ~10 years (±2 months for calendar alignment)."""
|
||||
for s in etfs_splits:
|
||||
span = s["train_end"] - s["train_start"]
|
||||
assert pd.Timedelta(days=365 * 10 - 60) <= span <= pd.Timedelta(days=365 * 10 + 60), s
|
||||
|
||||
|
||||
def test_generate_cv_splits_etfs_val_size_1y(etfs_splits) -> None:
|
||||
"""1Y val_size — span should be ~1 year."""
|
||||
for s in etfs_splits:
|
||||
span = s["val_end"] - s["val_start"]
|
||||
assert pd.Timedelta(days=330) <= span <= pd.Timedelta(days=380), s
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# generate_cv_splits — crypto (24/7, calendar=None after mapping)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_generate_cv_splits_crypto_respects_8h_buffer_and_no_calendar() -> None:
|
||||
ts = pd.date_range("2019-01-01", "2023-12-31", freq="8h")
|
||||
df = pl.DataFrame({"timestamp": pl.Series(ts)})
|
||||
splits = generate_cv_splits(df, case_study_id="crypto_perps_funding", label_buffer="8H")
|
||||
assert len(splits) == 2
|
||||
for s in splits:
|
||||
# 8h buffer means val_start ≥ train_end + 8h (may be slightly larger
|
||||
# because step is in 8-hour bars).
|
||||
gap = s["val_start"] - s["train_end"]
|
||||
assert gap >= pd.Timedelta(hours=8), s
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# generate_cv_splits — input DataFrame flavors
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_generate_cv_splits_accepts_pandas_dataframe() -> None:
|
||||
"""Both pl.DataFrame and pd.DataFrame inputs produce identical splits."""
|
||||
ts = pd.date_range("1999-01-01", "2023-12-31", freq="B")
|
||||
pdf = pd.DataFrame({"timestamp": ts})
|
||||
pldf = pl.DataFrame({"timestamp": pl.Series(ts)})
|
||||
|
||||
pd_splits = generate_cv_splits(pdf, case_study_id="etfs", label_buffer="21D")
|
||||
pl_splits = generate_cv_splits(pldf, case_study_id="etfs", label_buffer="21D")
|
||||
assert pd_splits == pl_splits
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# generate_cv_splits — legacy cv_config dict path
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_generate_cv_splits_cv_config_passthrough_of_precomputed_splits() -> None:
|
||||
"""If cv_config already carries a ``splits`` list, return it unchanged."""
|
||||
precomputed = [
|
||||
{
|
||||
"fold": 0,
|
||||
"train_start": "2020-01-01",
|
||||
"train_end": "2022-12-31",
|
||||
"val_start": "2023-01-01",
|
||||
"val_end": "2023-12-31",
|
||||
}
|
||||
]
|
||||
df = pl.DataFrame({"timestamp": pl.Series(pd.date_range("2020", "2023", freq="D"))})
|
||||
out = generate_cv_splits(df, cv_config={"splits": precomputed})
|
||||
assert out is precomputed or out == precomputed
|
||||
|
||||
|
||||
def test_generate_cv_splits_cv_config_accepts_legacy_alias_keys() -> None:
|
||||
"""Legacy keys test_size / test_start / test_end must be accepted.
|
||||
|
||||
Old pipeline persisted cv_config.json with these aliases; the loader
|
||||
must still accept them so archived runs replay correctly.
|
||||
"""
|
||||
cv = {
|
||||
"n_splits": 2,
|
||||
"train_size": "5Y",
|
||||
"test_size": "1Y",
|
||||
"test_start": "2023-01-01",
|
||||
"test_end": "2023-12-31",
|
||||
"calendar": "NYSE",
|
||||
}
|
||||
ts = pd.date_range("2010-01-01", "2023-12-31", freq="B")
|
||||
df = pl.DataFrame({"timestamp": pl.Series(ts)})
|
||||
splits = generate_cv_splits(df, cv_config=cv, label_buffer="5D")
|
||||
assert len(splits) == 2
|
||||
for s in splits:
|
||||
assert s["train_end"] < s["val_start"]
|
||||
|
||||
|
||||
def test_generate_cv_splits_cv_config_with_val_size_key_also_works() -> None:
|
||||
"""Newer pipelines persist val_size / holdout_start — also supported."""
|
||||
cv = {
|
||||
"n_splits": 2,
|
||||
"train_size": "5Y",
|
||||
"val_size": "1Y",
|
||||
"holdout_start": "2023-01-01",
|
||||
"holdout_end": "2023-12-31",
|
||||
"calendar": "NYSE",
|
||||
}
|
||||
ts = pd.date_range("2010-01-01", "2023-12-31", freq="B")
|
||||
df = pl.DataFrame({"timestamp": pl.Series(ts)})
|
||||
splits = generate_cv_splits(df, cv_config=cv, label_buffer="5D")
|
||||
assert len(splits) == 2
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# generate_cv_splits — error paths
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_generate_cv_splits_raises_without_any_config_source() -> None:
|
||||
df = pl.DataFrame({"timestamp": pl.Series(pd.date_range("2020", "2023", freq="D"))})
|
||||
with pytest.raises(ValueError, match="case_study_id"):
|
||||
generate_cv_splits(df)
|
||||
|
||||
|
||||
def test_generate_cv_splits_raises_on_empty_dataset() -> None:
|
||||
df = pl.DataFrame({"timestamp": pl.Series([], dtype=pl.Datetime)})
|
||||
with pytest.raises(ValueError, match="No timestamps"):
|
||||
generate_cv_splits(df, case_study_id="etfs", label_buffer="21D")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# make_walk_forward_config
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_make_walk_forward_config_nyse_label_horizon_is_int_trading_days() -> None:
|
||||
"""NYSE case study with a D-unit buffer passes label_horizon as int so
|
||||
the library counts trading days instead of calendar days.
|
||||
"""
|
||||
cfg = make_walk_forward_config("etfs", label_horizon="21D")
|
||||
assert isinstance(cfg.label_horizon, int)
|
||||
assert cfg.label_horizon == 21
|
||||
assert cfg.calendar_id == "NYSE"
|
||||
assert cfg.n_splits == 8
|
||||
assert cfg.train_size == "10Y"
|
||||
assert cfg.test_size == "1Y" # val_size → test_size alias
|
||||
assert cfg.fold_direction == "backward"
|
||||
|
||||
|
||||
def test_make_walk_forward_config_crypto_label_horizon_is_timedelta() -> None:
|
||||
"""24/7 crypto: calendar_id=None → horizon stays as string/Timedelta."""
|
||||
cfg = make_walk_forward_config("crypto_perps_funding", label_horizon="8H")
|
||||
assert cfg.calendar_id is None
|
||||
# Library may coerce to Timedelta; never an int for calendar-less case studies.
|
||||
assert not isinstance(cfg.label_horizon, int)
|
||||
|
||||
|
||||
def test_make_walk_forward_config_holdout_dates_round_trip() -> None:
|
||||
"""holdout_start / holdout_end from setup.yaml flow through to test_start / test_end."""
|
||||
cfg = make_walk_forward_config("etfs", label_horizon="21D")
|
||||
# Library stores as date objects
|
||||
assert str(cfg.test_start) == "2024-01-01"
|
||||
assert str(cfg.test_end) == "2025-12-31"
|
||||
|
||||
|
||||
def test_make_wf_config_is_alias_of_make_walk_forward_config() -> None:
|
||||
"""Backward-compat alias should delegate with identical output."""
|
||||
a = make_walk_forward_config("etfs", label_horizon="21D")
|
||||
b = make_wf_config("etfs", label_horizon="21D")
|
||||
assert a.model_dump() == b.model_dump()
|
||||
@@ -0,0 +1,162 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Contract tests for data/exceptions.py.
|
||||
|
||||
Every loader in data/ raises DataNotFoundError with a specific combination
|
||||
of keyword arguments (download_script vs instructions vs download_url, plus
|
||||
readme). The tests below pin the message shape so a reformat of _build_message
|
||||
does not silently drop the reader-facing download instructions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from data.exceptions import DataNotFoundError, DownloadError, MissingDependencyError
|
||||
|
||||
|
||||
def test_data_not_found_with_download_script_shows_command() -> None:
|
||||
err = DataNotFoundError(
|
||||
dataset_name="ETF Universe",
|
||||
path=Path("/data/etfs/market/etf_universe.parquet"),
|
||||
download_script="data/etfs/market/download.py",
|
||||
)
|
||||
msg = str(err)
|
||||
|
||||
assert "DATA NOT FOUND: ETF Universe" in msg
|
||||
assert "/data/etfs/market/etf_universe.parquet" in msg
|
||||
assert "uv run python data/etfs/market/download.py" in msg
|
||||
assert "data/README.md" in msg # Default readme fallback
|
||||
|
||||
|
||||
def test_data_not_found_with_custom_readme_overrides_default() -> None:
|
||||
err = DataNotFoundError(
|
||||
dataset_name="SEC Filings",
|
||||
path=Path("/data/equities/fundamentals/"),
|
||||
download_script="data/equities/fundamentals/filings_download.py",
|
||||
readme="data/equities/fundamentals/README.md",
|
||||
)
|
||||
msg = str(err)
|
||||
|
||||
assert "data/equities/fundamentals/README.md" in msg
|
||||
# Default readme should not appear when custom is set
|
||||
assert msg.count("README.md") == 1
|
||||
|
||||
|
||||
def test_data_not_found_with_download_url_prefers_url_over_script() -> None:
|
||||
err = DataNotFoundError(
|
||||
dataset_name="AlgoSeek Options",
|
||||
path=Path("/data/options/sp500_options.parquet"),
|
||||
download_url="https://example.com/algoseek.zip",
|
||||
download_script="data/ignored/download.py", # Should be ignored
|
||||
requires_api_key="ALGOSEEK_KEY",
|
||||
)
|
||||
msg = str(err)
|
||||
|
||||
assert "https://example.com/algoseek.zip" in msg
|
||||
# download_script is shadowed by download_url in the elif chain
|
||||
assert "uv run python data/ignored/download.py" not in msg
|
||||
assert "Extract to:" in msg
|
||||
assert "ALGOSEEK_KEY" in msg
|
||||
|
||||
|
||||
def test_data_not_found_with_instructions_overrides_other_branches() -> None:
|
||||
err = DataNotFoundError(
|
||||
dataset_name="Derived Daily Bars",
|
||||
path=Path("/data/futures/market/continuous/daily/continuous_daily.parquet"),
|
||||
instructions="Run: python 02/05_futures_session_aggregation.py",
|
||||
download_script="should_not_appear.py",
|
||||
)
|
||||
msg = str(err)
|
||||
|
||||
assert "02/05_futures_session_aggregation.py" in msg
|
||||
assert "should_not_appear.py" not in msg
|
||||
|
||||
|
||||
def test_data_not_found_with_derivation_notebook_adds_pointer() -> None:
|
||||
err = DataNotFoundError(
|
||||
dataset_name="Derived Label",
|
||||
path=Path("/data/labels/fwd_ret_5d.parquet"),
|
||||
derivation_notebook="07_defining_the_learning_task/03_label_methods.py",
|
||||
)
|
||||
msg = str(err)
|
||||
|
||||
assert "How this dataset is built" in msg
|
||||
assert "07_defining_the_learning_task/03_label_methods.py" in msg
|
||||
|
||||
|
||||
def test_data_not_found_is_filenotfounderror() -> None:
|
||||
"""Callers catch FileNotFoundError generically; guard the inheritance."""
|
||||
err = DataNotFoundError(
|
||||
dataset_name="x",
|
||||
path=Path("/tmp/x"),
|
||||
download_script="x.py",
|
||||
)
|
||||
|
||||
assert isinstance(err, FileNotFoundError)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
raise err
|
||||
|
||||
|
||||
def test_data_not_found_accepts_str_path() -> None:
|
||||
"""Some callers pass str paths; the class must normalize to Path."""
|
||||
err = DataNotFoundError(
|
||||
dataset_name="x",
|
||||
path="/tmp/string_path.parquet",
|
||||
download_script="x.py",
|
||||
)
|
||||
|
||||
assert isinstance(err.path, Path)
|
||||
assert "/tmp/string_path.parquet" in str(err)
|
||||
|
||||
|
||||
def test_download_error_includes_reason_and_suggestion() -> None:
|
||||
err = DownloadError(
|
||||
dataset_name="Crypto Perps",
|
||||
reason="API returned 429",
|
||||
suggestion="Retry after backoff or check rate limits",
|
||||
)
|
||||
msg = str(err)
|
||||
|
||||
assert "DOWNLOAD FAILED: Crypto Perps" in msg
|
||||
assert "API returned 429" in msg
|
||||
assert "Retry after backoff" in msg
|
||||
|
||||
|
||||
def test_download_error_without_suggestion_still_valid() -> None:
|
||||
err = DownloadError(dataset_name="x", reason="unknown")
|
||||
msg = str(err)
|
||||
|
||||
assert "DOWNLOAD FAILED: x" in msg
|
||||
assert "Suggestion:" not in msg
|
||||
|
||||
|
||||
def test_download_error_is_runtime_error() -> None:
|
||||
err = DownloadError(dataset_name="x", reason="y")
|
||||
assert isinstance(err, RuntimeError)
|
||||
|
||||
|
||||
def test_missing_dependency_error_defaults_install_command() -> None:
|
||||
err = MissingDependencyError(package="edgartools")
|
||||
msg = str(err)
|
||||
|
||||
assert "edgartools" in msg
|
||||
assert "pip install edgartools" in msg
|
||||
|
||||
|
||||
def test_missing_dependency_error_custom_install_and_purpose() -> None:
|
||||
err = MissingDependencyError(
|
||||
package="torch",
|
||||
install_command="uv sync --extra gpu",
|
||||
purpose="LSTM training in Ch13",
|
||||
)
|
||||
msg = str(err)
|
||||
|
||||
assert "torch" in msg
|
||||
assert "uv sync --extra gpu" in msg
|
||||
assert "LSTM training in Ch13" in msg
|
||||
|
||||
|
||||
def test_missing_dependency_is_import_error() -> None:
|
||||
"""Callers catch ImportError to gate optional backends."""
|
||||
err = MissingDependencyError(package="x")
|
||||
assert isinstance(err, ImportError)
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Tests for utils/data_quality.py.
|
||||
|
||||
Pins:
|
||||
- apply_max_symbols: seed determinism + edge cases (no-op when max<=0 or >=N).
|
||||
Called by every loader; non-determinism would break reproducibility of tests
|
||||
and notebooks that depend on a sampled subset.
|
||||
- check_ohlc_invariants: correct detection of OHLC violations, graceful
|
||||
handling of null values (TAQ no-trade bars), and missing-column tolerance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import polars as pl
|
||||
|
||||
from utils.data_quality import (
|
||||
apply_max_symbols,
|
||||
check_ohlc_invariants,
|
||||
describe_coverage,
|
||||
null_rate,
|
||||
)
|
||||
|
||||
|
||||
def _make_prices(symbols: list[str], n_rows: int = 3) -> pl.DataFrame:
|
||||
rows = []
|
||||
for s in symbols:
|
||||
for i in range(n_rows):
|
||||
rows.append(
|
||||
{
|
||||
"symbol": s,
|
||||
"timestamp": f"2024-01-{i + 1:02d}",
|
||||
"open": 100.0 + i,
|
||||
"high": 105.0 + i,
|
||||
"low": 95.0 + i,
|
||||
"close": 102.0 + i,
|
||||
"volume": 1_000 * (i + 1),
|
||||
}
|
||||
)
|
||||
return pl.DataFrame(rows)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# apply_max_symbols
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_apply_max_symbols_zero_is_passthrough() -> None:
|
||||
df = _make_prices(["A", "B", "C"])
|
||||
out = apply_max_symbols(df, 0)
|
||||
assert out.equals(df)
|
||||
|
||||
|
||||
def test_apply_max_symbols_negative_is_passthrough() -> None:
|
||||
df = _make_prices(["A", "B", "C"])
|
||||
out = apply_max_symbols(df, -1)
|
||||
assert out.equals(df)
|
||||
|
||||
|
||||
def test_apply_max_symbols_exceeds_universe_is_passthrough() -> None:
|
||||
df = _make_prices(["A", "B"])
|
||||
out = apply_max_symbols(df, 10)
|
||||
assert out.equals(df)
|
||||
|
||||
|
||||
def test_apply_max_symbols_samples_requested_count() -> None:
|
||||
df = _make_prices(["A", "B", "C", "D", "E"])
|
||||
out = apply_max_symbols(df, 3)
|
||||
assert out["symbol"].n_unique() == 3
|
||||
|
||||
|
||||
def test_apply_max_symbols_is_seed_deterministic() -> None:
|
||||
"""Same seed → same subset; critical for reproducible tests."""
|
||||
df = _make_prices(["A", "B", "C", "D", "E", "F", "G", "H"])
|
||||
|
||||
first = apply_max_symbols(df, 3, seed=42)["symbol"].unique().sort().to_list()
|
||||
second = apply_max_symbols(df, 3, seed=42)["symbol"].unique().sort().to_list()
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_apply_max_symbols_different_seed_yields_different_sample() -> None:
|
||||
df = _make_prices(["A", "B", "C", "D", "E", "F", "G", "H"])
|
||||
|
||||
s42 = set(apply_max_symbols(df, 3, seed=42)["symbol"].unique().to_list())
|
||||
s7 = set(apply_max_symbols(df, 3, seed=7)["symbol"].unique().to_list())
|
||||
# At least one sample differs — very high probability for k=3, n=8
|
||||
assert s42 != s7
|
||||
|
||||
|
||||
def test_apply_max_symbols_preserves_all_rows_per_symbol() -> None:
|
||||
df = _make_prices(["A", "B", "C", "D"], n_rows=5)
|
||||
out = apply_max_symbols(df, 2)
|
||||
# Each selected symbol should keep all its rows (function filters by symbol set)
|
||||
per_symbol = out.group_by("symbol").len()
|
||||
assert per_symbol["len"].to_list() == [5, 5]
|
||||
|
||||
|
||||
def test_apply_max_symbols_sort_then_sample_is_order_invariant() -> None:
|
||||
"""Shuffling the input before sampling must yield the same subset: the
|
||||
function sorts symbols before seeding the RNG so unstable loader order
|
||||
(e.g., parquet partition order) can't perturb the selection."""
|
||||
df_asc = _make_prices(["A", "B", "C", "D", "E"])
|
||||
df_desc = df_asc.sort("symbol", descending=True)
|
||||
|
||||
s1 = apply_max_symbols(df_asc, 2, seed=42)["symbol"].unique().sort().to_list()
|
||||
s2 = apply_max_symbols(df_desc, 2, seed=42)["symbol"].unique().sort().to_list()
|
||||
assert s1 == s2
|
||||
|
||||
|
||||
def test_apply_max_symbols_custom_symbol_col() -> None:
|
||||
df = pl.DataFrame({"product": ["X", "Y", "Z"], "value": [1, 2, 3]})
|
||||
out = apply_max_symbols(df, 2, symbol_col="product")
|
||||
assert out["product"].n_unique() == 2
|
||||
|
||||
|
||||
def test_apply_max_symbols_with_lazyframe_returns_lazyframe() -> None:
|
||||
df = _make_prices(["A", "B", "C", "D"])
|
||||
lf = df.lazy()
|
||||
out = apply_max_symbols(lf, 2)
|
||||
assert isinstance(out, pl.LazyFrame)
|
||||
assert out.collect()["symbol"].n_unique() == 2
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# check_ohlc_invariants
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_check_ohlc_invariants_clean_data_is_100_percent() -> None:
|
||||
df = _make_prices(["A"], n_rows=5)
|
||||
invariants = check_ohlc_invariants(df)
|
||||
# 6 checks expected: high_gte_low/open/close, low_lte_open/close, volume_non_negative
|
||||
assert invariants.height == 6
|
||||
assert (invariants["valid_pct"] == 100.0).all()
|
||||
|
||||
|
||||
def test_check_ohlc_invariants_detects_high_below_low() -> None:
|
||||
df = pl.DataFrame(
|
||||
{
|
||||
"open": [100.0, 100.0],
|
||||
"high": [99.0, 105.0], # first row violates high >= low
|
||||
"low": [100.0, 95.0],
|
||||
"close": [99.5, 102.0],
|
||||
"volume": [1_000, 1_000],
|
||||
}
|
||||
)
|
||||
invariants = check_ohlc_invariants(df)
|
||||
row = invariants.filter(pl.col("check") == "high_gte_low").row(0, named=True)
|
||||
assert row["valid_pct"] == 50.0
|
||||
assert row["applicable_rows"] == 2
|
||||
|
||||
|
||||
def test_check_ohlc_invariants_ignores_null_rows() -> None:
|
||||
"""Rows where any required col is null are excluded from the percentage."""
|
||||
df = pl.DataFrame(
|
||||
{
|
||||
"open": [100.0, None, 100.0],
|
||||
"high": [105.0, None, 102.0],
|
||||
"low": [95.0, None, 95.0],
|
||||
"close": [101.0, None, 101.0],
|
||||
"volume": [1_000, 1_000, 1_000],
|
||||
}
|
||||
)
|
||||
invariants = check_ohlc_invariants(df)
|
||||
row = invariants.filter(pl.col("check") == "high_gte_low").row(0, named=True)
|
||||
assert row["applicable_rows"] == 2 # 3 rows, 1 excluded for nulls
|
||||
assert row["valid_pct"] == 100.0
|
||||
|
||||
|
||||
def test_check_ohlc_invariants_detects_negative_volume() -> None:
|
||||
df = pl.DataFrame(
|
||||
{
|
||||
"open": [100.0],
|
||||
"high": [105.0],
|
||||
"low": [95.0],
|
||||
"close": [101.0],
|
||||
"volume": [-5],
|
||||
}
|
||||
)
|
||||
invariants = check_ohlc_invariants(df)
|
||||
row = invariants.filter(pl.col("check") == "volume_non_negative").row(0, named=True)
|
||||
assert row["valid_pct"] == 0.0
|
||||
|
||||
|
||||
def test_check_ohlc_invariants_omits_volume_when_missing() -> None:
|
||||
df = pl.DataFrame({"open": [100.0], "high": [105.0], "low": [95.0], "close": [101.0]})
|
||||
invariants = check_ohlc_invariants(df)
|
||||
assert "volume_non_negative" not in invariants["check"].to_list()
|
||||
assert invariants.height == 5 # 5 price checks, no volume check
|
||||
|
||||
|
||||
def test_check_ohlc_invariants_empty_df_returns_empty_result() -> None:
|
||||
df = pl.DataFrame({"x": []})
|
||||
invariants = check_ohlc_invariants(df)
|
||||
assert invariants.height == 0
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Smaller coverage helpers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_describe_coverage_basic_shape() -> None:
|
||||
df = _make_prices(["A", "B"], n_rows=3)
|
||||
cov = describe_coverage(df)
|
||||
assert cov["rows"] == 6
|
||||
assert cov["assets"] == 2
|
||||
assert cov["unique_times"] == 3
|
||||
|
||||
|
||||
def test_null_rate_reports_per_column() -> None:
|
||||
df = pl.DataFrame({"a": [1, None, 3], "b": [None, None, 3]})
|
||||
rates = null_rate(df)
|
||||
by_col = dict(zip(rates["column"], rates["null_pct"], strict=True))
|
||||
# 1/3 ≈ 33.33 for a, 2/3 ≈ 66.66 for b
|
||||
assert round(by_col["a"], 2) == 33.33
|
||||
assert round(by_col["b"], 2) == 66.67
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Test notebooks that require Docker environments (py312, neo4j, benchmark).
|
||||
|
||||
Same as test_chapter_notebooks.py but IGNORES skip flags from overrides.yaml.
|
||||
These notebooks are skipped in uv-native runs (missing modules like signatory,
|
||||
gensim, esig, tfcausalimpact, or Neo4j) but CAN run inside their respective
|
||||
Docker images.
|
||||
|
||||
The skip flag stays in overrides.yaml so the uv-native runner still skips them.
|
||||
This file runs them in Docker where the dependencies are available.
|
||||
|
||||
Usage:
|
||||
# Py312 notebooks (signatory, gensim, esig, pfhedge, tfcausalimpact, torch CUDA bug)
|
||||
python -m pytest tests/test_docker_notebooks.py -v -k "03_sigcwgan or ..."
|
||||
|
||||
# Neo4j notebooks
|
||||
python -m pytest tests/test_docker_notebooks.py -v -k "08_8k_event_extraction or ..."
|
||||
|
||||
# Benchmark notebooks
|
||||
python -m pytest tests/test_docker_notebooks.py -v -k "18_storage_benchmark_database or ..."
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.pm_helpers import (
|
||||
collect_chapter_notebooks,
|
||||
current_test_tier,
|
||||
get_overrides,
|
||||
get_tier,
|
||||
run_notebook,
|
||||
)
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
|
||||
# Collect all chapter teaching notebooks (Ch01-Ch26) + data layer
|
||||
CHAPTER_RANGE = range(1, 27)
|
||||
CHAPTER_NOTEBOOKS = collect_chapter_notebooks(REPO_ROOT, CHAPTER_RANGE)
|
||||
for notebook in sorted(REPO_ROOT.glob("data/**/dataset_card.py")):
|
||||
CHAPTER_NOTEBOOKS.append(notebook)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"notebook_path",
|
||||
CHAPTER_NOTEBOOKS,
|
||||
ids=lambda p: p.relative_to(REPO_ROOT).as_posix().replace("/", "::"),
|
||||
)
|
||||
def test_docker_notebook(notebook_path, populated_data_dir, seeded_output_dir):
|
||||
"""Execute a notebook via Papermill, ignoring skip flags.
|
||||
|
||||
Identical to test_chapter_notebook() but does NOT honor the 'skip' key
|
||||
in overrides.yaml. This allows Docker-based CI jobs to run notebooks
|
||||
that are skipped in the uv-native environment due to missing modules.
|
||||
|
||||
GPU skips are still honored (Docker CI runners have no GPU).
|
||||
"""
|
||||
rel_path = notebook_path.relative_to(REPO_ROOT).with_suffix("")
|
||||
overrides = get_overrides(str(rel_path))
|
||||
|
||||
# Tier routing still applies — Docker tests are normally per_commit, but
|
||||
# this honors the same env-driven gating used by the uv-native runners.
|
||||
nb_tier = get_tier(overrides)
|
||||
run_tier = current_test_tier()
|
||||
if nb_tier != run_tier:
|
||||
pytest.skip(f"Tier {nb_tier} — current run tier is {run_tier}")
|
||||
|
||||
# NOTE: We intentionally do NOT check overrides.get("skip") here.
|
||||
# That's the whole point of this file — Docker provides the missing deps.
|
||||
|
||||
# GPU requirement still applies (CI runners have no GPU)
|
||||
if overrides.get("gpu"):
|
||||
try:
|
||||
import torch
|
||||
|
||||
if not torch.cuda.is_available():
|
||||
pytest.skip("GPU required but not available")
|
||||
except ImportError:
|
||||
pytest.skip("GPU required but torch not installed")
|
||||
|
||||
timeout = overrides.get("timeout", 300)
|
||||
parameters = overrides.get("parameters", {})
|
||||
|
||||
result = run_notebook(
|
||||
py_path=notebook_path,
|
||||
parameters=parameters,
|
||||
timeout=timeout,
|
||||
output_dir=seeded_output_dir,
|
||||
data_dir=populated_data_dir,
|
||||
)
|
||||
|
||||
if result["status"] == "error":
|
||||
pytest.fail(
|
||||
f"\n{'=' * 70}\n"
|
||||
f"Notebook failed: {rel_path}\n"
|
||||
f"{'=' * 70}\n"
|
||||
f"Error: {result['error']}\n"
|
||||
f"{'=' * 70}\n"
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Dispatch-logic tests for ``data/download_all.py``.
|
||||
|
||||
These run the real ``main()`` control flow with the network boundary
|
||||
(``run_download_script``, which shells out to each downloader) replaced by a
|
||||
recorder. No subprocesses, no network. They pin which datasets each mode
|
||||
dispatches — the layer that silently broke in the #361 fixes:
|
||||
|
||||
- ``--free-only`` must skip the API-key datasets (macro/FX) but keep the core +
|
||||
factor + firm-char downloads;
|
||||
- ``--skip-firm-characteristics`` must omit the 1.5 GB academic pull;
|
||||
- firm characteristics must be invoked *plainly* (no ``--convert``, which used
|
||||
to run a conversion over data that had never been downloaded);
|
||||
- every dispatched script must carry the selected ``--data-path``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
import data.download_all as da
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recorder(monkeypatch, tmp_path):
|
||||
"""Replace run_download_script with a call recorder; neutralise dotenv."""
|
||||
calls: list[tuple[str, list]] = []
|
||||
|
||||
def _record(script_name, extra_args=None):
|
||||
calls.append((script_name, list(extra_args or [])))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(da, "run_download_script", _record)
|
||||
monkeypatch.setattr(da, "load_dotenv", lambda *a, **k: None)
|
||||
return calls
|
||||
|
||||
|
||||
def _run(monkeypatch, tmp_path, *cli):
|
||||
monkeypatch.setattr(sys, "argv", ["download_all.py", "--data-path", str(tmp_path), *cli])
|
||||
da.main()
|
||||
|
||||
|
||||
def _names(calls):
|
||||
return {name for name, _ in calls}
|
||||
|
||||
|
||||
def test_free_only_dispatches_core_and_factors(recorder, monkeypatch, tmp_path):
|
||||
_run(monkeypatch, tmp_path, "--free-only")
|
||||
names = _names(recorder)
|
||||
# core case-study datasets + free factors + firm characteristics
|
||||
assert {
|
||||
"etfs.py",
|
||||
"crypto.py",
|
||||
"prediction_markets.py",
|
||||
"cot.py",
|
||||
"ff_factors.py",
|
||||
"aqr_factors.py",
|
||||
"firm_characteristics.py",
|
||||
} <= names
|
||||
# API-key / paid / large datasets must NOT be dispatched in free-only mode
|
||||
assert "macro.py" not in names
|
||||
assert "fx_pairs.py" not in names
|
||||
assert "us_equities.py" not in names
|
||||
|
||||
|
||||
def test_skip_firm_characteristics(recorder, monkeypatch, tmp_path):
|
||||
_run(monkeypatch, tmp_path, "--free-only", "--skip-firm-characteristics")
|
||||
assert "firm_characteristics.py" not in _names(recorder)
|
||||
# the rest of the free tier still runs
|
||||
assert "etfs.py" in _names(recorder)
|
||||
|
||||
|
||||
def test_firm_characteristics_invoked_without_convert(recorder, monkeypatch, tmp_path):
|
||||
"""firm-char must download plainly — never with ``--convert`` alone."""
|
||||
_run(monkeypatch, tmp_path, "--free-only")
|
||||
fc = [args for name, args in recorder if name == "firm_characteristics.py"]
|
||||
assert len(fc) == 1
|
||||
assert "--convert" not in fc[0]
|
||||
assert "--data-path" in fc[0]
|
||||
|
||||
|
||||
def test_core_mode_adds_api_key_datasets(recorder, monkeypatch, tmp_path):
|
||||
"""Default (core) mode also dispatches the free-API-key datasets."""
|
||||
_run(monkeypatch, tmp_path) # no --free-only
|
||||
names = _names(recorder)
|
||||
assert "macro.py" in names
|
||||
assert "fx_pairs.py" in names
|
||||
# but still not the --all-only historical equities pull
|
||||
assert "us_equities.py" not in names
|
||||
|
||||
|
||||
def test_every_dispatch_carries_selected_data_path(recorder, monkeypatch, tmp_path):
|
||||
"""No dispatched script may be left to guess the data root."""
|
||||
_run(monkeypatch, tmp_path, "--free-only")
|
||||
for name, args in recorder:
|
||||
assert str(tmp_path) in args, f"{name} did not receive the data path: {args}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([__file__, "-v"]))
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Phase 4 — coverage ratchet: every free dataset keeps a live drift smoke.
|
||||
|
||||
Phase 1 keeps ``download_all.py``'s registry in lock-step with the on-disk
|
||||
scripts. Phase 3 adds a live external drift smoke per free source. This test
|
||||
welds the two together so coverage can't rot as later beats ship more datasets:
|
||||
a newly shipped free dataset must appear in the free-tier list (Phase 1) *and*
|
||||
carry a drift test (Phase 3), or CI fails on the PR that adds it.
|
||||
|
||||
Deterministic and network-free — it only introspects test modules and the
|
||||
download registry, so it runs in the fast per-PR ``test-unit`` job.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tests.test_external_drift as drift
|
||||
from tests.test_download_scripts_registry import DATA_DIR, FREE_TIER_SCRIPTS
|
||||
|
||||
# Maps each free-tier download script (the Phase 1 SSOT) to the drift-source key
|
||||
# it must be smoke-tested under in tests/test_external_drift.py. Keep this in
|
||||
# lock-step with FREE_TIER_SCRIPTS — the tests below fail loudly if it drifts.
|
||||
FREE_SCRIPT_TO_DRIFT_SOURCE = {
|
||||
"etfs/market/download.py": "etf",
|
||||
"crypto/market/download.py": "crypto",
|
||||
"prediction_markets/download.py": "prediction_markets",
|
||||
"futures/positioning/cot_download.py": "cot",
|
||||
"factors/ff_download.py": "fama_french",
|
||||
"factors/aqr_download.py": "aqr",
|
||||
"equities/firm_characteristics/download.py": "firm_characteristics",
|
||||
"macro/download.py": "fred",
|
||||
"fx/market/download.py": "fx",
|
||||
}
|
||||
|
||||
|
||||
def _drift_test_names() -> set[str]:
|
||||
return {
|
||||
name for name in dir(drift) if name.startswith("test_") and callable(getattr(drift, name))
|
||||
}
|
||||
|
||||
|
||||
def test_every_free_script_is_mapped_to_a_drift_source():
|
||||
"""A new free dataset can't ship without declaring its drift source.
|
||||
|
||||
When a later beat adds a free dataset to FREE_TIER_SCRIPTS, this fails until
|
||||
the author maps it here (which in turn forces a DRIFT_SOURCES entry and a
|
||||
drift test via the checks below).
|
||||
"""
|
||||
mapped = set(FREE_SCRIPT_TO_DRIFT_SOURCE)
|
||||
free = set(FREE_TIER_SCRIPTS)
|
||||
unmapped = free - mapped
|
||||
assert not unmapped, (
|
||||
f"free-tier download scripts with no drift-source mapping: {sorted(unmapped)} — "
|
||||
f"add them to FREE_SCRIPT_TO_DRIFT_SOURCE and give each a drift test."
|
||||
)
|
||||
stale = mapped - free
|
||||
assert not stale, (
|
||||
f"drift-source mappings for scripts no longer in FREE_TIER_SCRIPTS: {sorted(stale)} — "
|
||||
f"remove them (the dataset was dropped or renamed)."
|
||||
)
|
||||
|
||||
|
||||
def test_mapped_scripts_exist_on_disk():
|
||||
"""Every mapped free script resolves to a real file (catches a rename)."""
|
||||
missing = [rel for rel in FREE_SCRIPT_TO_DRIFT_SOURCE if not (DATA_DIR / rel).is_file()]
|
||||
assert not missing, f"mapped free-tier scripts missing on disk: {missing}"
|
||||
|
||||
|
||||
def test_drift_sources_match_mapping():
|
||||
"""``DRIFT_SOURCES`` and the free-script mapping are bidirectionally consistent."""
|
||||
declared = set(drift.DRIFT_SOURCES)
|
||||
mapped = set(FREE_SCRIPT_TO_DRIFT_SOURCE.values())
|
||||
assert declared == mapped, (
|
||||
f"DRIFT_SOURCES {sorted(declared)} != mapped free sources {sorted(mapped)} — "
|
||||
f"a source is declared without a script mapping (or vice versa)."
|
||||
)
|
||||
|
||||
|
||||
def test_every_drift_source_has_a_test():
|
||||
"""Each declared drift source is backed by a ``test_<source>*`` function."""
|
||||
names = _drift_test_names()
|
||||
for source in drift.DRIFT_SOURCES:
|
||||
assert any(n.startswith(f"test_{source}") for n in names), (
|
||||
f"drift source '{source}' has no test_{source}* function in "
|
||||
f"tests/test_external_drift.py — declared but not smoke-tested."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
|
||||
raise SystemExit(pytest.main([__file__, "-v"]))
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Unit tests for the shared download plumbing in ``utils/downloading.py``.
|
||||
|
||||
These are fast, deterministic, and network-free. They pin the *contracts* that
|
||||
every ``data/**/download.py`` script relies on for path resolution and output
|
||||
placement — the layer where the #361-class bugs lived:
|
||||
|
||||
- a downloader must write under the *selected* data root (``--data-path`` /
|
||||
``ML4T_DATA_PATH``), never blindly under ``<repo>/data`` (the read-only /
|
||||
wrong-dir bug);
|
||||
- ``~`` in a ``--config`` / ``--data-path`` must expand (the Copilot review bug);
|
||||
- ``atomic_write_parquet`` must land a complete file and leave no ``.tmp`` behind.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
from utils.downloading import (
|
||||
atomic_write_parquet,
|
||||
create_base_parser,
|
||||
flatten_group_values,
|
||||
load_section,
|
||||
resolve_data_dir,
|
||||
resolve_storage_path,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_data_dir — precedence + expansion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_data_dir_cli_arg_wins_over_env(tmp_path, monkeypatch):
|
||||
"""An explicit ``--data-path`` must win over ``ML4T_DATA_PATH``.
|
||||
|
||||
This is the contract that keeps a downloader writing where the reader
|
||||
asked — the mechanism behind the #361 read-only-mount fix.
|
||||
"""
|
||||
cli = tmp_path / "cli_root"
|
||||
monkeypatch.setenv("ML4T_DATA_PATH", str(tmp_path / "env_root"))
|
||||
resolved = resolve_data_dir(cli)
|
||||
assert resolved == cli.resolve()
|
||||
|
||||
|
||||
def test_resolve_data_dir_expands_user(monkeypatch, tmp_path):
|
||||
"""A ``~``-prefixed CLI path is expanded, not taken literally."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
resolved = resolve_data_dir(Path("~/some_data"))
|
||||
assert resolved == (tmp_path / "some_data").resolve()
|
||||
assert "~" not in str(resolved)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_storage_path — the exact mechanism behind the ETF wrong-dir bug
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_storage_path_relative_joins_data_root(tmp_path):
|
||||
"""A relative configured path is joined under the selected data root."""
|
||||
root = tmp_path / "data_root"
|
||||
resolved = resolve_storage_path(root, "etfs/market", "etfs")
|
||||
assert resolved == root / "etfs/market"
|
||||
|
||||
|
||||
def test_resolve_storage_path_absolute_is_preserved(tmp_path):
|
||||
"""An absolute configured path is preserved (not re-rooted)."""
|
||||
absolute = tmp_path / "elsewhere" / "etfs"
|
||||
resolved = resolve_storage_path(tmp_path / "data_root", str(absolute), "etfs")
|
||||
assert resolved == absolute
|
||||
|
||||
|
||||
def test_resolve_storage_path_falls_back_when_unconfigured(tmp_path):
|
||||
"""With no configured path, the fallback is used under the data root."""
|
||||
root = tmp_path / "data_root"
|
||||
assert resolve_storage_path(root, None, "etfs") == root / "etfs"
|
||||
|
||||
|
||||
def test_resolve_storage_path_expands_user(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
resolved = resolve_storage_path(tmp_path / "data_root", "~/abs_etfs", "etfs")
|
||||
assert resolved == (tmp_path / "abs_etfs")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_section — YAML section reader used by the ETF/crypto/fx downloaders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_load_section_reads_named_section(tmp_path):
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text("etfs:\n storage_path: etfs/market\n start: '2010-01-01'\n")
|
||||
section = load_section(cfg, "etfs")
|
||||
assert section == {"storage_path": "etfs/market", "start": "2010-01-01"}
|
||||
|
||||
|
||||
def test_load_section_missing_section_returns_empty(tmp_path):
|
||||
cfg = tmp_path / "config.yaml"
|
||||
cfg.write_text("etfs:\n storage_path: etfs/market\n")
|
||||
assert load_section(cfg, "nonexistent") == {}
|
||||
|
||||
|
||||
def test_load_section_expands_user(tmp_path, monkeypatch):
|
||||
"""``load_section`` must honour ``~`` so ``--config ~/foo.yaml`` works."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
(tmp_path / "config.yaml").write_text("etfs:\n storage_path: etfs/market\n")
|
||||
section = load_section("~/config.yaml", "etfs")
|
||||
assert section == {"storage_path": "etfs/market"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# flatten_group_values — grouped symbols/pairs flattening
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_flatten_group_values_dedups_and_preserves_order():
|
||||
groups = {
|
||||
"large_cap": {"symbols": ["SPY", "QQQ", "SPY"]},
|
||||
"bonds": {"symbols": ["TLT", "QQQ"]},
|
||||
"not_a_dict": ["ignored"],
|
||||
}
|
||||
assert flatten_group_values(groups, "symbols") == ["SPY", "QQQ", "TLT"]
|
||||
|
||||
|
||||
def test_flatten_group_values_empty():
|
||||
assert flatten_group_values({}, "symbols") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# atomic_write_parquet — complete write, no temp residue
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_atomic_write_parquet_writes_and_roundtrips(tmp_path):
|
||||
df = pl.DataFrame({"symbol": ["AAA", "BBB"], "timestamp": [1, 2]})
|
||||
out = tmp_path / "nested" / "dir" / "data.parquet"
|
||||
atomic_write_parquet(df, out)
|
||||
|
||||
assert out.exists() # parent dirs created
|
||||
assert pl.read_parquet(out).equals(df)
|
||||
# no leftover temp file
|
||||
assert not (out.parent / f".{out.name}.tmp").exists()
|
||||
assert list(out.parent.glob(".*.tmp")) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create_base_parser — the standard download CLI flags
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_base_parser_has_standard_flags(tmp_path):
|
||||
parser = create_base_parser("test")
|
||||
args = parser.parse_args(["--data-path", str(tmp_path), "--dry-run", "--force", "--verbose"])
|
||||
assert args.data_path == tmp_path
|
||||
assert args.dry_run is True
|
||||
assert args.force is True
|
||||
assert args.verbose is True
|
||||
|
||||
|
||||
def test_create_base_parser_defaults(tmp_path):
|
||||
parser = create_base_parser("test")
|
||||
args = parser.parse_args([])
|
||||
assert args.data_path is None
|
||||
assert args.dry_run is False
|
||||
assert args.force is False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([__file__, "-v"]))
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Coverage-ratchet tests for the download-script registry.
|
||||
|
||||
``download_all.py`` dispatches by shelling out to the paths in
|
||||
``DOWNLOAD_SCRIPTS``. If a downloader is renamed or moved without updating the
|
||||
map, dispatch silently degrades to "Script not found" at runtime — invisible to
|
||||
CI until a reader hits it. These tests keep the map and the on-disk scripts in
|
||||
lock-step, in both directions, and fail when a *new* ``download.py`` is added
|
||||
without wiring it in (so coverage can't rot as later beats ship more datasets).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import data.download_all as da
|
||||
|
||||
DATA_DIR = Path(da.__file__).parent
|
||||
|
||||
# The free datasets a reader gets with ``download_all.py --free-only`` (no API
|
||||
# key, or a free key). Single source of truth: the registry ratchet below pins
|
||||
# that these ship, and ``tests/test_download_coverage.py`` (Phase 4) pins that
|
||||
# each keeps a live drift smoke — so a newly shipped free dataset can't land
|
||||
# without both a registry entry and an external-drift test.
|
||||
FREE_TIER_SCRIPTS = (
|
||||
"etfs/market/download.py",
|
||||
"crypto/market/download.py",
|
||||
"prediction_markets/download.py",
|
||||
"futures/positioning/cot_download.py",
|
||||
"factors/ff_download.py",
|
||||
"factors/aqr_download.py",
|
||||
"equities/firm_characteristics/download.py",
|
||||
"macro/download.py",
|
||||
"fx/market/download.py",
|
||||
)
|
||||
|
||||
|
||||
def _discovered_download_scripts() -> set[str]:
|
||||
"""All download scripts on disk, as paths relative to ``data/``."""
|
||||
found = set(DATA_DIR.glob("**/download.py")) | set(DATA_DIR.glob("**/*_download.py"))
|
||||
return {str(p.relative_to(DATA_DIR)) for p in found}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("script_name,relative_path", sorted(da.DOWNLOAD_SCRIPTS.items()))
|
||||
def test_registered_script_exists(script_name, relative_path):
|
||||
"""Every entry in DOWNLOAD_SCRIPTS resolves to a real file."""
|
||||
assert (DATA_DIR / relative_path).is_file(), (
|
||||
f"DOWNLOAD_SCRIPTS['{script_name}'] -> {relative_path} does not exist; "
|
||||
"a downloader was renamed/moved without updating download_all.py"
|
||||
)
|
||||
|
||||
|
||||
def test_every_download_script_is_registered():
|
||||
"""Coverage ratchet: no download script may be orphaned from the registry."""
|
||||
registered = set(da.DOWNLOAD_SCRIPTS.values())
|
||||
discovered = _discovered_download_scripts()
|
||||
unregistered = discovered - registered
|
||||
assert not unregistered, (
|
||||
f"download scripts present on disk but missing from DOWNLOAD_SCRIPTS: "
|
||||
f"{sorted(unregistered)} — wire them into download_all.py (or rename)."
|
||||
)
|
||||
|
||||
|
||||
def test_free_tier_scripts_present():
|
||||
"""The free datasets a reader gets with `download_all.py --free-only` all ship."""
|
||||
missing = [p for p in FREE_TIER_SCRIPTS if not (DATA_DIR / p).is_file()]
|
||||
assert not missing, f"free-tier download scripts missing: {missing}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([__file__, "-v"]))
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Phase 3 — live external drift smoke (weekly, flake-tolerant, auto-issue).
|
||||
|
||||
One tiny **real** pull per FREE data source. Each test asserts the source is
|
||||
reachable, returns non-empty data, and still carries the schema our download
|
||||
scripts depend on. When an upstream provider moves, renames a file, or changes
|
||||
its payload shape, the matching test fails and the weekly workflow opens a
|
||||
deduped GitHub issue — this is the drift detector.
|
||||
|
||||
Design rules (mirror ``work/2026-07-06-public-test-suite/PLAN.md``):
|
||||
- **Tiny props only** — 2 symbols / a short window / a single product-year.
|
||||
Never a full-universe or full-history pull.
|
||||
- **No billed APIs.** Only free sources run. Key-gated free sources (FRED,
|
||||
OANDA) skip cleanly when their free key is absent; geo-restricted sources
|
||||
(Kalshi / Polymarket) skip when blocked rather than fail.
|
||||
- **Provider-level, not script-level.** We call the same providers the
|
||||
``data/**/download.py`` scripts use, so a provider/API change surfaces here
|
||||
without writing files or pulling gigabytes.
|
||||
|
||||
Marked ``@pytest.mark.drift`` and collected only by the weekly ``drift`` job —
|
||||
never per-PR (they touch the network).
|
||||
|
||||
``DRIFT_SOURCES`` is the public contract that ``tests/test_download_coverage.py``
|
||||
(Phase 4) ratchets against: every free dataset must keep a drift smoke here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.drift
|
||||
|
||||
# The free data sources this module keeps a live drift smoke for. Phase 4's
|
||||
# coverage ratchet asserts every free ``data/**/download.py`` maps to an entry
|
||||
# here, so a newly shipped free dataset can't land without a drift test.
|
||||
DRIFT_SOURCES: tuple[str, ...] = (
|
||||
"etf", # data/etfs/market/download.py (Yahoo Finance)
|
||||
"crypto", # data/crypto/market/download.py (Binance public)
|
||||
"fama_french", # data/factors/ff_download.py (Ken French library)
|
||||
"aqr", # data/factors/aqr_download.py (AQR research)
|
||||
"cot", # data/futures/positioning/cot_download.py (CFTC)
|
||||
"fred", # data/macro/download.py (FRED — free key)
|
||||
"firm_characteristics", # data/equities/firm_characteristics/download.py (Google Drive)
|
||||
"fx", # data/fx/market/download.py (OANDA — free key)
|
||||
"prediction_markets", # data/prediction_markets/download.py (Kalshi + Polymarket)
|
||||
)
|
||||
|
||||
# A short, recent window keeps every pull tiny and deterministic in size.
|
||||
_START = "2024-01-02"
|
||||
_END = "2024-01-10"
|
||||
|
||||
|
||||
def _assert_schema(df, required: set[str], source: str) -> None:
|
||||
"""A source is healthy only if it returns non-empty data with our columns."""
|
||||
import polars as pl
|
||||
|
||||
assert isinstance(df, pl.DataFrame), f"{source}: expected a polars DataFrame"
|
||||
assert not df.is_empty(), f"{source}: reachable but returned zero rows (drift?)"
|
||||
missing = required - set(df.columns)
|
||||
assert not missing, f"{source}: missing expected column(s) {sorted(missing)} — schema drift"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# No-key free sources — always run in the weekly job.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_etf_yahoo_reachable():
|
||||
"""ETF universe download source (Yahoo Finance) — canonical OHLCV schema."""
|
||||
from ml4t.data.providers.yahoo import YahooFinanceProvider
|
||||
|
||||
provider = YahooFinanceProvider()
|
||||
try:
|
||||
df = provider.fetch_ohlcv("SPY", _START, _END, "daily")
|
||||
finally:
|
||||
provider.close()
|
||||
_assert_schema(df, {"timestamp", "symbol", "open", "high", "low", "close"}, "etf/yahoo")
|
||||
|
||||
|
||||
def test_crypto_binance_reachable():
|
||||
"""Crypto premium-index source (Binance public) — canonical premium schema."""
|
||||
from ml4t.data.providers.binance_public import BinancePublicProvider
|
||||
|
||||
provider = BinancePublicProvider(market="futures")
|
||||
try:
|
||||
df = provider.fetch_premium_index(
|
||||
"BTCUSDT", start="2024-01-01", end="2024-01-05", interval="8h"
|
||||
)
|
||||
finally:
|
||||
provider.close()
|
||||
_assert_schema(df, {"timestamp", "symbol", "premium_index_close"}, "crypto/binance")
|
||||
|
||||
|
||||
def test_fama_french_reachable():
|
||||
"""Fama-French factor source (Ken French library) — ff3 factors present."""
|
||||
from ml4t.data.providers.fama_french import FamaFrenchProvider
|
||||
|
||||
provider = FamaFrenchProvider()
|
||||
try:
|
||||
df = provider.fetch("ff3", frequency="monthly", start="2023-01-01", end="2023-06-30")
|
||||
finally:
|
||||
provider.close()
|
||||
# Ken French renames/reformats his CSV zips periodically; pin the columns
|
||||
# the book's factor pipeline reads.
|
||||
_assert_schema(df, {"timestamp", "Mkt-RF", "SMB", "HML", "RF"}, "fama_french")
|
||||
|
||||
|
||||
def test_aqr_reachable():
|
||||
"""AQR factor source — quality-minus-junk panel reachable (wide country panel)."""
|
||||
from ml4t.data.providers.aqr import AQRFactorProvider
|
||||
|
||||
provider = AQRFactorProvider()
|
||||
try:
|
||||
# No date filter: the provider's date-string filter path is brittle and
|
||||
# the monthly QMJ panel is already small. We only need reachability +
|
||||
# that the Excel workbook still parses to a timestamped frame.
|
||||
df = provider.fetch("qmj_factors")
|
||||
finally:
|
||||
provider.close()
|
||||
_assert_schema(df, {"timestamp"}, "aqr")
|
||||
assert df.width >= 2, "aqr: QMJ workbook parsed but carries no factor columns — layout drift"
|
||||
|
||||
|
||||
def test_cot_cftc_reachable(tmp_path, monkeypatch):
|
||||
"""CFTC Commitment-of-Traders source — one product-year panel."""
|
||||
from ml4t.data.cot import COTConfig, COTFetcher
|
||||
|
||||
# cot_reports writes a scratch .txt into the CWD; keep it out of the repo.
|
||||
monkeypatch.chdir(tmp_path)
|
||||
fetcher = COTFetcher(COTConfig(products=["ES"], start_year=2024, end_year=2024))
|
||||
df = fetcher.fetch_product("ES")
|
||||
_assert_schema(df, {"report_date", "open_interest"}, "cot")
|
||||
|
||||
|
||||
def test_firm_characteristics_gdrive_listing():
|
||||
"""Firm-characteristics source (Google Drive folder) — listing still resolves.
|
||||
|
||||
Lists the folder without downloading (``skip_download=True``), catching a
|
||||
moved/renamed folder or a gdown API change — the class of bug that broke the
|
||||
firm-char download — for a fraction of a second and zero of the ~1.5 GB.
|
||||
"""
|
||||
import os
|
||||
|
||||
import gdown
|
||||
|
||||
from data.equities.firm_characteristics.download import GDRIVE_FOLDER_URL
|
||||
|
||||
listing = gdown.download_folder(GDRIVE_FOLDER_URL, skip_download=True, quiet=True)
|
||||
assert listing, "firm_characteristics: Google Drive folder listing is empty (moved/renamed?)"
|
||||
# The raw folder holds the source files the downloader fetches, then extracts:
|
||||
# RetChar.csv (the ~1.1 GB characteristics table the converter reads) and
|
||||
# datasets.zip (the char/macro/RF numpy splits). A rename of either breaks the
|
||||
# download, so pin their presence by basename rather than the extracted layout.
|
||||
names = {os.path.basename(getattr(f, "path", str(f))) for f in listing}
|
||||
for required in ("RetChar.csv", "datasets.zip"):
|
||||
assert required in names, (
|
||||
f"firm_characteristics: '{required}' missing from Drive folder "
|
||||
f"(found {sorted(names)}) — upstream dataset renamed/moved"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Key-gated free sources — skip cleanly when the free key is absent (no charge).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fred_reachable():
|
||||
"""Treasury/macro source (FRED) — free API key required, skip if unset."""
|
||||
if not os.getenv("FRED_API_KEY"):
|
||||
pytest.skip("FRED_API_KEY not set — free-key source, nothing to charge")
|
||||
|
||||
from ml4t.data.providers.fred import FREDProvider
|
||||
|
||||
provider = FREDProvider()
|
||||
try:
|
||||
# DGS10 = 10-Year Treasury constant maturity, a stable free series.
|
||||
df = provider.fetch_ohlcv("DGS10", _START, _END, "daily")
|
||||
finally:
|
||||
provider.close()
|
||||
_assert_schema(df, {"timestamp"}, "fred")
|
||||
|
||||
|
||||
def test_fx_oanda_reachable():
|
||||
"""FX source (OANDA) — free API key required, skip if unset."""
|
||||
if not os.getenv("OANDA_API_KEY"):
|
||||
pytest.skip("OANDA_API_KEY not set — free-key source, nothing to charge")
|
||||
|
||||
from ml4t.data.providers.oanda import OandaProvider
|
||||
|
||||
provider = OandaProvider(api_key=os.environ["OANDA_API_KEY"])
|
||||
try:
|
||||
df = provider.fetch_ohlcv("EUR_USD", _START, _END, "daily")
|
||||
finally:
|
||||
close = getattr(provider, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
_assert_schema(df, {"timestamp", "open", "high", "low", "close"}, "fx/oanda")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Geo-restricted free source — skip (not fail) when the region blocks access.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_prediction_markets_reachable():
|
||||
"""Prediction-markets source (Kalshi) — geo-restricted, skip when blocked.
|
||||
|
||||
Kalshi and Polymarket restrict API access by jurisdiction, so a listing can
|
||||
fail at the network layer from some regions even though the endpoints are
|
||||
up. This dataset is optional; a geo/network block skips rather than fails so
|
||||
the weekly job doesn't file spurious drift issues from a blocked runner.
|
||||
"""
|
||||
try:
|
||||
from ml4t.data.providers.kalshi import KalshiProvider
|
||||
except ImportError:
|
||||
pytest.skip("Kalshi provider not installed")
|
||||
|
||||
provider = KalshiProvider()
|
||||
try:
|
||||
markets = provider.list_markets(limit=1)
|
||||
except Exception as exc: # network/geo block — optional dataset
|
||||
pytest.skip(f"Kalshi unreachable from this runner (likely geo-restricted): {exc}")
|
||||
finally:
|
||||
close = getattr(provider, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
|
||||
if not markets:
|
||||
pytest.skip("Kalshi reachable but returned no markets (geo-filtered)")
|
||||
assert isinstance(markets, list) and markets[0].get("ticker"), (
|
||||
"prediction_markets: Kalshi listing shape changed (no 'ticker') — schema drift"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([__file__, "-v", "-m", "drift"]))
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Unit tests for the firm-characteristics archive handling.
|
||||
|
||||
``extract_zip`` flattens the published Chen-Pelger-Zhu archives, which wrap
|
||||
their contents in a single top-level ``data/`` or ``datasets/`` directory and
|
||||
(when zipped on macOS) ship ``__MACOSX`` resource forks + ``.DS_Store`` files.
|
||||
A mismatch here silently lands the ``.npz`` files at the wrong depth, which
|
||||
``verify_files`` then reports as "missing" — exactly the bug fixed in the
|
||||
firm-char end-to-end repair. These synthetic-zip tests lock the flatten rule
|
||||
without pulling the real ~1.5 GB dataset.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from data.equities.firm_characteristics.download import EXPECTED_FILES, extract_zip
|
||||
|
||||
|
||||
def _make_zip(zip_path: Path, entries: dict[str, bytes]) -> None:
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
for arcname, content in entries.items():
|
||||
zf.writestr(arcname, content)
|
||||
|
||||
|
||||
def test_extract_zip_strips_datasets_wrapper(tmp_path):
|
||||
"""A ``datasets/`` wrapper is stripped so files land directly under the dir."""
|
||||
zip_path = tmp_path / "datasets.zip"
|
||||
_make_zip(
|
||||
zip_path,
|
||||
{
|
||||
"datasets/RetChar.csv": b"Date,RET\n20000101,0.01\n",
|
||||
"datasets/char/Char_train.npz": b"fake-npz",
|
||||
"datasets/macro/macro_test.npz": b"fake-npz",
|
||||
},
|
||||
)
|
||||
extract_dir = tmp_path / "out"
|
||||
extract_dir.mkdir()
|
||||
|
||||
assert extract_zip(zip_path, extract_dir) is True
|
||||
assert (extract_dir / "RetChar.csv").exists()
|
||||
assert (extract_dir / "char" / "Char_train.npz").exists()
|
||||
assert (extract_dir / "macro" / "macro_test.npz").exists()
|
||||
# wrapper directory itself must NOT survive
|
||||
assert not (extract_dir / "datasets").exists()
|
||||
|
||||
|
||||
def test_extract_zip_strips_data_wrapper(tmp_path):
|
||||
"""The alternate ``data/`` wrapper is stripped identically."""
|
||||
zip_path = tmp_path / "data.zip"
|
||||
_make_zip(zip_path, {"data/char/Char_valid.npz": b"x"})
|
||||
extract_dir = tmp_path / "out"
|
||||
extract_dir.mkdir()
|
||||
|
||||
assert extract_zip(zip_path, extract_dir) is True
|
||||
assert (extract_dir / "char" / "Char_valid.npz").exists()
|
||||
assert not (extract_dir / "data").exists()
|
||||
|
||||
|
||||
def test_extract_zip_skips_macos_cruft(tmp_path):
|
||||
"""``__MACOSX`` resource forks and ``.DS_Store`` files are dropped."""
|
||||
zip_path = tmp_path / "datasets.zip"
|
||||
_make_zip(
|
||||
zip_path,
|
||||
{
|
||||
"datasets/RetChar.csv": b"data",
|
||||
"datasets/.DS_Store": b"junk",
|
||||
"__MACOSX/datasets/._RetChar.csv": b"resource-fork",
|
||||
},
|
||||
)
|
||||
extract_dir = tmp_path / "out"
|
||||
extract_dir.mkdir()
|
||||
|
||||
assert extract_zip(zip_path, extract_dir) is True
|
||||
assert (extract_dir / "RetChar.csv").exists()
|
||||
assert not (extract_dir / ".DS_Store").exists()
|
||||
assert not (extract_dir / "__MACOSX").exists()
|
||||
# the resource-fork file must not have leaked in under any name
|
||||
assert not any(p.name == "._RetChar.csv" for p in extract_dir.rglob("*"))
|
||||
|
||||
|
||||
def test_extract_zip_cleans_temp_dir(tmp_path):
|
||||
"""The intermediate ``_temp_extract`` scratch dir is removed."""
|
||||
zip_path = tmp_path / "datasets.zip"
|
||||
_make_zip(zip_path, {"datasets/RetChar.csv": b"data"})
|
||||
extract_dir = tmp_path / "out"
|
||||
extract_dir.mkdir()
|
||||
|
||||
extract_zip(zip_path, extract_dir)
|
||||
assert not (extract_dir / "_temp_extract").exists()
|
||||
|
||||
|
||||
def test_extract_zip_returns_false_on_bad_archive(tmp_path):
|
||||
"""A corrupt archive fails gracefully (returns False, no raise)."""
|
||||
bad = tmp_path / "broken.zip"
|
||||
bad.write_bytes(b"not a real zip")
|
||||
extract_dir = tmp_path / "out"
|
||||
extract_dir.mkdir()
|
||||
assert extract_zip(bad, extract_dir) is False
|
||||
|
||||
|
||||
def test_expected_files_manifest_is_sane():
|
||||
"""The published dataset ships 11 files across RetChar/Macro/char/macro/RF."""
|
||||
assert len(EXPECTED_FILES) == 11
|
||||
assert "RetChar.csv" in EXPECTED_FILES
|
||||
assert all(size > 0 for size in EXPECTED_FILES.values())
|
||||
# the three split families each contribute a train/valid/test triple
|
||||
for prefix in ("char/", "macro/", "RF/"):
|
||||
assert sum(1 for k in EXPECTED_FILES if k.startswith(prefix)) == 3
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(pytest.main([__file__, "-v"]))
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Unit tests for data/futures/loader.py — list_cme_products().
|
||||
|
||||
The loader binds ML4T_DATA_PATH at import time (`from utils import
|
||||
ML4T_DATA_PATH`), so monkeypatching the env var is insufficient —
|
||||
we patch the module symbol directly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from data.exceptions import DataNotFoundError
|
||||
from data.futures import loader as futures_loader
|
||||
|
||||
|
||||
def _build_hive_fixture(root: Path, products: list[str]) -> None:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
for p in products:
|
||||
(root / f"product={p}").mkdir()
|
||||
|
||||
|
||||
def _build_individual_fixture(root: Path, products: list[str], include_empty: bool = False) -> None:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
for p in products:
|
||||
pdir = root / p
|
||||
pdir.mkdir()
|
||||
(pdir / "data.parquet").write_bytes(b"fake-parquet")
|
||||
if include_empty:
|
||||
(root / "EMPTY_DIR").mkdir() # no data.parquet — must be skipped
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_data_path(tmp_path, monkeypatch):
|
||||
"""Redirect the loader's bound ML4T_DATA_PATH to a temp directory."""
|
||||
monkeypatch.setattr(futures_loader, "ML4T_DATA_PATH", tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_list_cme_products_hourly_returns_sorted_unique(isolated_data_path) -> None:
|
||||
hive_root = isolated_data_path / "futures" / "market" / "continuous" / "hourly"
|
||||
_build_hive_fixture(hive_root, ["ES", "NQ", "CL", "GC", "6E"])
|
||||
|
||||
products = futures_loader.list_cme_products()
|
||||
assert products == ["6E", "CL", "ES", "GC", "NQ"]
|
||||
|
||||
|
||||
def test_list_cme_products_ignores_non_product_subdirs(isolated_data_path) -> None:
|
||||
hive_root = isolated_data_path / "futures" / "market" / "continuous" / "hourly"
|
||||
hive_root.mkdir(parents=True, exist_ok=True)
|
||||
(hive_root / "product=ES").mkdir()
|
||||
(hive_root / "_metadata").mkdir()
|
||||
(hive_root / "random_other_dir").mkdir()
|
||||
(hive_root / "readme.txt").write_text("x")
|
||||
|
||||
assert futures_loader.list_cme_products() == ["ES"]
|
||||
|
||||
|
||||
def test_list_cme_products_individual_requires_data_parquet(isolated_data_path) -> None:
|
||||
ind_root = isolated_data_path / "futures" / "market" / "individual"
|
||||
_build_individual_fixture(ind_root, ["ES", "CL"], include_empty=True)
|
||||
|
||||
assert futures_loader.list_cme_products(frequency="individual") == ["CL", "ES"]
|
||||
|
||||
|
||||
def test_list_cme_products_raises_when_hourly_root_missing(isolated_data_path) -> None:
|
||||
with pytest.raises(DataNotFoundError, match="CME Futures Hourly"):
|
||||
futures_loader.list_cme_products()
|
||||
|
||||
|
||||
def test_list_cme_products_raises_when_individual_root_missing(isolated_data_path) -> None:
|
||||
with pytest.raises(DataNotFoundError, match="CME Futures Individual"):
|
||||
futures_loader.list_cme_products(frequency="individual")
|
||||
|
||||
|
||||
def test_list_cme_products_rejects_unknown_frequency(isolated_data_path) -> None:
|
||||
with pytest.raises(ValueError, match="frequency must be"):
|
||||
futures_loader.list_cme_products(frequency="daily")
|
||||
|
||||
|
||||
def test_list_cme_products_returns_empty_list_for_empty_hive(isolated_data_path) -> None:
|
||||
(isolated_data_path / "futures" / "market" / "continuous" / "hourly").mkdir(parents=True)
|
||||
assert futures_loader.list_cme_products() == []
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Scanner-driven import-coverage test.
|
||||
|
||||
Uses ``envs.scan_imports`` to extract every third-party top-level import
|
||||
that appears anywhere in the book's source code, then verifies each one
|
||||
that's expected to resolve in the current Docker image actually does.
|
||||
|
||||
The point of this test is **drift detection**. When a chapter adds a
|
||||
new dependency, the scanner picks it up automatically — no hand-edited
|
||||
list to remember to update. If the new package isn't installed in the
|
||||
image a reader built (or isn't classified in ``IMAGE_OVERRIDES``), the
|
||||
test fails loudly instead of waiting for a reader to hit the missing
|
||||
import mid-notebook.
|
||||
|
||||
The current image is taken from the ``ML4T_IMAGE`` environment variable
|
||||
(defaulting to ``"ml4t"``). Each Docker image's entrypoint should set
|
||||
``ML4T_IMAGE=<image-id>`` so the right set of imports gets exercised.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from envs.scan_imports import (
|
||||
IMAGE_OVERRIDES,
|
||||
REPO_ROOT,
|
||||
VALID_IMAGES,
|
||||
classify,
|
||||
scan_repo,
|
||||
try_import,
|
||||
)
|
||||
|
||||
# The default image for agent / CI runs without an explicit ML4T_IMAGE
|
||||
_DEFAULT_IMAGE = "ml4t"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def scanned_imports() -> set[str]:
|
||||
"""Run the scanner once per module — it walks the whole tree."""
|
||||
return scan_repo()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def classified(scanned_imports) -> dict[str, set[str]]:
|
||||
return classify(scanned_imports)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Sanity: the scanner finds things
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_scanner_discovers_reasonable_number_of_external_imports(scanned_imports) -> None:
|
||||
"""A healthy scan finds 50-200 third-party imports across 27 chapters +
|
||||
9 case studies. Outside that envelope indicates the filter is broken
|
||||
(too few: stdlib/first-party leaking in; too many: everything is being
|
||||
called third-party).
|
||||
"""
|
||||
assert 50 <= len(scanned_imports) <= 200, (
|
||||
f"Scanner found {len(scanned_imports)} external imports — "
|
||||
"outside the plausible range for this repo"
|
||||
)
|
||||
|
||||
|
||||
def test_scanner_finds_core_stack(scanned_imports) -> None:
|
||||
"""The book's core stack must appear in every scan."""
|
||||
core = {"numpy", "pandas", "polars", "matplotlib", "scipy", "sklearn", "torch"}
|
||||
missing = core - scanned_imports
|
||||
assert not missing, f"core stack packages not detected: {missing}"
|
||||
|
||||
|
||||
def test_scanner_excludes_stdlib(scanned_imports) -> None:
|
||||
"""Basic regression: no stdlib name should appear in external imports."""
|
||||
stdlib_leak = scanned_imports & {"os", "sys", "json", "pathlib", "re", "ast"}
|
||||
assert not stdlib_leak, f"stdlib names leaked into external set: {stdlib_leak}"
|
||||
|
||||
|
||||
def test_scanner_excludes_first_party(scanned_imports) -> None:
|
||||
"""First-party names must be auto-filtered by the scanner."""
|
||||
first_party_leak = scanned_imports & {
|
||||
"utils",
|
||||
"data",
|
||||
"case_studies",
|
||||
"conftest",
|
||||
}
|
||||
assert not first_party_leak, f"first-party leaked: {first_party_leak}"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classification invariants
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_every_image_override_targets_a_valid_image() -> None:
|
||||
"""Every package in IMAGE_OVERRIDES must map to a recognized image id."""
|
||||
invalid = {pkg: img for pkg, img in IMAGE_OVERRIDES.items() if img not in VALID_IMAGES}
|
||||
assert not invalid, f"packages mapped to unknown images: {invalid}"
|
||||
|
||||
|
||||
def test_every_override_target_appears_in_at_least_one_source_file(scanned_imports) -> None:
|
||||
"""Trim dead entries: a package in IMAGE_OVERRIDES should actually be
|
||||
imported somewhere. If none of the code uses it, the classification
|
||||
entry is stale.
|
||||
"""
|
||||
stale = set(IMAGE_OVERRIDES) - scanned_imports
|
||||
assert not stale, (
|
||||
f"IMAGE_OVERRIDES entries no longer imported anywhere: {sorted(stale)} — "
|
||||
"remove them from envs/scan_imports.py"
|
||||
)
|
||||
|
||||
|
||||
def test_classify_groups_partition_the_scanned_set(classified, scanned_imports) -> None:
|
||||
"""The union of all image buckets must equal the scanned set (no orphans)."""
|
||||
union: set[str] = set()
|
||||
for bucket in classified.values():
|
||||
union |= bucket
|
||||
assert union == scanned_imports
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# The real test: every expected import resolves in this image
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_every_expected_import_resolves_in_current_image(classified) -> None:
|
||||
"""Attempt to import every package the scanner classified for this image.
|
||||
|
||||
The image id comes from ``$ML4T_IMAGE`` (default ``ml4t``). Each Docker
|
||||
entrypoint should set this variable. Running pytest locally picks up
|
||||
the default.
|
||||
"""
|
||||
image = os.environ.get("ML4T_IMAGE", _DEFAULT_IMAGE)
|
||||
expected = sorted(classified[image])
|
||||
|
||||
failures: list[tuple[str, str]] = []
|
||||
for pkg in expected:
|
||||
ok, err = try_import(pkg)
|
||||
if not ok:
|
||||
failures.append((pkg, err))
|
||||
|
||||
if failures:
|
||||
lines = "\n".join(f" {pkg}: {err[:120]}" for pkg, err in failures)
|
||||
pytest.fail(
|
||||
f"{len(failures)} of {len(expected)} expected imports failed in "
|
||||
f"image={image!r}:\n{lines}"
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Smoke: first-party packages we ship must import too (readers installed us)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"module",
|
||||
[
|
||||
"utils.paths",
|
||||
"utils.modeling",
|
||||
"data",
|
||||
"case_studies.utils.analytics",
|
||||
"case_studies.utils.signals",
|
||||
"case_studies.utils.allocation",
|
||||
"case_studies.utils.registry.metrics",
|
||||
],
|
||||
)
|
||||
def test_first_party_modules_import(module: str) -> None:
|
||||
"""First-party packages must load cleanly — regression guard for refactors
|
||||
that break the data/* or case_studies/* package tree."""
|
||||
ok, err = try_import(module)
|
||||
assert ok, f"first-party import failed: {module}: {err[:200]}"
|
||||
@@ -0,0 +1,347 @@
|
||||
"""Regression tests for the latent factor forecasting contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import numpy as np
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
from case_studies.utils.latent_factors.panel import compute_managed_portfolios
|
||||
|
||||
|
||||
def test_managed_portfolios_are_cross_sectionally_shared() -> None:
|
||||
rng = np.random.default_rng(1)
|
||||
chars = rng.normal(size=(12, 8, 4)).astype(np.float32)
|
||||
returns = rng.normal(size=(12, 8)).astype(np.float32)
|
||||
portfolios = compute_managed_portfolios(chars, returns)
|
||||
|
||||
for date_idx in range(portfolios.shape[0]):
|
||||
assert np.allclose(portfolios[date_idx, :1, :], portfolios[date_idx]), (
|
||||
f"managed portfolios vary within date {date_idx}"
|
||||
)
|
||||
|
||||
|
||||
def test_managed_portfolios_use_current_date_only() -> None:
|
||||
rng = np.random.default_rng(2)
|
||||
chars = rng.normal(size=(10, 6, 3)).astype(np.float32)
|
||||
returns = rng.normal(size=(10, 6)).astype(np.float32)
|
||||
|
||||
portfolios_a = compute_managed_portfolios(chars, returns)
|
||||
perturbed = returns.copy()
|
||||
perturbed[4] += 100.0
|
||||
portfolios_b = compute_managed_portfolios(chars, perturbed)
|
||||
|
||||
changed = np.abs(portfolios_a - portfolios_b).max(axis=(1, 2))
|
||||
assert changed[4] > 0.0
|
||||
assert np.all(changed[np.arange(len(changed)) != 4] == 0.0)
|
||||
|
||||
|
||||
def test_cae_validation_batch_receives_validation_returns(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from case_studies.utils.latent_factors import library_bridge
|
||||
|
||||
rng = np.random.default_rng(123)
|
||||
chars_train = rng.normal(size=(12, 8, 4)).astype(np.float32)
|
||||
returns_train = rng.normal(size=(12, 8)).astype(np.float32) * 0.02
|
||||
chars_val = rng.normal(size=(5, 8, 4)).astype(np.float32)
|
||||
returns_val = rng.normal(size=(5, 8)).astype(np.float32) * 0.02
|
||||
|
||||
captured: dict[str, np.ndarray] = {}
|
||||
|
||||
def capture_pipeline(**kwargs):
|
||||
captured["validation_returns"] = kwargs["val_batch"].returns.copy()
|
||||
return {
|
||||
"checkpoint_predictions": {0: np.zeros_like(returns_val)},
|
||||
"checkpoint_epochs": [0],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(library_bridge, "_run_checkpointed_latent_pipeline", capture_pipeline)
|
||||
|
||||
library_bridge.run_cae_fold_with_library(
|
||||
chars_train,
|
||||
returns_train,
|
||||
chars_val=chars_val,
|
||||
returns_val=returns_val,
|
||||
n_factors=2,
|
||||
n_epochs=2,
|
||||
n_ensemble=1,
|
||||
hidden_units=(8,),
|
||||
checkpoint_epochs=[2],
|
||||
)
|
||||
|
||||
assert np.array_equal(captured["validation_returns"], returns_val)
|
||||
|
||||
|
||||
@pytest.mark.gpu
|
||||
def test_cae_predictions_independent_of_validation_returns() -> None:
|
||||
"""End-to-end regression: perturbing validation returns must not change predictions.
|
||||
|
||||
Restores the byte-identical fit-twice check that the wiring-only test above
|
||||
cannot enforce — guards against future changes in
|
||||
`_run_checkpointed_latent_pipeline` (or anything downstream of
|
||||
`model.fit(..., validation_batch=val_batch)`) that accidentally let
|
||||
validation returns influence the fitted model.
|
||||
"""
|
||||
pytest.importorskip("torch")
|
||||
from case_studies.utils.latent_factors.cae import run_cae_fold
|
||||
|
||||
rng = np.random.default_rng(31)
|
||||
chars_train = rng.normal(size=(12, 8, 4)).astype(np.float32)
|
||||
returns_train = rng.normal(size=(12, 8)).astype(np.float32) * 0.02
|
||||
chars_val = rng.normal(size=(5, 8, 4)).astype(np.float32)
|
||||
returns_val = rng.normal(size=(5, 8)).astype(np.float32) * 0.02
|
||||
|
||||
base_preds_by_epoch, _ = run_cae_fold(
|
||||
chars_train,
|
||||
returns_train,
|
||||
chars_val,
|
||||
returns_val,
|
||||
n_factors=2,
|
||||
n_epochs=2,
|
||||
checkpoint_epochs=[2],
|
||||
hidden_units=(8,),
|
||||
log_fn=lambda *args, **kwargs: None,
|
||||
)
|
||||
perturbed_val = returns_val.copy()
|
||||
perturbed_val += 100.0
|
||||
perturbed_preds_by_epoch, _ = run_cae_fold(
|
||||
chars_train,
|
||||
returns_train,
|
||||
chars_val,
|
||||
perturbed_val,
|
||||
n_factors=2,
|
||||
n_epochs=2,
|
||||
checkpoint_epochs=[2],
|
||||
hidden_units=(8,),
|
||||
log_fn=lambda *args, **kwargs: None,
|
||||
)
|
||||
|
||||
base_preds = base_preds_by_epoch[2]
|
||||
perturbed_preds = perturbed_preds_by_epoch[2]
|
||||
assert np.array_equal(base_preds, perturbed_preds), (
|
||||
"CAE predictions changed when validation returns were perturbed by +100 — "
|
||||
"validation returns are leaking into the fitted model"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.gpu
|
||||
def test_cae_classification_uses_continuous_factor_returns(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
pytest.importorskip("torch")
|
||||
from case_studies.utils.latent_factors import library_bridge
|
||||
|
||||
captured: dict[str, np.ndarray] = {}
|
||||
original_cross_section_batch = library_bridge._cross_section_batch
|
||||
|
||||
def capture_batch(
|
||||
characteristics: np.ndarray,
|
||||
*,
|
||||
returns: np.ndarray | None = None,
|
||||
factor_returns: np.ndarray | None = None,
|
||||
context_features: np.ndarray | None = None,
|
||||
):
|
||||
if factor_returns is not None:
|
||||
captured["factor_returns"] = factor_returns.copy()
|
||||
return original_cross_section_batch(
|
||||
characteristics,
|
||||
returns=returns,
|
||||
factor_returns=factor_returns,
|
||||
context_features=context_features,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(library_bridge, "_cross_section_batch", capture_batch)
|
||||
|
||||
rng = np.random.default_rng(7)
|
||||
chars = rng.normal(size=(24, 10, 5)).astype(np.float32)
|
||||
class_labels = (rng.random(size=(24, 10)) > 0.5).astype(np.float32)
|
||||
factor_returns = rng.normal(size=(24, 10)).astype(np.float32) * 0.02
|
||||
|
||||
from case_studies.utils.latent_factors.cae import run_cae_fold
|
||||
|
||||
run_cae_fold(
|
||||
chars[:18],
|
||||
class_labels[:18],
|
||||
chars[18:],
|
||||
class_labels[18:],
|
||||
n_factors=2,
|
||||
factor_returns_train=factor_returns[:18],
|
||||
n_epochs=1,
|
||||
checkpoint_epochs=[1],
|
||||
hidden_units=(8,),
|
||||
task_type="classification",
|
||||
log_fn=lambda *args, **kwargs: None,
|
||||
)
|
||||
|
||||
assert np.array_equal(captured["factor_returns"], factor_returns[:18])
|
||||
|
||||
|
||||
def test_reporting_epoch_defaults_to_last_checkpoint() -> None:
|
||||
from case_studies.utils.latent_factors.cv import _select_reporting_epoch
|
||||
|
||||
metrics = pl.DataFrame(
|
||||
{
|
||||
"fold_id": [0, 0, 1, 1],
|
||||
"epoch": [5, 10, 5, 10],
|
||||
"ic_mean": [0.12, 0.03, 0.11, 0.02],
|
||||
}
|
||||
)
|
||||
|
||||
epoch, mean_ic = _select_reporting_epoch(
|
||||
metrics,
|
||||
checkpoint_selection_policy="fixed",
|
||||
reporting_epoch=None,
|
||||
)
|
||||
|
||||
assert epoch == 10
|
||||
assert mean_ic == pytest.approx(0.025)
|
||||
|
||||
|
||||
def test_reporting_epoch_prefers_validation_best_checkpoint_zero() -> None:
|
||||
from case_studies.utils.latent_factors.cv import _select_reporting_epoch
|
||||
|
||||
metrics = pl.DataFrame(
|
||||
{
|
||||
"fold_id": [0, 0, 1, 1],
|
||||
"epoch": [0, 10, 0, 10],
|
||||
"ic_mean": [0.04, 0.03, 0.05, 0.02],
|
||||
}
|
||||
)
|
||||
|
||||
epoch, mean_ic = _select_reporting_epoch(
|
||||
metrics,
|
||||
checkpoint_selection_policy="fixed",
|
||||
reporting_epoch=None,
|
||||
)
|
||||
|
||||
assert epoch == 0
|
||||
assert mean_ic == pytest.approx(0.045)
|
||||
|
||||
|
||||
def test_prediction_frame_preserves_temporal_timestamp_dtype() -> None:
|
||||
from case_studies.utils.latent_factors.cv import _build_prediction_frame
|
||||
|
||||
predictions = np.array([[0.1, np.nan, 0.3], [0.4, 0.5, np.nan]], dtype=np.float64)
|
||||
returns_val = np.array([[0.0, np.nan, 1.0], [1.0, 2.0, np.nan]], dtype=np.float64)
|
||||
val_dates = np.array(["2024-01-31", "2024-02-29"], dtype="datetime64[ns]")
|
||||
val_entities = np.array(
|
||||
[["A", "B", "C"], ["A", "B", "C"]],
|
||||
dtype=object,
|
||||
)
|
||||
|
||||
frame = _build_prediction_frame(
|
||||
predictions=predictions,
|
||||
returns_val=returns_val,
|
||||
eval_returns_val=None,
|
||||
val_dates=val_dates,
|
||||
val_entities=val_entities,
|
||||
fold_id=0,
|
||||
model_name="ipca",
|
||||
epoch=0,
|
||||
)
|
||||
|
||||
assert frame is not None
|
||||
assert frame["timestamp"].dtype.is_temporal()
|
||||
assert frame["timestamp"].to_list() == [
|
||||
datetime(2024, 1, 31),
|
||||
datetime(2024, 1, 31),
|
||||
datetime(2024, 2, 29),
|
||||
datetime(2024, 2, 29),
|
||||
]
|
||||
|
||||
|
||||
def test_rebalance_scoring_thins_to_declared_schedule() -> None:
|
||||
from case_studies.utils.latent_factors.cv import _compute_frame_ic, _score_prediction_frame
|
||||
|
||||
frame = pl.DataFrame(
|
||||
{
|
||||
"timestamp": [
|
||||
datetime(2024, 1, 15),
|
||||
datetime(2024, 1, 15),
|
||||
datetime(2024, 1, 15),
|
||||
datetime(2024, 1, 15),
|
||||
datetime(2024, 1, 15),
|
||||
datetime(2024, 1, 31),
|
||||
datetime(2024, 1, 31),
|
||||
datetime(2024, 1, 31),
|
||||
datetime(2024, 1, 31),
|
||||
datetime(2024, 1, 31),
|
||||
datetime(2024, 2, 15),
|
||||
datetime(2024, 2, 15),
|
||||
datetime(2024, 2, 15),
|
||||
datetime(2024, 2, 15),
|
||||
datetime(2024, 2, 15),
|
||||
datetime(2024, 2, 29),
|
||||
datetime(2024, 2, 29),
|
||||
datetime(2024, 2, 29),
|
||||
datetime(2024, 2, 29),
|
||||
datetime(2024, 2, 29),
|
||||
],
|
||||
"symbol": ["A", "B", "C", "D", "E"] * 4,
|
||||
"y_true": [
|
||||
0.0,
|
||||
1.0,
|
||||
2.0,
|
||||
3.0,
|
||||
4.0,
|
||||
0.0,
|
||||
1.0,
|
||||
2.0,
|
||||
3.0,
|
||||
4.0,
|
||||
4.0,
|
||||
3.0,
|
||||
2.0,
|
||||
1.0,
|
||||
0.0,
|
||||
4.0,
|
||||
3.0,
|
||||
2.0,
|
||||
1.0,
|
||||
0.0,
|
||||
],
|
||||
"y_score": [
|
||||
0.0,
|
||||
1.0,
|
||||
2.0,
|
||||
3.0,
|
||||
4.0,
|
||||
4.0,
|
||||
3.0,
|
||||
2.0,
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
2.0,
|
||||
3.0,
|
||||
4.0,
|
||||
4.0,
|
||||
3.0,
|
||||
2.0,
|
||||
1.0,
|
||||
0.0,
|
||||
],
|
||||
"fold_id": [0] * 20,
|
||||
"config_name": ["cae"] * 20,
|
||||
"epoch": [10] * 20,
|
||||
}
|
||||
)
|
||||
|
||||
_, full_periods = _compute_frame_ic(frame)
|
||||
thinned = _score_prediction_frame(
|
||||
frame,
|
||||
score_dates="rebalance",
|
||||
score_cadence="monthly_month_end",
|
||||
score_rebalance_step=1,
|
||||
)
|
||||
_, thinned_periods = _compute_frame_ic(thinned)
|
||||
|
||||
assert full_periods == 4
|
||||
assert thinned_periods == 2
|
||||
assert thinned is not None
|
||||
assert thinned["timestamp"].unique().sort().to_list() == [
|
||||
datetime(2024, 1, 31),
|
||||
datetime(2024, 2, 29),
|
||||
]
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Unit tests for list_etfs / list_crypto_perps / list_fx_pairs.
|
||||
|
||||
Parallel to test_futures_loader.py — each loader binds ML4T_DATA_PATH at
|
||||
import time, so we monkeypatch the module symbol rather than the env var.
|
||||
|
||||
The helpers enumerate the symbol universe from each dataset's parquet
|
||||
and are the canonical way to answer "what's available locally?" for
|
||||
marketing / data-inventory / Ch2 EDA notebooks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
from data.crypto import loader as crypto_loader
|
||||
from data.etfs import loader as etfs_loader
|
||||
from data.exceptions import DataNotFoundError
|
||||
from data.fx import loader as fx_loader
|
||||
|
||||
|
||||
def _write_symbol_parquet(path: Path, symbols: list[str]) -> None:
|
||||
"""Minimal parquet with a ``symbol`` column plus a dummy value column."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
pl.DataFrame({"symbol": symbols, "close": [1.0] * len(symbols)}).write_parquet(path)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# list_etfs
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def etfs_isolated(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(etfs_loader, "ML4T_DATA_PATH", tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_list_etfs_returns_sorted_unique_symbols(etfs_isolated) -> None:
|
||||
_write_symbol_parquet(
|
||||
etfs_isolated / "etfs" / "market" / "etf_universe.parquet",
|
||||
# deliberately unsorted with a duplicate
|
||||
["SPY", "QQQ", "AGG", "SPY", "IWM"],
|
||||
)
|
||||
assert etfs_loader.list_etfs() == ["AGG", "IWM", "QQQ", "SPY"]
|
||||
|
||||
|
||||
def test_list_etfs_raises_when_parquet_missing(etfs_isolated) -> None:
|
||||
with pytest.raises(DataNotFoundError, match="ETF Universe"):
|
||||
etfs_loader.list_etfs()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# list_crypto_perps
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def crypto_isolated(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(crypto_loader, "ML4T_DATA_PATH", tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_list_crypto_perps_returns_sorted_unique_symbols(crypto_isolated) -> None:
|
||||
_write_symbol_parquet(
|
||||
crypto_isolated / "crypto" / "market" / "perps_1h.parquet",
|
||||
["BTCUSDT", "ETHUSDT", "ADAUSDT", "BTCUSDT"],
|
||||
)
|
||||
assert crypto_loader.list_crypto_perps() == ["ADAUSDT", "BTCUSDT", "ETHUSDT"]
|
||||
|
||||
|
||||
def test_list_crypto_perps_raises_when_parquet_missing(crypto_isolated) -> None:
|
||||
with pytest.raises(DataNotFoundError, match="Crypto Perpetuals"):
|
||||
crypto_loader.list_crypto_perps()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# list_fx_pairs
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fx_isolated(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(fx_loader, "ML4T_DATA_PATH", tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_list_fx_pairs_default_probes_daily(fx_isolated) -> None:
|
||||
_write_symbol_parquet(
|
||||
fx_isolated / "fx" / "market" / "daily.parquet",
|
||||
["EUR_USD", "GBP_USD", "USD_JPY"],
|
||||
)
|
||||
assert fx_loader.list_fx_pairs() == ["EUR_USD", "GBP_USD", "USD_JPY"]
|
||||
|
||||
|
||||
def test_list_fx_pairs_4h_uses_separate_parquet(fx_isolated) -> None:
|
||||
_write_symbol_parquet(
|
||||
fx_isolated / "fx" / "market" / "4h.parquet",
|
||||
["AUD_JPY", "AUD_NZD"],
|
||||
)
|
||||
assert fx_loader.list_fx_pairs(frequency="4h") == ["AUD_JPY", "AUD_NZD"]
|
||||
|
||||
|
||||
def test_list_fx_pairs_raises_when_parquet_missing(fx_isolated) -> None:
|
||||
with pytest.raises(DataNotFoundError, match="FX Pairs"):
|
||||
fx_loader.list_fx_pairs()
|
||||
|
||||
|
||||
def test_list_fx_pairs_rejects_unknown_frequency(fx_isolated) -> None:
|
||||
with pytest.raises(ValueError, match="frequency must be"):
|
||||
fx_loader.list_fx_pairs(frequency="hourly")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Re-export from data/__init__.py
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_helpers_are_exported_from_data_package() -> None:
|
||||
"""All three list_*() helpers must be importable at ``from data import ...``
|
||||
so marketing/inventory consumers don't need to know submodule layout.
|
||||
"""
|
||||
import data
|
||||
|
||||
assert hasattr(data, "list_etfs")
|
||||
assert hasattr(data, "list_crypto_perps")
|
||||
assert hasattr(data, "list_fx_pairs")
|
||||
assert "list_etfs" in data.__all__
|
||||
assert "list_crypto_perps" in data.__all__
|
||||
assert "list_fx_pairs" in data.__all__
|
||||
@@ -0,0 +1,445 @@
|
||||
"""Test model notebooks produce correct registry entries in isolation.
|
||||
|
||||
Runs each case study model notebook (stage >= 06) with minimal parameters
|
||||
in an isolated environment. Production data is read via symlinks; all
|
||||
writes (registry.db, predictions, results JSON) go to a temp directory.
|
||||
|
||||
The production registry is NEVER opened or touched.
|
||||
|
||||
Design:
|
||||
1. Session fixture creates temp dir with symlinked read-only data
|
||||
2. Each notebook runs via Papermill with aggressive param reduction
|
||||
3. ML4T_OUTPUT_DIR redirects all get_case_study_dir() writes to temp
|
||||
4. After each run, query the test registry.db for expected entries
|
||||
|
||||
The goal is code-path coverage, not model quality. Params are set to the
|
||||
absolute minimum that still exercises the training→register→predict loop:
|
||||
MAX_SYMBOLS=3, MAX_FOLDS=2, N_EPOCHS=2, NUM_BOOST_ROUND=20.
|
||||
|
||||
Usage:
|
||||
# All model notebooks (~15-20 min)
|
||||
uv run pytest tests/test_model_registry.py -v
|
||||
|
||||
# Specific case study
|
||||
uv run pytest tests/test_model_registry.py -v -k "crypto_perps_funding"
|
||||
|
||||
# Specific model family across all case studies
|
||||
uv run pytest tests/test_model_registry.py -v -k "06_linear"
|
||||
|
||||
# Single notebook
|
||||
uv run pytest tests/test_model_registry.py -v -k "etfs and 06_linear"
|
||||
|
||||
# Dry run — see what would be tested
|
||||
uv run pytest tests/test_model_registry.py --collect-only
|
||||
"""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.pm_helpers import get_overrides, run_notebook
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
PROD_CS_DIR = REPO_ROOT / "case_studies"
|
||||
|
||||
# Ordered smallest-to-largest for faster feedback
|
||||
CASE_STUDIES = [
|
||||
"crypto_perps_funding",
|
||||
"fx_pairs",
|
||||
"cme_futures",
|
||||
"etfs",
|
||||
"sp500_options",
|
||||
"nasdaq100_microstructure",
|
||||
"sp500_equity_option_analytics",
|
||||
"us_firm_characteristics",
|
||||
"us_equities_panel",
|
||||
]
|
||||
|
||||
# Directories containing production pipeline artifacts (read-only).
|
||||
# Symlinked into the test output directory so model notebooks can read them.
|
||||
# Everything else (run_log/, results/, models/) is created fresh for writes.
|
||||
_READ_ONLY_DIRS = {"config", "features", "labels"}
|
||||
|
||||
# Minimum stage number for model notebooks
|
||||
_MODEL_STAGE_MIN = 6
|
||||
|
||||
# Suffixes that are NOT model notebooks (backtest, strategy, diagnostics).
|
||||
# These depend on upstream predictions and should be tested separately.
|
||||
_EXCLUDED_SUFFIXES = frozenset(
|
||||
{
|
||||
"backtest",
|
||||
"backtest_sweep",
|
||||
"backtest_analysis",
|
||||
"portfolio_management",
|
||||
"costs",
|
||||
"risk_management",
|
||||
"model_analysis",
|
||||
"strategy_analysis",
|
||||
"synthesis",
|
||||
"ic_diagnostic",
|
||||
"prediction_ingestion",
|
||||
}
|
||||
)
|
||||
|
||||
# Latent factor models need more symbols than other families because factor
|
||||
# extraction requires a cross-section wide enough for the covariance matrix.
|
||||
_LATENT_FACTOR_SUFFIXES = frozenset(
|
||||
{
|
||||
"latent_factors",
|
||||
"pca",
|
||||
"ipca",
|
||||
"sdf",
|
||||
"cae",
|
||||
"sae",
|
||||
"term_structure_pca",
|
||||
}
|
||||
)
|
||||
_LATENT_FACTOR_OVERRIDES = {
|
||||
"MAX_SYMBOLS": 10,
|
||||
"N_FACTORS": 3,
|
||||
}
|
||||
|
||||
# Case studies with sparse data (monthly frequency) need more symbols
|
||||
# to have enough observations for CV splits.
|
||||
_SPARSE_DATA_CASE_STUDIES = frozenset({"us_firm_characteristics"})
|
||||
_SPARSE_DATA_OVERRIDES = {"MAX_SYMBOLS": 20}
|
||||
|
||||
# Minimal parameters for code-path coverage. Applied LAST so they
|
||||
# override anything from overrides.yaml — we want the absolute minimum
|
||||
# that still exercises the full train→register→predict loop.
|
||||
_QUICK_PARAMS = {
|
||||
"MAX_SYMBOLS": 3,
|
||||
"MAX_FOLDS": 2,
|
||||
"N_EPOCHS": 2,
|
||||
"NUM_BOOST_ROUND": 20,
|
||||
"BATCH_SIZE": 64,
|
||||
"LOOKBACK": 24, # PatchTST needs lookback + stride >= patch_len (≥8); 24 leaves margin
|
||||
"MAX_SAMPLES": 1000,
|
||||
"CV_FOLDS": 2,
|
||||
"N_PLACEBO": 3,
|
||||
"N_FACTORS": 2,
|
||||
"FORCE_RETRAIN": True,
|
||||
}
|
||||
|
||||
# Model suffixes known to use register=True (training_runs + prediction_sets).
|
||||
# Matched against the suffix after the NN_ prefix, since notebook numbers
|
||||
# vary across case studies (e.g. causal_dml is 10, 11, 12, or 13 depending
|
||||
# on the case study).
|
||||
# Built from: grep -l "register=True" case_studies/*/[0-9][0-9]_*.py
|
||||
_REGISTERING_SUFFIXES = frozenset(
|
||||
{
|
||||
"linear",
|
||||
"gbm",
|
||||
"tabular_dl",
|
||||
"dl_lstm",
|
||||
"dl_patchtst",
|
||||
"dl_tsmixer",
|
||||
"dl_nlinear",
|
||||
"dl_tcn",
|
||||
# NOTE: causal_dml notebooks register to ``causal_runs`` (DML effect
|
||||
# estimates), not ``training_runs`` — so they are intentionally NOT in
|
||||
# this set. Likewise ``NN_latent_factors`` is a thin index notebook that
|
||||
# only displays the best already-registered factor IC; the factor models
|
||||
# themselves register under their own sub-stems (pca/ipca/sdf/cae/sae,
|
||||
# listed below). Both still execute; only the training-run-registration
|
||||
# assertion is skipped for them.
|
||||
"ipca",
|
||||
"pca",
|
||||
"sdf",
|
||||
"cae",
|
||||
"sae",
|
||||
}
|
||||
)
|
||||
|
||||
# DL notebooks use entry_point = "dl_{model}" (e.g. "dl_lstm") instead of
|
||||
# the full filename stem (e.g. "09_dl_lstm"). Map stage stems to actual
|
||||
# entry_point values for these notebooks.
|
||||
_DL_RE = re.compile(r"^\d{2}_(dl_.+)$")
|
||||
|
||||
|
||||
def _expected_entry_point(stage: str) -> str:
|
||||
"""Return the entry_point value the notebook will use in the registry."""
|
||||
m = _DL_RE.match(stage)
|
||||
if m:
|
||||
return m.group(1) # "09_dl_lstm" → "dl_lstm"
|
||||
return stage # "06_linear" → "06_linear"
|
||||
|
||||
|
||||
_STAGE_RE = re.compile(r"^(\d{2})_")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test collection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _collect_model_notebooks() -> list[tuple[str, str, Path]]:
|
||||
"""Discover all model notebooks (stage >= 06) across case studies.
|
||||
|
||||
Returns (case_study, stage_stem, notebook_path) tuples sorted by
|
||||
case study order then filename within each case study.
|
||||
"""
|
||||
tests = []
|
||||
for cs in CASE_STUDIES:
|
||||
cs_dir = PROD_CS_DIR / cs
|
||||
if not cs_dir.exists():
|
||||
continue
|
||||
for notebook in sorted(cs_dir.glob("[0-9][0-9]_*.py")):
|
||||
if notebook.name.startswith("_"):
|
||||
continue
|
||||
match = _STAGE_RE.match(notebook.name)
|
||||
if not match:
|
||||
continue
|
||||
stage_num = int(match.group(1))
|
||||
if stage_num < _MODEL_STAGE_MIN:
|
||||
continue
|
||||
# Skip non-model notebooks (backtest, strategy, diagnostics)
|
||||
suffix = notebook.stem[len(match.group(0)) :]
|
||||
if suffix in _EXCLUDED_SUFFIXES:
|
||||
continue
|
||||
tests.append((cs, notebook.stem, notebook))
|
||||
return tests
|
||||
|
||||
|
||||
MODEL_TESTS = _collect_model_notebooks()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def isolated_model_output(tmp_path_factory):
|
||||
"""Create an isolated output directory with symlinked production data.
|
||||
|
||||
For each case study, symlinks read-only directories (config/, features/,
|
||||
labels/) from the production case study directory so that model notebooks
|
||||
can load upstream artifacts. Write-target directories (run_log/, results/,
|
||||
models/) are NOT symlinked — they are created fresh by the notebooks.
|
||||
|
||||
Returns the temp root directory (passed as output_dir to run_notebook,
|
||||
which sets ML4T_OUTPUT_DIR).
|
||||
"""
|
||||
import shutil
|
||||
|
||||
test_root = tmp_path_factory.mktemp("model_registry_test")
|
||||
|
||||
for cs in CASE_STUDIES:
|
||||
prod_cs = PROD_CS_DIR / cs
|
||||
if not prod_cs.exists():
|
||||
continue
|
||||
|
||||
test_cs = test_root / cs
|
||||
test_cs.mkdir()
|
||||
|
||||
for subdir in _READ_ONLY_DIRS:
|
||||
src = prod_cs / subdir
|
||||
if src.exists():
|
||||
(test_cs / subdir).symlink_to(src.resolve())
|
||||
|
||||
# Seed the global preset library (case_studies/config/{model_type}/*.yaml).
|
||||
# load_configs() resolves presets at {case_dir.parent}/config/, which maps
|
||||
# to test_root/config/ when ML4T_OUTPUT_DIR is set. Without this, every
|
||||
# notebook that loads GBM/DL/TabDL/latent/causal presets fails.
|
||||
global_config_src = PROD_CS_DIR / "config"
|
||||
global_config_dst = test_root / "config"
|
||||
if global_config_src.exists():
|
||||
shutil.copytree(global_config_src, global_config_dst)
|
||||
# Patch presets for minimal runtime (2 epochs, etc.)
|
||||
from tests.conftest import _patch_presets_for_testing
|
||||
|
||||
_patch_presets_for_testing(global_config_dst)
|
||||
|
||||
return test_root
|
||||
|
||||
|
||||
LOG_PATH = Path("/tmp/model_registry_test.log")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _init_log():
|
||||
"""Initialize the progress log and route Papermill cell output to it."""
|
||||
import logging
|
||||
import time
|
||||
|
||||
with open(LOG_PATH, "w") as f:
|
||||
f.write(f"[{time.strftime('%H:%M:%S')}] === Model Registry Test Suite ===\n")
|
||||
f.write(f"[{time.strftime('%H:%M:%S')}] {len(MODEL_TESTS)} tests collected\n")
|
||||
f.flush()
|
||||
|
||||
# Route Papermill's cell-level progress + notebook print() output to log file.
|
||||
# Papermill uses "papermill" logger (not "papermill.execute") for cell markers
|
||||
# and captured output. We add a file handler so it goes to our log regardless
|
||||
# of pytest's log level.
|
||||
handler = logging.FileHandler(LOG_PATH)
|
||||
handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s", datefmt="%H:%M:%S"))
|
||||
for logger_name in ("papermill", "papermill.execute"):
|
||||
logger = logging.getLogger(logger_name)
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False # Don't pollute pytest captured output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _query_registry(db_path: Path, table: str, where: str = "") -> list[dict]:
|
||||
"""Query a registry table and return rows as dicts."""
|
||||
if not db_path.exists():
|
||||
return []
|
||||
db = sqlite3.connect(str(db_path))
|
||||
db.row_factory = sqlite3.Row
|
||||
try:
|
||||
sql = f"SELECT * FROM {table}"
|
||||
if where:
|
||||
sql += f" WHERE {where}"
|
||||
return [dict(r) for r in db.execute(sql).fetchall()]
|
||||
except sqlite3.OperationalError:
|
||||
return []
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _registry_summary(db_path: Path) -> dict:
|
||||
"""Return a summary of registry contents for reporting."""
|
||||
return {
|
||||
"training_runs": len(_query_registry(db_path, "training_runs")),
|
||||
"prediction_sets": len(_query_registry(db_path, "prediction_sets")),
|
||||
"prediction_metrics": len(_query_registry(db_path, "prediction_metrics")),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case_study,stage,notebook_path",
|
||||
MODEL_TESTS,
|
||||
ids=[f"{cs}::{stage}" for cs, stage, _ in MODEL_TESTS],
|
||||
)
|
||||
def test_model_notebook(case_study, stage, notebook_path, isolated_model_output):
|
||||
"""Run a model notebook in isolation and verify registry output.
|
||||
|
||||
Steps:
|
||||
1. Load per-notebook overrides (timeout, parameters, skip/gpu flags)
|
||||
2. Merge with default reduced parameters (MAX_SYMBOLS=15, MAX_FOLDS=2)
|
||||
3. Execute via Papermill with ML4T_OUTPUT_DIR → isolated temp dir
|
||||
4. Assert successful completion
|
||||
5. For notebooks with register=True, assert registry entries exist
|
||||
"""
|
||||
# --- Skip / override handling ---
|
||||
rel_path = notebook_path.relative_to(REPO_ROOT).with_suffix("")
|
||||
overrides = get_overrides(str(rel_path))
|
||||
|
||||
if overrides.get("skip"):
|
||||
pytest.skip(overrides.get("skip_reason", "marked skip in overrides"))
|
||||
|
||||
if overrides.get("gpu"):
|
||||
try:
|
||||
import torch
|
||||
|
||||
if not torch.cuda.is_available():
|
||||
pytest.skip("GPU required but not available")
|
||||
except ImportError:
|
||||
pytest.skip("torch not installed")
|
||||
|
||||
# --- Parameters ---
|
||||
# Start with overrides.yaml, then apply ALL quick-test params on top.
|
||||
# Quick params win — we want minimal runtime, not overrides.yaml scale.
|
||||
# Papermill warns (but doesn't error) about unknown parameters, so it's
|
||||
# safe to inject all of them even if the notebook doesn't use them all.
|
||||
override_params = overrides.get("parameters", {})
|
||||
parameters = {**override_params, **_QUICK_PARAMS}
|
||||
|
||||
# Latent factor models need a wider cross-section for factor extraction
|
||||
stage_match_p = _STAGE_RE.match(stage)
|
||||
suffix_p = stage[len(stage_match_p.group(0)) :] if stage_match_p else stage
|
||||
if suffix_p in _LATENT_FACTOR_SUFFIXES:
|
||||
parameters.update(_LATENT_FACTOR_OVERRIDES)
|
||||
|
||||
# Sparse-data case studies (monthly frequency) need more symbols
|
||||
if case_study in _SPARSE_DATA_CASE_STUDIES:
|
||||
parameters.update(_SPARSE_DATA_OVERRIDES)
|
||||
|
||||
default_timeout = 600 if suffix_p in _LATENT_FACTOR_SUFFIXES else 300
|
||||
timeout = overrides.get("timeout", default_timeout)
|
||||
|
||||
# --- Snapshot registry state before run ---
|
||||
registry_db = isolated_model_output / case_study / "run_log" / "registry.db"
|
||||
before = _registry_summary(registry_db)
|
||||
|
||||
# --- Execute ---
|
||||
result = run_notebook(
|
||||
py_path=notebook_path,
|
||||
parameters=parameters,
|
||||
timeout=timeout,
|
||||
output_dir=isolated_model_output,
|
||||
log_path=LOG_PATH,
|
||||
)
|
||||
|
||||
assert result["status"] == "ok", (
|
||||
f"\n{'=' * 70}\nFAILED: {case_study}::{stage}\n{'=' * 70}\n{result['error']}\n{'=' * 70}"
|
||||
)
|
||||
|
||||
# --- Registry assertions (for notebooks that register) ---
|
||||
after = _registry_summary(registry_db)
|
||||
|
||||
# Check if this notebook is expected to register (match on suffix)
|
||||
stage_match = _STAGE_RE.match(stage)
|
||||
suffix = stage[len(stage_match.group(0)) :] if stage_match else stage
|
||||
expects_registration = suffix in _REGISTERING_SUFFIXES
|
||||
|
||||
if expects_registration:
|
||||
new_training = after["training_runs"] - before["training_runs"]
|
||||
new_predictions = after["prediction_sets"] - before["prediction_sets"]
|
||||
|
||||
# Check for new entries OR updated entries (upserts).
|
||||
# Some notebooks (e.g. 12_pca) re-register configs that were
|
||||
# already created by an earlier notebook (11_latent_factors),
|
||||
# resulting in upserts with 0 net new rows but updated entry_points.
|
||||
expected_ep = _expected_entry_point(stage)
|
||||
runs = _query_registry(
|
||||
registry_db,
|
||||
"training_runs",
|
||||
f"entry_point = '{expected_ep}'",
|
||||
)
|
||||
|
||||
if new_training > 0:
|
||||
assert new_predictions > 0, (
|
||||
f"{case_study}::{stage} created {new_training} training_runs "
|
||||
f"but 0 new prediction_sets"
|
||||
)
|
||||
print(
|
||||
f"\n Registry OK: +{new_training} training_runs, "
|
||||
f"+{new_predictions} prediction_sets"
|
||||
)
|
||||
elif len(runs) > 0:
|
||||
print(
|
||||
f"\n Registry OK: {len(runs)} training_runs with "
|
||||
f"entry_point='{expected_ep}' (upserted, no net new rows)"
|
||||
)
|
||||
else:
|
||||
# Neither new entries nor matching entry_points — real failure
|
||||
msg = (
|
||||
f"{case_study}::{stage} has register=True but created "
|
||||
f"0 new training_runs and found 0 with "
|
||||
f"entry_point='{expected_ep}' (total: {after['training_runs']})"
|
||||
)
|
||||
raise AssertionError(msg)
|
||||
else:
|
||||
# Non-registering notebook — just report what happened
|
||||
new_training = after["training_runs"] - before["training_runs"]
|
||||
if new_training > 0:
|
||||
print(
|
||||
f"\n Note: {stage} created {new_training} training_runs "
|
||||
f"(not in _REGISTERING_NOTEBOOKS set — consider adding)"
|
||||
)
|
||||
else:
|
||||
print("\n OK (no registry writes expected)")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Guard: committed notebooks must not leak machine-specific absolute paths.
|
||||
|
||||
Notebooks executed on a contributor's machine can bake ``/home/<user>/...``
|
||||
paths into committed cell outputs and papermill metadata. Readers should never
|
||||
see those. This test scans every tracked ``.ipynb`` and fails if any survive.
|
||||
|
||||
To fix a failure, run::
|
||||
|
||||
uv run python scripts/sanitize_notebook_paths.py
|
||||
|
||||
which rewrites repo-internal paths to repo-relative form. See that script and
|
||||
``utils.paths.display_path`` for the source-side helper.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
from sanitize_notebook_paths import _iter_notebooks, sanitize_text # noqa: E402
|
||||
|
||||
|
||||
def test_no_machine_specific_paths_in_committed_notebooks() -> None:
|
||||
offenders: list[str] = []
|
||||
for nb in _iter_notebooks():
|
||||
raw = nb.read_text(encoding="utf-8")
|
||||
_, n = sanitize_text(raw)
|
||||
if n:
|
||||
offenders.append(f"{nb.relative_to(REPO_ROOT)} ({n})")
|
||||
assert not offenders, (
|
||||
"Notebooks leak machine-specific absolute paths in their committed "
|
||||
"outputs/metadata. Run `uv run python scripts/sanitize_notebook_paths.py` "
|
||||
"to fix:\n " + "\n ".join(offenders)
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Gate: a committed notebook must be its current .py executed in production.
|
||||
|
||||
Stamped notebooks carry ``metadata.ml4t_provenance`` recording the git blob of the
|
||||
paired ``.py`` they were executed from and whether the run used production
|
||||
parameters. This test fails if any *stamped* notebook is stale (its ``.py`` changed
|
||||
since execution) or was committed from a TEST-mode run.
|
||||
|
||||
Unstamped notebooks are not failed here (adoption is gradual — stamp notebooks as
|
||||
they are re-run through the canonical path). See
|
||||
``scripts/notebook_provenance.py`` for the stamp/check tool. To stamp::
|
||||
|
||||
uv run python scripts/notebook_provenance.py stamp <nb.ipynb> --executor <env>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
from notebook_provenance import check_all # noqa: E402
|
||||
|
||||
|
||||
def test_stamped_notebooks_are_current_and_production() -> None:
|
||||
stale, testmode, _unverified = check_all(strict=False)
|
||||
assert not stale and not testmode, (
|
||||
"Committed notebooks are out of sync with their source .py:\n"
|
||||
+ (
|
||||
" STALE (re-run in the canonical env):\n " + "\n ".join(stale) + "\n"
|
||||
if stale
|
||||
else ""
|
||||
)
|
||||
+ (
|
||||
" TEST-MODE (must be a production run):\n " + "\n ".join(testmode)
|
||||
if testmode
|
||||
else ""
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Tests for utils/paths.py — chapter/case-study path resolution.
|
||||
|
||||
Pins the redirection semantics of ML4T_OUTPUT_DIR (test isolation env var)
|
||||
and the input validation on the chapter/strategy registries. The redirection
|
||||
behavior is load-bearing: every case-study pipeline notebook depends on it
|
||||
to avoid overwriting production artifacts during tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from utils.paths import (
|
||||
CHAPTERS,
|
||||
REPO_ROOT,
|
||||
STRATEGY_IDS,
|
||||
get_case_study_dir,
|
||||
get_chapter_dir,
|
||||
get_output_dir,
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Registry invariants
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_chapters_registry_covers_1_through_27() -> None:
|
||||
"""The book ships 27 chapters; the registry must enumerate all of them."""
|
||||
assert set(CHAPTERS.keys()) == set(range(1, 28))
|
||||
|
||||
|
||||
def test_chapter_directory_names_are_prefixed_by_number() -> None:
|
||||
"""Notebook discovery in tests/pm_helpers relies on the NN_ prefix pattern."""
|
||||
for n, dirname in CHAPTERS.items():
|
||||
assert dirname.startswith(f"{n:02d}_"), f"chapter {n} dir {dirname!r} missing prefix"
|
||||
|
||||
|
||||
def test_strategy_ids_match_case_studies_dir() -> None:
|
||||
"""STRATEGY_IDS should mirror case_studies/<id>/ on disk (structure invariant)."""
|
||||
cs_root = REPO_ROOT / "case_studies"
|
||||
on_disk = {
|
||||
p.name
|
||||
for p in cs_root.iterdir()
|
||||
if p.is_dir() and not p.name.startswith(("_", ".")) and p.name not in {"utils", "config"}
|
||||
}
|
||||
# STRATEGY_IDS is the enforced registry; on_disk may contain extras (ignored_subdirs)
|
||||
# but every declared id must exist on disk.
|
||||
missing = STRATEGY_IDS - on_disk
|
||||
assert not missing, f"STRATEGY_IDS declare non-existent case studies: {missing}"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# get_chapter_dir
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_chapter_dir_returns_absolute_path() -> None:
|
||||
path = get_chapter_dir(7)
|
||||
assert path.is_absolute()
|
||||
assert path.name == "07_defining_the_learning_task"
|
||||
|
||||
|
||||
def test_get_chapter_dir_rejects_out_of_range() -> None:
|
||||
with pytest.raises(ValueError, match="Invalid chapter"):
|
||||
get_chapter_dir(99)
|
||||
|
||||
|
||||
def test_get_chapter_dir_rejects_zero() -> None:
|
||||
with pytest.raises(ValueError, match="Invalid chapter"):
|
||||
get_chapter_dir(0)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# get_output_dir — test-mode redirection
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_output_dir_production_path(tmp_path, monkeypatch) -> None:
|
||||
"""No env var → writes under the chapter dir."""
|
||||
monkeypatch.delenv("ML4T_OUTPUT_DIR", raising=False)
|
||||
monkeypatch.delenv("ML4T_CHAPTER_OUTPUT_DIR", raising=False)
|
||||
|
||||
# Use create=False to avoid making a directory in the real repo.
|
||||
path = get_output_dir(7, "etfs", create=False)
|
||||
assert path == get_chapter_dir(7) / "output" / "etfs"
|
||||
|
||||
|
||||
def test_get_output_dir_redirects_under_ml4t_output_dir(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("ML4T_OUTPUT_DIR", str(tmp_path))
|
||||
monkeypatch.delenv("ML4T_CHAPTER_OUTPUT_DIR", raising=False)
|
||||
|
||||
path = get_output_dir(7, "etfs")
|
||||
assert path == tmp_path / "ch07_etfs"
|
||||
assert path.exists()
|
||||
|
||||
|
||||
def test_get_output_dir_chapter_specific_env_wins_over_global(tmp_path, monkeypatch) -> None:
|
||||
"""ML4T_CHAPTER_OUTPUT_DIR should take precedence over ML4T_OUTPUT_DIR."""
|
||||
ch_dir = tmp_path / "chapter-only"
|
||||
global_dir = tmp_path / "global"
|
||||
monkeypatch.setenv("ML4T_CHAPTER_OUTPUT_DIR", str(ch_dir))
|
||||
monkeypatch.setenv("ML4T_OUTPUT_DIR", str(global_dir))
|
||||
|
||||
path = get_output_dir(11, "etfs")
|
||||
assert path.parent == ch_dir
|
||||
assert not str(path).startswith(str(global_dir))
|
||||
|
||||
|
||||
def test_get_output_dir_zero_pads_chapter_number(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("ML4T_OUTPUT_DIR", str(tmp_path))
|
||||
monkeypatch.delenv("ML4T_CHAPTER_OUTPUT_DIR", raising=False)
|
||||
|
||||
assert get_output_dir(3, "x").name == "ch03_x"
|
||||
assert get_output_dir(11, "x").name == "ch11_x"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# get_case_study_dir — test-mode redirection
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_case_study_dir_production_path(monkeypatch) -> None:
|
||||
"""No env var → writes under case_studies/{id}/."""
|
||||
monkeypatch.delenv("ML4T_OUTPUT_DIR", raising=False)
|
||||
|
||||
path = get_case_study_dir("etfs", create=False)
|
||||
assert path == REPO_ROOT / "case_studies" / "etfs"
|
||||
|
||||
|
||||
def test_get_case_study_dir_redirects_under_ml4t_output_dir(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("ML4T_OUTPUT_DIR", str(tmp_path))
|
||||
|
||||
path = get_case_study_dir("etfs")
|
||||
assert path == tmp_path / "etfs"
|
||||
assert path.exists()
|
||||
|
||||
|
||||
def test_get_case_study_dir_create_false_does_not_mkdir(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("ML4T_OUTPUT_DIR", str(tmp_path))
|
||||
|
||||
path = get_case_study_dir("etfs", create=False)
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_get_case_study_dir_create_true_is_idempotent(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("ML4T_OUTPUT_DIR", str(tmp_path))
|
||||
|
||||
first = get_case_study_dir("etfs", create=True)
|
||||
second = get_case_study_dir("etfs", create=True)
|
||||
assert first == second
|
||||
assert first.exists()
|
||||
@@ -0,0 +1,98 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.pm_helpers import (
|
||||
RECORD_REPLAY,
|
||||
RECORD_REWRITE,
|
||||
TIER_ON_DEMAND,
|
||||
TIER_PER_COMMIT,
|
||||
TIER_WEEKLY,
|
||||
collect_chapter_notebooks,
|
||||
current_test_tier,
|
||||
get_record_mode,
|
||||
get_reruns,
|
||||
get_tier,
|
||||
)
|
||||
|
||||
|
||||
def test_collect_chapter_notebooks_keeps_real_notebooks_and_skips_helpers() -> None:
|
||||
notebooks = {path.as_posix() for path in collect_chapter_notebooks(Path("."), range(1, 28))}
|
||||
|
||||
assert "06_strategy_definition/02_case_study_overview.py" in notebooks
|
||||
assert "08_financial_features/case_study_feature_summary.py" in notebooks
|
||||
assert "11_ml_pipeline/08_ml_backtest_intro.py" in notebooks
|
||||
assert "16_strategy_simulation/01_backtest_first_principles.py" in notebooks
|
||||
assert "21_rl_execution_hedging/07_backtest_with_impact.py" in notebooks
|
||||
|
||||
assert "03_market_microstructure/filter_itch_symbol.py" not in notebooks
|
||||
assert "03_market_microstructure/lob_utils.py" not in notebooks
|
||||
assert "03_market_microstructure/lob_generator.py" not in notebooks
|
||||
assert "13_dl_time_series/dl_utils.py" not in notebooks
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier / reruns / record_mode helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_tier_defaults_to_per_commit() -> None:
|
||||
assert get_tier({}) == TIER_PER_COMMIT
|
||||
assert get_tier({"tier": None}) == TIER_PER_COMMIT
|
||||
|
||||
|
||||
def test_get_tier_accepts_valid_values() -> None:
|
||||
assert get_tier({"tier": "per_commit"}) == TIER_PER_COMMIT
|
||||
assert get_tier({"tier": "weekly"}) == TIER_WEEKLY
|
||||
assert get_tier({"tier": "on_demand"}) == TIER_ON_DEMAND
|
||||
|
||||
|
||||
def test_get_tier_rejects_invalid() -> None:
|
||||
with pytest.raises(ValueError, match="Invalid tier"):
|
||||
get_tier({"tier": "nightly"})
|
||||
|
||||
|
||||
def test_current_test_tier_defaults_to_per_commit(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("ML4T_TEST_TIER", raising=False)
|
||||
assert current_test_tier() == TIER_PER_COMMIT
|
||||
|
||||
|
||||
def test_current_test_tier_reads_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ML4T_TEST_TIER", "weekly")
|
||||
assert current_test_tier() == TIER_WEEKLY
|
||||
monkeypatch.setenv("ML4T_TEST_TIER", "on_demand")
|
||||
assert current_test_tier() == TIER_ON_DEMAND
|
||||
|
||||
|
||||
def test_current_test_tier_rejects_invalid(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("ML4T_TEST_TIER", "bogus")
|
||||
with pytest.raises(ValueError, match="Invalid ML4T_TEST_TIER"):
|
||||
current_test_tier()
|
||||
|
||||
|
||||
def test_get_reruns_default_zero() -> None:
|
||||
assert get_reruns({}) == 0
|
||||
|
||||
|
||||
def test_get_reruns_returns_int() -> None:
|
||||
assert get_reruns({"reruns": 3}) == 3
|
||||
|
||||
|
||||
def test_get_reruns_rejects_negative_or_nonint() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
get_reruns({"reruns": -1})
|
||||
with pytest.raises(ValueError):
|
||||
get_reruns({"reruns": "2"})
|
||||
|
||||
|
||||
def test_get_record_mode_defaults_to_replay() -> None:
|
||||
assert get_record_mode({}) == RECORD_REPLAY
|
||||
|
||||
|
||||
def test_get_record_mode_accepts_rewrite() -> None:
|
||||
assert get_record_mode({"record_mode": "rewrite"}) == RECORD_REWRITE
|
||||
|
||||
|
||||
def test_get_record_mode_rejects_invalid() -> None:
|
||||
with pytest.raises(ValueError, match="Invalid record_mode"):
|
||||
get_record_mode({"record_mode": "none"})
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Tests for case_studies/utils/registry/completeness.py.
|
||||
|
||||
The skip-if-complete invariant is load-bearing: wrong answers either
|
||||
waste hours of compute (should have skipped) or silently reuse stale
|
||||
partial artifacts (should have retrained). Tests:
|
||||
|
||||
- missing training_run → exists=False, not complete
|
||||
- present training_run but no prediction_sets → partial, missing list
|
||||
- complete run → exists=True, complete=True
|
||||
- partial backtest_run (no daily_returns.parquet) → partial
|
||||
- require_metrics=False relaxes the completeness rule as advertised
|
||||
- skip_* wrappers return the same status (behavior pin for callers)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from case_studies.utils.registry.completeness import (
|
||||
BacktestRunStatus,
|
||||
TrainingRunStatus,
|
||||
backtest_run_status,
|
||||
skip_backtest_if_complete,
|
||||
skip_training_if_complete,
|
||||
training_run_status,
|
||||
)
|
||||
from case_studies.utils.registry.specs import (
|
||||
backtest_hash_from_parts,
|
||||
training_hash_from_spec,
|
||||
)
|
||||
from case_studies.utils.registry.store import (
|
||||
REGISTRY_SCHEMA_SQL,
|
||||
_backtest_dir,
|
||||
_prediction_dir,
|
||||
_registry_db_path,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def case_dir(tmp_path) -> Path:
|
||||
"""Create a minimal case study dir with an empty registry.db."""
|
||||
case = tmp_path / "etfs"
|
||||
case.mkdir()
|
||||
db_path = _registry_db_path(case)
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
db = sqlite3.connect(str(db_path))
|
||||
db.executescript(REGISTRY_SCHEMA_SQL)
|
||||
db.commit()
|
||||
db.close()
|
||||
return case
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def canonical_spec() -> dict:
|
||||
return {"family": "linear", "label": "fwd_ret_21d", "seed": 42, "n_folds": 5}
|
||||
|
||||
|
||||
def _insert_training_run(case_dir: Path, spec: dict) -> str:
|
||||
"""Insert a training_runs row. Returns the training_hash."""
|
||||
t_hash = training_hash_from_spec(spec)
|
||||
db = sqlite3.connect(str(_registry_db_path(case_dir)))
|
||||
now = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
db.execute(
|
||||
"INSERT INTO training_runs (training_hash, family, label, config_name, spec_json, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(t_hash, spec["family"], spec["label"], "test", "{}", now),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
return t_hash
|
||||
|
||||
|
||||
def _insert_prediction_set(case_dir: Path, t_hash: str, split: str = "val") -> str:
|
||||
"""Insert a prediction_sets row. Returns the prediction_hash."""
|
||||
from case_studies.utils.registry.specs import prediction_hash_from_parts
|
||||
|
||||
p_hash = prediction_hash_from_parts(t_hash, None, split)
|
||||
db = sqlite3.connect(str(_registry_db_path(case_dir)))
|
||||
now = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
db.execute(
|
||||
"INSERT INTO prediction_sets (prediction_hash, training_hash, split, created_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(p_hash, t_hash, split, now),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
return p_hash
|
||||
|
||||
|
||||
def _insert_prediction_metric(case_dir: Path, p_hash: str, ic_mean: float = 0.01) -> None:
|
||||
db = sqlite3.connect(str(_registry_db_path(case_dir)))
|
||||
now = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
db.execute(
|
||||
"INSERT INTO prediction_metrics (prediction_hash, computed_at, ic_mean) VALUES (?, ?, ?)",
|
||||
(p_hash, now, ic_mean),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def _touch_predictions_file(case_dir: Path, p_hash: str) -> None:
|
||||
d = _prediction_dir(case_dir, p_hash)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / "predictions.parquet").write_bytes(b"fake")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# training_run_status
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_training_status_missing_run_is_not_complete(case_dir, canonical_spec) -> None:
|
||||
status = training_run_status("etfs", canonical_spec, case_dir=case_dir)
|
||||
|
||||
assert not status.exists
|
||||
assert not status.complete
|
||||
assert not status.partial # Neither complete nor partial when nothing exists
|
||||
assert "training_run" in status.missing
|
||||
assert status.training_hash == training_hash_from_spec(canonical_spec)
|
||||
|
||||
|
||||
def test_training_status_run_without_predictions_is_partial(case_dir, canonical_spec) -> None:
|
||||
_insert_training_run(case_dir, canonical_spec)
|
||||
|
||||
status = training_run_status("etfs", canonical_spec, case_dir=case_dir)
|
||||
assert status.exists
|
||||
assert status.partial
|
||||
assert not status.complete
|
||||
assert "prediction_sets" in status.missing
|
||||
|
||||
|
||||
def test_training_status_run_without_metrics_is_partial(case_dir, canonical_spec) -> None:
|
||||
t_hash = _insert_training_run(case_dir, canonical_spec)
|
||||
p_hash = _insert_prediction_set(case_dir, t_hash)
|
||||
_touch_predictions_file(case_dir, p_hash)
|
||||
# Deliberately skip metric insertion
|
||||
|
||||
status = training_run_status("etfs", canonical_spec, case_dir=case_dir)
|
||||
assert status.partial
|
||||
assert not status.complete
|
||||
assert "ic_mean" in status.missing
|
||||
|
||||
|
||||
def test_training_status_complete_when_all_artifacts_present(case_dir, canonical_spec) -> None:
|
||||
t_hash = _insert_training_run(case_dir, canonical_spec)
|
||||
p_hash = _insert_prediction_set(case_dir, t_hash)
|
||||
_insert_prediction_metric(case_dir, p_hash)
|
||||
_touch_predictions_file(case_dir, p_hash)
|
||||
|
||||
status = training_run_status("etfs", canonical_spec, case_dir=case_dir)
|
||||
assert status.complete
|
||||
assert not status.partial
|
||||
assert status.missing == ()
|
||||
|
||||
|
||||
def test_training_status_require_metrics_false_relaxes_completeness(
|
||||
case_dir, canonical_spec
|
||||
) -> None:
|
||||
"""With require_metrics=False, a run is complete even without ic_mean."""
|
||||
t_hash = _insert_training_run(case_dir, canonical_spec)
|
||||
p_hash = _insert_prediction_set(case_dir, t_hash)
|
||||
_touch_predictions_file(case_dir, p_hash)
|
||||
# No metric insertion
|
||||
|
||||
status = training_run_status("etfs", canonical_spec, case_dir=case_dir, require_metrics=False)
|
||||
assert status.complete
|
||||
assert "ic_mean" not in status.missing
|
||||
|
||||
|
||||
def test_training_status_require_predictions_file_false_relaxes(case_dir, canonical_spec) -> None:
|
||||
t_hash = _insert_training_run(case_dir, canonical_spec)
|
||||
p_hash = _insert_prediction_set(case_dir, t_hash)
|
||||
_insert_prediction_metric(case_dir, p_hash)
|
||||
# Deliberately skip writing predictions.parquet
|
||||
|
||||
status = training_run_status(
|
||||
"etfs", canonical_spec, case_dir=case_dir, require_predictions_file=False
|
||||
)
|
||||
assert status.complete
|
||||
|
||||
|
||||
def test_training_status_summary_formats() -> None:
|
||||
missing = TrainingRunStatus(
|
||||
training_hash="abcdef1234567890",
|
||||
exists=False,
|
||||
has_predictions=False,
|
||||
has_predictions_file=False,
|
||||
has_metrics=False,
|
||||
missing=("training_run",),
|
||||
)
|
||||
assert "no training_run" in missing.summary()
|
||||
assert "abcdef123456" in missing.summary()
|
||||
|
||||
partial = TrainingRunStatus(
|
||||
training_hash="abcdef1234567890",
|
||||
exists=True,
|
||||
has_predictions=True,
|
||||
has_predictions_file=False,
|
||||
has_metrics=False,
|
||||
missing=("predictions.parquet", "ic_mean"),
|
||||
)
|
||||
assert "partial" in partial.summary()
|
||||
assert "predictions.parquet" in partial.summary()
|
||||
|
||||
complete = TrainingRunStatus(
|
||||
training_hash="abcdef1234567890",
|
||||
exists=True,
|
||||
has_predictions=True,
|
||||
has_predictions_file=True,
|
||||
has_metrics=True,
|
||||
)
|
||||
assert "complete" in complete.summary()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# skip_training_if_complete (thin wrapper)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_skip_training_returns_same_status_as_direct_call(case_dir, canonical_spec) -> None:
|
||||
direct = training_run_status("etfs", canonical_spec, case_dir=case_dir)
|
||||
wrapped = skip_training_if_complete("etfs", canonical_spec, case_dir=case_dir, verbose=False)
|
||||
assert direct.training_hash == wrapped.training_hash
|
||||
assert direct.complete == wrapped.complete
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# backtest_run_status
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_backtest_status_missing_is_not_complete(case_dir) -> None:
|
||||
strategy = {"signal": {"method": "equal_weight_top_k", "top_k": 10}}
|
||||
|
||||
status = backtest_run_status("etfs", "pred123", strategy, case_dir=case_dir)
|
||||
assert not status.exists
|
||||
assert not status.complete
|
||||
assert "backtest_run" in status.missing
|
||||
|
||||
|
||||
def test_backtest_status_partial_when_returns_missing(case_dir) -> None:
|
||||
strategy = {"signal": {"method": "equal_weight_top_k", "top_k": 10}}
|
||||
b_hash = backtest_hash_from_parts("pred123", strategy)
|
||||
|
||||
# Insert backtest_runs row but no returns file
|
||||
db = sqlite3.connect(str(_registry_db_path(case_dir)))
|
||||
# Need to satisfy FK: insert a synthetic prediction first.
|
||||
# The schema is ON CASCADE default, but FK references must exist.
|
||||
db.execute("PRAGMA foreign_keys=OFF") # Tests: skip FK check to simplify fixture
|
||||
now = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
db.execute(
|
||||
"INSERT INTO backtest_runs (backtest_hash, prediction_hash, spec_json, created_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(b_hash, "pred123", "{}", now),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO backtest_metrics (backtest_hash, computed_at, sharpe) VALUES (?, ?, ?)",
|
||||
(b_hash, now, 1.5),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
status = backtest_run_status("etfs", "pred123", strategy, case_dir=case_dir)
|
||||
assert status.exists
|
||||
assert status.partial
|
||||
assert not status.complete
|
||||
assert "daily_returns.parquet" in status.missing
|
||||
|
||||
|
||||
def test_backtest_status_complete_when_all_present(case_dir) -> None:
|
||||
strategy = {"signal": {"method": "equal_weight_top_k", "top_k": 10}}
|
||||
b_hash = backtest_hash_from_parts("pred123", strategy)
|
||||
|
||||
db = sqlite3.connect(str(_registry_db_path(case_dir)))
|
||||
db.execute("PRAGMA foreign_keys=OFF")
|
||||
now = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
db.execute(
|
||||
"INSERT INTO backtest_runs (backtest_hash, prediction_hash, spec_json, created_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(b_hash, "pred123", "{}", now),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT INTO backtest_metrics (backtest_hash, computed_at, sharpe) VALUES (?, ?, ?)",
|
||||
(b_hash, now, 1.5),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
d = _backtest_dir(case_dir, b_hash)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / "daily_returns.parquet").write_bytes(b"fake")
|
||||
|
||||
status = backtest_run_status("etfs", "pred123", strategy, case_dir=case_dir)
|
||||
assert status.complete
|
||||
|
||||
|
||||
def test_backtest_status_hash_is_deterministic(case_dir) -> None:
|
||||
strategy = {"signal": {"method": "x", "top_k": 10}, "allocation": {"method": "eq"}}
|
||||
s1 = backtest_run_status("etfs", "pred123", strategy, case_dir=case_dir)
|
||||
s2 = backtest_run_status("etfs", "pred123", dict(strategy), case_dir=case_dir)
|
||||
assert s1.backtest_hash == s2.backtest_hash
|
||||
|
||||
|
||||
def test_backtest_status_summary_formats() -> None:
|
||||
missing = BacktestRunStatus(
|
||||
backtest_hash="abc123def456000",
|
||||
exists=False,
|
||||
has_returns=False,
|
||||
has_metrics=False,
|
||||
missing=("backtest_run",),
|
||||
)
|
||||
assert "no backtest_run" in missing.summary()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# skip_backtest_if_complete (thin wrapper)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_skip_backtest_wraps_backtest_run_status(case_dir) -> None:
|
||||
strategy = {"signal": {"method": "x"}}
|
||||
direct = backtest_run_status("etfs", "pred123", strategy, case_dir=case_dir)
|
||||
wrapped = skip_backtest_if_complete(
|
||||
"etfs", "pred123", strategy, case_dir=case_dir, verbose=False
|
||||
)
|
||||
assert direct.backtest_hash == wrapped.backtest_hash
|
||||
assert direct.complete == wrapped.complete
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Tests for case_studies/utils/registry/metrics.py — prediction metric aggregation.
|
||||
|
||||
The critical contract this pins is the *classification IC rule*: when
|
||||
``task_type='classification'``, IC is computed against the continuous
|
||||
return column named by ``eval_col``, never against the binary label.
|
||||
Computing IC against the binary label degenerates to ``2·(AUC − 0.5)``
|
||||
and is not a valid Spearman rank correlation against returns — the April
|
||||
classification IC backfill was needed precisely because this was wrong.
|
||||
|
||||
These tests lock in:
|
||||
|
||||
- Regression path: IC is cross-sectional rank correlation of y_score
|
||||
vs y_true (continuous); RMSE / MAE are computed on valid pairs.
|
||||
- Classification path: IC uses ``eval_col`` (continuous return), and
|
||||
AUC / log_loss / accuracy use the binary ``y_true_col``.
|
||||
- Classification IC on y_score + y_ret equals the regression IC on the
|
||||
same y_score + y_ret — i.e., the classification branch does not
|
||||
silently fall back to using the binary label for IC.
|
||||
- Missing ``eval_col`` (or a column that isn't on the DataFrame) raises
|
||||
loudly rather than silently collapsing to AUC-disguised-as-IC.
|
||||
- Headline aggregation: ``ic_mean`` = mean across folds, ``ic_t`` =
|
||||
Newey-West-free pooled t, ``pct_positive`` = fraction of folds with
|
||||
IC > 0, ``n_folds`` = count, ``task_type`` = 'classification' for classification.
|
||||
|
||||
All fixtures are hermetic — no real data, no setup.yaml.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
from case_studies.utils.registry.metrics import compute_prediction_fold_metrics
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def regression_predictions() -> pl.DataFrame:
|
||||
"""2 folds × 10 dates × 10 entities with y_score ≈ y_true (high IC)."""
|
||||
rng = np.random.default_rng(0)
|
||||
rows = []
|
||||
for fold in (0, 1):
|
||||
for d in range(10):
|
||||
for e in range(10):
|
||||
y_true = float(rng.normal())
|
||||
y_score = 0.8 * y_true + 0.2 * float(rng.normal())
|
||||
rows.append(
|
||||
{
|
||||
"timestamp": f"2024-{fold + 1:02d}-{d + 1:02d}",
|
||||
"symbol": f"S{e}",
|
||||
"fold_id": fold,
|
||||
"y_true": y_true,
|
||||
"y_score": y_score,
|
||||
}
|
||||
)
|
||||
return pl.DataFrame(rows).with_columns(pl.col("timestamp").str.to_date())
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def classification_predictions(regression_predictions) -> pl.DataFrame:
|
||||
"""Classification variant: y_true is the sign of the continuous return.
|
||||
|
||||
- ``y_ret`` preserves the continuous return (the eval_col target)
|
||||
- ``y_true`` is the binary label (1 if return > 0)
|
||||
- ``y_score`` is a probability-style score from a logistic squash of
|
||||
the original continuous score, so it is still monotone in the
|
||||
continuous return
|
||||
"""
|
||||
return regression_predictions.rename({"y_score": "y_score_cont"}).with_columns(
|
||||
y_ret=pl.col("y_true"),
|
||||
y_true=pl.when(pl.col("y_true") > 0).then(1).otherwise(0).cast(pl.Int8),
|
||||
y_score=1.0 / (1.0 + (-pl.col("y_score_cont")).exp()),
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Regression path
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_regression_computes_rmse_mae_and_ic(regression_predictions) -> None:
|
||||
headline, folds = compute_prediction_fold_metrics(
|
||||
regression_predictions, task_type="regression"
|
||||
)
|
||||
assert set(folds.keys()) == {0, 1}
|
||||
for fm in folds.values():
|
||||
assert "rmse" in fm and "mae" in fm
|
||||
assert "ic" in fm
|
||||
# y_score ≈ 0.8 * y_true so IC should be high
|
||||
assert fm["ic"] > 0.5
|
||||
# RMSE / MAE on standard normals with 0.2σ noise should be tiny-ish
|
||||
assert fm["rmse"] >= 0
|
||||
assert fm["mae"] >= 0
|
||||
|
||||
|
||||
def test_regression_headline_task_type_is_regression(regression_predictions) -> None:
|
||||
headline, _ = compute_prediction_fold_metrics(regression_predictions, task_type="regression")
|
||||
assert headline["task_type"] == "regression"
|
||||
|
||||
|
||||
def test_regression_headline_ic_mean_equals_fold_ic_mean(regression_predictions) -> None:
|
||||
headline, folds = compute_prediction_fold_metrics(
|
||||
regression_predictions, task_type="regression"
|
||||
)
|
||||
expected = float(np.mean([fm["ic"] for fm in folds.values()]))
|
||||
assert math.isclose(headline["ic_mean"], expected, rel_tol=1e-12)
|
||||
|
||||
|
||||
def test_regression_pct_positive_matches_fraction_of_positive_ic_folds(
|
||||
regression_predictions,
|
||||
) -> None:
|
||||
headline, folds = compute_prediction_fold_metrics(
|
||||
regression_predictions, task_type="regression"
|
||||
)
|
||||
expected = float(np.mean([fm["ic"] > 0 for fm in folds.values()]))
|
||||
assert headline["pct_positive"] == expected
|
||||
|
||||
|
||||
def test_regression_headline_ic_t_is_mean_over_stderr(regression_predictions) -> None:
|
||||
headline, folds = compute_prediction_fold_metrics(
|
||||
regression_predictions, task_type="regression"
|
||||
)
|
||||
fold_ics = np.array([fm["ic"] for fm in folds.values()])
|
||||
expected_t = float(np.mean(fold_ics) / (np.std(fold_ics) / np.sqrt(len(fold_ics))))
|
||||
assert math.isclose(headline["ic_t"], expected_t, rel_tol=1e-12)
|
||||
|
||||
|
||||
def test_regression_n_folds_matches_unique_fold_ids(regression_predictions) -> None:
|
||||
headline, _ = compute_prediction_fold_metrics(regression_predictions, task_type="regression")
|
||||
assert headline["n_folds"] == 2
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Classification path — the load-bearing contract
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_classification_without_eval_col_raises_value_error(classification_predictions) -> None:
|
||||
"""The defensive check that saved us from re-introducing the IC-on-binary bug."""
|
||||
with pytest.raises(ValueError, match="eval_col"):
|
||||
compute_prediction_fold_metrics(
|
||||
classification_predictions, task_type="classification", class_values=[0, 1]
|
||||
)
|
||||
|
||||
|
||||
def test_classification_missing_eval_col_raises_key_error(classification_predictions) -> None:
|
||||
with pytest.raises(KeyError, match="does_not_exist"):
|
||||
compute_prediction_fold_metrics(
|
||||
classification_predictions,
|
||||
task_type="classification",
|
||||
eval_col="does_not_exist",
|
||||
class_values=[0, 1],
|
||||
)
|
||||
|
||||
|
||||
def test_classification_ic_is_computed_vs_continuous_return(
|
||||
regression_predictions, classification_predictions
|
||||
) -> None:
|
||||
"""The classification IC on (y_score_cont, y_ret) must equal the regression
|
||||
IC on the same (y_score, y_true) — proving classification did not silently
|
||||
fall back to IC-vs-binary.
|
||||
|
||||
We compare against a regression run on the ORIGINAL continuous pair, to
|
||||
establish what the IC should be. Then we compare the classification IC
|
||||
on (y_score_cont, y_ret) against that reference. Classification's IC
|
||||
uses the CONTINUOUS score column via ``y_score_col`` override.
|
||||
"""
|
||||
# Reference: regression on the continuous ground truth
|
||||
ref_headline, _ = compute_prediction_fold_metrics(
|
||||
regression_predictions, task_type="regression"
|
||||
)
|
||||
|
||||
# Classification run — pass the continuous score column as ``y_score_col``
|
||||
# and point ``eval_col`` at the continuous return. That pairing should
|
||||
# reproduce the regression IC exactly.
|
||||
cls_headline, _ = compute_prediction_fold_metrics(
|
||||
classification_predictions,
|
||||
task_type="classification",
|
||||
y_score_col="y_score_cont",
|
||||
eval_col="y_ret",
|
||||
class_values=[0, 1],
|
||||
)
|
||||
|
||||
assert math.isclose(cls_headline["ic_mean"], ref_headline["ic_mean"], rel_tol=1e-12)
|
||||
assert math.isclose(cls_headline["ic_std"], ref_headline["ic_std"], rel_tol=1e-12)
|
||||
|
||||
|
||||
def test_classification_ic_differs_from_ic_on_binary_label(classification_predictions) -> None:
|
||||
"""Sanity: IC on continuous return is materially different from what you'd
|
||||
get if you wrongly computed IC on the binary label.
|
||||
|
||||
We simulate the wrong behavior by aliasing y_true as eval_col and check
|
||||
that IC differs from the correct run.
|
||||
"""
|
||||
correct, _ = compute_prediction_fold_metrics(
|
||||
classification_predictions,
|
||||
task_type="classification",
|
||||
y_score_col="y_score_cont",
|
||||
eval_col="y_ret",
|
||||
class_values=[0, 1],
|
||||
)
|
||||
|
||||
# Build a frame where eval_col points at the BINARY label (simulating the bug).
|
||||
wrong_df = classification_predictions.with_columns(y_ret_bin=pl.col("y_true"))
|
||||
wrong, _ = compute_prediction_fold_metrics(
|
||||
wrong_df,
|
||||
task_type="classification",
|
||||
y_score_col="y_score_cont",
|
||||
eval_col="y_ret_bin",
|
||||
class_values=[0, 1],
|
||||
)
|
||||
|
||||
# The two IC values MUST differ materially — if they matched, IC-on-binary
|
||||
# would be indistinguishable from IC-on-continuous, defeating the rule.
|
||||
assert abs(correct["ic_mean"] - wrong["ic_mean"]) > 0.05
|
||||
|
||||
|
||||
def test_classification_adds_auc_accuracy_logloss_to_headline(
|
||||
classification_predictions,
|
||||
) -> None:
|
||||
headline, _ = compute_prediction_fold_metrics(
|
||||
classification_predictions,
|
||||
task_type="classification",
|
||||
y_score_col="y_score_cont",
|
||||
eval_col="y_ret",
|
||||
class_values=[0, 1],
|
||||
)
|
||||
for m in ("auc_roc", "auc_pr", "log_loss", "brier_score", "accuracy", "balanced_accuracy"):
|
||||
assert m in headline, f"missing classification metric {m!r} in headline"
|
||||
|
||||
|
||||
def test_classification_headline_task_type_is_one(classification_predictions) -> None:
|
||||
headline, _ = compute_prediction_fold_metrics(
|
||||
classification_predictions,
|
||||
task_type="classification",
|
||||
y_score_col="y_score_cont",
|
||||
eval_col="y_ret",
|
||||
class_values=[0, 1],
|
||||
)
|
||||
assert headline["task_type"] == "classification"
|
||||
|
||||
|
||||
def test_classification_auc_is_computed_on_binary_label(classification_predictions) -> None:
|
||||
"""AUC / accuracy / log_loss go against the binary y_true, not the continuous
|
||||
eval_col. A classification score that is perfectly monotone in the binary
|
||||
label should yield AUC=1.0 (well above the chance baseline of 0.5).
|
||||
"""
|
||||
headline, _ = compute_prediction_fold_metrics(
|
||||
classification_predictions,
|
||||
task_type="classification",
|
||||
y_score_col="y_score_cont",
|
||||
eval_col="y_ret",
|
||||
class_values=[0, 1],
|
||||
)
|
||||
# Strongly monotone score ⇒ high AUC (not 0.5)
|
||||
assert headline["auc_roc"] > 0.9
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Edge cases
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_single_fold_returns_zero_ic_std_and_zero_t(regression_predictions) -> None:
|
||||
"""With only one fold, cross-fold stddev is undefined; the function reports 0."""
|
||||
fold0_only = regression_predictions.filter(pl.col("fold_id") == 0)
|
||||
headline, _ = compute_prediction_fold_metrics(fold0_only, task_type="regression")
|
||||
assert headline["n_folds"] == 1
|
||||
assert headline["ic_std"] == 0.0
|
||||
assert headline["ic_t"] == 0.0
|
||||
|
||||
|
||||
def test_accepts_pandas_dataframe(regression_predictions) -> None:
|
||||
"""pd.DataFrame input is converted to polars internally."""
|
||||
import pandas as pd
|
||||
|
||||
pdf = regression_predictions.to_pandas()
|
||||
assert isinstance(pdf, pd.DataFrame)
|
||||
headline, folds = compute_prediction_fold_metrics(pdf, task_type="regression")
|
||||
assert set(folds.keys()) == {0, 1}
|
||||
assert "ic_mean" in headline
|
||||
|
||||
|
||||
def test_deterministic_across_calls(regression_predictions) -> None:
|
||||
"""Repeated calls produce numerically equivalent output.
|
||||
|
||||
BLAS threading can introduce <1e-13 jitter in rank-correlation summations,
|
||||
so we use approximate equality rather than bit-exact.
|
||||
"""
|
||||
a_head, a_folds = compute_prediction_fold_metrics(
|
||||
regression_predictions, task_type="regression"
|
||||
)
|
||||
b_head, b_folds = compute_prediction_fold_metrics(
|
||||
regression_predictions, task_type="regression"
|
||||
)
|
||||
for key in a_head:
|
||||
assert a_head[key] == pytest.approx(b_head[key], abs=1e-10), key
|
||||
for fold_id in a_folds:
|
||||
for key in a_folds[fold_id]:
|
||||
assert a_folds[fold_id][key] == pytest.approx(b_folds[fold_id][key], abs=1e-10), (
|
||||
f"fold {fold_id} / {key}"
|
||||
)
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Tests for case_studies/utils/registry/queries.py cohort_metrics overrides.
|
||||
|
||||
Pins three behaviors of ``load_backtest_metrics``:
|
||||
|
||||
1. ER values from ``cohort_metrics`` override the legacy ``dsr`` /
|
||||
``dsr_pvalue`` / ``expected_max_sharpe`` / ``min_trl_periods`` columns
|
||||
for rows that are the family leader; non-leaders pass through with
|
||||
NULLs (legacy backtest_metrics columns were dropped post-Phase-H).
|
||||
2. The pre-migration fallback — when ``cohort_metrics`` doesn't exist on
|
||||
the registry — returns raw ``backtest_metrics`` rows with null
|
||||
placeholder columns for the override columns. The fallback is keyed
|
||||
on a ``sqlite_master`` probe (``_table_exists``) so the narrow case
|
||||
stays narrow.
|
||||
3. Duplicate-leader_hash defense — if two family cohorts somehow share
|
||||
a leader_hash, the join keeps the first row and emits a warning
|
||||
instead of fanning out and silently changing row cardinality.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
|
||||
def _bootstrap_registry(db_path: Path, *, with_cohort_metrics: bool = True) -> None:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
# Minimal backtest_metrics schema — only the columns the override
|
||||
# touches plus backtest_hash + a passthrough column.
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE backtest_metrics (
|
||||
backtest_hash TEXT PRIMARY KEY,
|
||||
sharpe REAL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.executemany(
|
||||
"INSERT INTO backtest_metrics(backtest_hash, sharpe) VALUES (?, ?)",
|
||||
[("hash_leader", 1.2), ("hash_other", 0.6)],
|
||||
)
|
||||
if with_cohort_metrics:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE cohort_metrics (
|
||||
cohort_type TEXT NOT NULL,
|
||||
stage TEXT,
|
||||
label TEXT NOT NULL,
|
||||
family TEXT,
|
||||
leader_hash TEXT NOT NULL,
|
||||
k_variants INTEGER,
|
||||
dsr_er REAL,
|
||||
dsr_er_pvalue REAL,
|
||||
expected_max_sharpe_er REAL,
|
||||
min_trl_periods_er REAL,
|
||||
leader_min_trl REAL,
|
||||
pbo REAL,
|
||||
pbo_n_combinations REAL,
|
||||
pbo_median_oos_rank REAL,
|
||||
pbo_mean_degradation REAL,
|
||||
pbo_n_folds REAL,
|
||||
reality_check_pvalue REAL,
|
||||
reality_check_statistic REAL,
|
||||
reality_check_k REAL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _seed_cohort_row(
|
||||
db_path: Path,
|
||||
*,
|
||||
leader_hash: str,
|
||||
stage: str = "signal",
|
||||
label: str = "fwd_ret_21d",
|
||||
family: str = "linear",
|
||||
dsr_er: float = 0.85,
|
||||
dsr_er_pvalue: float = 0.02,
|
||||
) -> None:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO cohort_metrics(
|
||||
cohort_type, stage, label, family, leader_hash, k_variants,
|
||||
dsr_er, dsr_er_pvalue, expected_max_sharpe_er, min_trl_periods_er,
|
||||
leader_min_trl, pbo, pbo_n_combinations, pbo_median_oos_rank,
|
||||
pbo_mean_degradation, pbo_n_folds, reality_check_pvalue,
|
||||
reality_check_statistic, reality_check_k
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"family",
|
||||
stage,
|
||||
label,
|
||||
family,
|
||||
leader_hash,
|
||||
30,
|
||||
dsr_er,
|
||||
dsr_er_pvalue,
|
||||
1.5,
|
||||
40.0,
|
||||
40.0,
|
||||
0.18,
|
||||
20.0,
|
||||
3.5,
|
||||
0.1,
|
||||
6.0,
|
||||
0.04,
|
||||
0.7,
|
||||
30.0,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry_with_cohort(tmp_path: Path) -> Path:
|
||||
case_dir = tmp_path / "case_x"
|
||||
db_path = case_dir / "run_log" / "registry.db"
|
||||
_bootstrap_registry(db_path, with_cohort_metrics=True)
|
||||
_seed_cohort_row(db_path, leader_hash="hash_leader")
|
||||
return case_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registry_without_cohort(tmp_path: Path) -> Path:
|
||||
case_dir = tmp_path / "case_y"
|
||||
db_path = case_dir / "run_log" / "registry.db"
|
||||
_bootstrap_registry(db_path, with_cohort_metrics=False)
|
||||
return case_dir
|
||||
|
||||
|
||||
def test_cohort_override_applies_to_leader_and_skips_non_leader(
|
||||
registry_with_cohort: Path,
|
||||
) -> None:
|
||||
from case_studies.utils.registry.queries import load_backtest_metrics
|
||||
|
||||
df = load_backtest_metrics("case_x", case_dir=registry_with_cohort)
|
||||
assert df.height == 2 # row count preserved
|
||||
|
||||
leader = df.filter(pl.col("backtest_hash") == "hash_leader")
|
||||
other = df.filter(pl.col("backtest_hash") == "hash_other")
|
||||
|
||||
assert leader["dsr"].item() == pytest.approx(0.85)
|
||||
assert leader["dsr_pvalue"].item() == pytest.approx(0.02)
|
||||
assert leader["k_variants"].item() == 30
|
||||
assert leader["pbo"].item() == pytest.approx(0.18)
|
||||
# Non-leader carries NULLs for the override columns.
|
||||
assert other["dsr"].item() is None
|
||||
assert other["pbo"].item() is None
|
||||
|
||||
|
||||
def test_missing_cohort_table_returns_raw_rows_with_null_overrides(
|
||||
registry_without_cohort: Path,
|
||||
) -> None:
|
||||
from case_studies.utils.registry.queries import load_backtest_metrics
|
||||
|
||||
df = load_backtest_metrics("case_y", case_dir=registry_without_cohort)
|
||||
assert df.height == 2
|
||||
assert {"dsr", "pbo", "reality_check_pvalue"}.issubset(df.columns)
|
||||
# All override columns null when cohort_metrics doesn't exist.
|
||||
assert df["dsr"].is_null().all()
|
||||
assert df["pbo"].is_null().all()
|
||||
|
||||
|
||||
def test_duplicate_leader_hash_keeps_row_cardinality_and_warns(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Two family cohorts with the same leader_hash must not fan out."""
|
||||
import logging
|
||||
|
||||
from case_studies.utils.registry.queries import load_backtest_metrics
|
||||
|
||||
case_dir = tmp_path / "case_z"
|
||||
db_path = case_dir / "run_log" / "registry.db"
|
||||
_bootstrap_registry(db_path, with_cohort_metrics=True)
|
||||
_seed_cohort_row(db_path, leader_hash="hash_leader", family="linear", dsr_er=0.85)
|
||||
_seed_cohort_row(db_path, leader_hash="hash_leader", family="gbm", dsr_er=0.99)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="case_studies.utils.registry.queries"):
|
||||
df = load_backtest_metrics("case_z", case_dir=case_dir)
|
||||
# Row count preserved; the join didn't fan out.
|
||||
assert df.height == 2
|
||||
assert any("duplicate family-cohort leader_hash" in r.message for r in caplog.records)
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Tests for case_studies/utils/registry/specs.py.
|
||||
|
||||
Hashing determinism is load-bearing: the registry is a content-addressed
|
||||
store, so any perturbation to hash computation (key ordering, separator
|
||||
choice, seed handling) silently duplicates runs and corrupts lineage.
|
||||
|
||||
These tests pin the exact byte-for-byte hash output so a reformat of
|
||||
canonical_json or compute_hash cannot change the addresses of existing runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from case_studies.utils.registry.specs import (
|
||||
DEFAULT_SEED,
|
||||
HASH_LENGTH,
|
||||
_validate_spec,
|
||||
backtest_hash_from_parts,
|
||||
canonical_json,
|
||||
compute_hash,
|
||||
prediction_hash_from_parts,
|
||||
training_hash_from_spec,
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# canonical_json — deterministic serialization
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_canonical_json_sorts_keys() -> None:
|
||||
a = canonical_json({"b": 2, "a": 1})
|
||||
b = canonical_json({"a": 1, "b": 2})
|
||||
assert a == b
|
||||
assert a == '{"a":1,"b":2}'
|
||||
|
||||
|
||||
def test_canonical_json_uses_compact_separators() -> None:
|
||||
assert canonical_json({"x": 1}) == '{"x":1}'
|
||||
|
||||
|
||||
def test_canonical_json_stringifies_unserializable_via_default() -> None:
|
||||
"""Path/Enum/datetime-like fields fall through `default=str` so a spec
|
||||
never fails serialization."""
|
||||
from pathlib import Path
|
||||
|
||||
out = canonical_json({"path": Path("/tmp/x.parquet")})
|
||||
assert "/tmp/x.parquet" in out
|
||||
|
||||
|
||||
def test_canonical_json_is_deterministic_across_nested_structures() -> None:
|
||||
spec = {
|
||||
"outer": {"b": [3, 2, 1], "a": {"z": 9, "y": 8}},
|
||||
"flat": 42,
|
||||
}
|
||||
first = canonical_json(spec)
|
||||
second = canonical_json(spec)
|
||||
assert first == second
|
||||
# Keys are sorted at every level
|
||||
assert first.index('"a":') < first.index('"b":')
|
||||
assert first.index('"y":') < first.index('"z":')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# compute_hash — sha256 truncation invariant
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_compute_hash_default_length_is_12() -> None:
|
||||
h = compute_hash("anything")
|
||||
assert len(h) == HASH_LENGTH == 12
|
||||
|
||||
|
||||
def test_compute_hash_is_prefix_of_full_sha256() -> None:
|
||||
content = "some_training_content"
|
||||
expected_prefix = hashlib.sha256(content.encode()).hexdigest()[:12]
|
||||
assert compute_hash(content) == expected_prefix
|
||||
|
||||
|
||||
def test_compute_hash_length_override_respects_arg() -> None:
|
||||
assert len(compute_hash("x", length=6)) == 6
|
||||
assert len(compute_hash("x", length=64)) == 64
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# training_hash_from_spec
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _base_spec(**overrides) -> dict:
|
||||
spec = {
|
||||
"family": "linear",
|
||||
"label": "fwd_ret_21d",
|
||||
"seed": 42,
|
||||
"n_folds": 5,
|
||||
}
|
||||
spec.update(overrides)
|
||||
return spec
|
||||
|
||||
|
||||
def test_training_hash_is_deterministic() -> None:
|
||||
spec = _base_spec()
|
||||
assert training_hash_from_spec(spec) == training_hash_from_spec(dict(spec))
|
||||
|
||||
|
||||
def test_training_hash_differs_when_seed_changes() -> None:
|
||||
assert training_hash_from_spec(_base_spec(seed=1)) != training_hash_from_spec(
|
||||
_base_spec(seed=2)
|
||||
)
|
||||
|
||||
|
||||
def test_training_hash_differs_when_family_changes() -> None:
|
||||
assert training_hash_from_spec(_base_spec(family="gbm")) != training_hash_from_spec(
|
||||
_base_spec(family="linear")
|
||||
)
|
||||
|
||||
|
||||
def test_training_hash_differs_when_label_changes() -> None:
|
||||
assert training_hash_from_spec(_base_spec(label="fwd_ret_5d")) != training_hash_from_spec(
|
||||
_base_spec(label="fwd_ret_21d")
|
||||
)
|
||||
|
||||
|
||||
def test_training_hash_invariant_under_key_order() -> None:
|
||||
"""Client code may build the spec dict in arbitrary order; hash must be stable."""
|
||||
spec_a = {"family": "gbm", "label": "fwd_ret_21d", "seed": 42}
|
||||
spec_b = {"seed": 42, "label": "fwd_ret_21d", "family": "gbm"}
|
||||
assert training_hash_from_spec(spec_a) == training_hash_from_spec(spec_b)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Spec validation
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_spec_missing_seed_injects_default(caplog) -> None:
|
||||
"""Missing-seed-only case: warn and inject DEFAULT_SEED."""
|
||||
with caplog.at_level("WARNING"):
|
||||
enriched = _validate_spec({"family": "gbm", "label": "fwd_ret_5d"})
|
||||
|
||||
assert enriched["seed"] == DEFAULT_SEED
|
||||
assert "missing 'seed'" in caplog.text
|
||||
|
||||
|
||||
def test_validate_spec_missing_multiple_fields_raises() -> None:
|
||||
"""Anything beyond a missing seed is a hard error."""
|
||||
with pytest.raises(ValueError, match="missing required fields"):
|
||||
_validate_spec({"family": "gbm"}) # missing label + seed
|
||||
|
||||
|
||||
def test_validate_spec_does_not_mutate_original() -> None:
|
||||
original = {"family": "gbm", "label": "x"}
|
||||
enriched = _validate_spec(original)
|
||||
assert "seed" not in original # original untouched
|
||||
assert enriched["seed"] == DEFAULT_SEED
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# prediction_hash_from_parts
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_prediction_hash_combines_training_hash_checkpoint_split() -> None:
|
||||
h = prediction_hash_from_parts("abc123", 100, "val")
|
||||
# Reconstruct exact content and compare to the public API
|
||||
assert h == compute_hash("abc123|100|val")
|
||||
|
||||
|
||||
def test_prediction_hash_none_checkpoint_becomes_final() -> None:
|
||||
h_none = prediction_hash_from_parts("abc", None, "val")
|
||||
h_final_str = prediction_hash_from_parts("abc", None, "val") # Same call
|
||||
assert h_none == compute_hash("abc|final|val")
|
||||
assert h_none == h_final_str
|
||||
|
||||
|
||||
def test_prediction_hash_distinct_on_split() -> None:
|
||||
assert prediction_hash_from_parts("abc", 1, "val") != prediction_hash_from_parts(
|
||||
"abc", 1, "test"
|
||||
)
|
||||
|
||||
|
||||
def test_prediction_hash_distinct_on_checkpoint() -> None:
|
||||
assert prediction_hash_from_parts("abc", 10, "val") != prediction_hash_from_parts(
|
||||
"abc", 20, "val"
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# backtest_hash_from_parts
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_backtest_hash_combines_prediction_hash_and_strategy_spec() -> None:
|
||||
strategy = {"signal": {"method": "equal_weight_top_k", "top_k": 10}}
|
||||
h = backtest_hash_from_parts("pred123", strategy)
|
||||
assert h == compute_hash(f"pred123|{canonical_json(strategy)}")
|
||||
|
||||
|
||||
def test_backtest_hash_sensitive_to_strategy_change() -> None:
|
||||
base = {"top_k": 10}
|
||||
variant = {"top_k": 20}
|
||||
assert backtest_hash_from_parts("p1", base) != backtest_hash_from_parts("p1", variant)
|
||||
|
||||
|
||||
def test_backtest_hash_invariant_under_strategy_key_order() -> None:
|
||||
a = {"signal": {"method": "x", "top_k": 10}, "allocation": {"method": "eq"}}
|
||||
b = {"allocation": {"method": "eq"}, "signal": {"top_k": 10, "method": "x"}}
|
||||
assert backtest_hash_from_parts("p", a) == backtest_hash_from_parts("p", b)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Regression pin — the exact hash for a canonical spec
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_training_hash_regression_pin_for_canonical_spec() -> None:
|
||||
"""Pin the exact hash of a minimal valid spec. Changing this value
|
||||
invalidates every existing registry entry — so any change should be an
|
||||
explicit migration, not an accidental refactor."""
|
||||
spec = {"family": "linear", "label": "fwd_ret_21d", "seed": 42}
|
||||
content = json.dumps(spec, sort_keys=True, separators=(",", ":"), default=str)
|
||||
expected = hashlib.sha256(content.encode()).hexdigest()[:12]
|
||||
|
||||
assert training_hash_from_spec(spec) == expected
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Correctness tests for case_studies/utils/sequence_dataset.py.
|
||||
|
||||
These tests encode the methodology property that every DL case study
|
||||
depends on: the first validation sequence must predict the target at
|
||||
val_start, using an input window that may extend back into train (this
|
||||
is legal because features at times ≤ val_start are already known at
|
||||
val_start; only labels after val_start are held out).
|
||||
|
||||
A test failure here means validation sequences have a warmup-drop bug
|
||||
where the first `lookback` trading days of each val fold are silently
|
||||
discarded — this inflates DL Sharpe on adversarial sample-period
|
||||
exclusions and diverges from how the model would be deployed in
|
||||
production.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
|
||||
def _synthetic_fold_df(
|
||||
*,
|
||||
n_symbols: int = 3,
|
||||
train_start: str = "2020-01-01",
|
||||
train_end: str = "2020-12-31",
|
||||
val_start: str = "2021-01-01",
|
||||
val_end: str = "2021-06-30",
|
||||
freq: str = "B",
|
||||
) -> tuple[pd.DataFrame, pd.Series, pd.Series, pd.Timestamp, pd.Timestamp]:
|
||||
"""Build a synthetic panel: N symbols × business days train+val.
|
||||
|
||||
Returns (df, train_mask, val_mask, val_start_ts, val_end_ts).
|
||||
"""
|
||||
all_dates = pd.date_range(train_start, val_end, freq=freq)
|
||||
rows = []
|
||||
for i, sym in enumerate([f"S{j}" for j in range(n_symbols)]):
|
||||
for dt in all_dates:
|
||||
rows.append(
|
||||
{
|
||||
"symbol": sym,
|
||||
"timestamp": dt,
|
||||
"feat0": float(i) + dt.toordinal() / 1e6,
|
||||
"feat1": float(i) * 2 + dt.toordinal() / 1e6,
|
||||
"y": float(i) + np.sin(dt.toordinal() / 10.0),
|
||||
}
|
||||
)
|
||||
df = pd.DataFrame(rows)
|
||||
|
||||
ts_start = pd.Timestamp(train_start)
|
||||
ts_train_end = pd.Timestamp(train_end)
|
||||
ts_val_start = pd.Timestamp(val_start)
|
||||
ts_val_end = pd.Timestamp(val_end)
|
||||
|
||||
train_mask = (df["timestamp"] >= ts_start) & (df["timestamp"] <= ts_train_end)
|
||||
val_mask = (df["timestamp"] >= ts_val_start) & (df["timestamp"] <= ts_val_end)
|
||||
return df, train_mask, val_mask, ts_val_start, ts_val_end
|
||||
|
||||
|
||||
def test_val_sequence_starts_at_val_start():
|
||||
"""Every symbol's first val sequence should have target == val_start.
|
||||
|
||||
This is the core correctness property: in production, on val_start
|
||||
we have all pre-val features available and must emit a prediction
|
||||
for val_start. The prior (buggy) implementation discards the first
|
||||
`lookback` rows of each val fold.
|
||||
"""
|
||||
from case_studies.utils.sequence_dataset import prepare_fold_sequence_stores
|
||||
|
||||
df, train_mask, val_mask, val_start_ts, _ = _synthetic_fold_df()
|
||||
lookback = 20
|
||||
|
||||
_, val_store, fold_info = prepare_fold_sequence_stores(
|
||||
df,
|
||||
train_mask=train_mask,
|
||||
val_mask=val_mask,
|
||||
feature_names=["feat0", "feat1"],
|
||||
label_col="y",
|
||||
date_col="timestamp",
|
||||
entity_col="symbol",
|
||||
lookback=lookback,
|
||||
val_start=val_start_ts,
|
||||
)
|
||||
|
||||
assert fold_info["val_sequences"] > 0, "No val sequences generated"
|
||||
|
||||
# For each symbol, find the first sequence's target timestamp
|
||||
for symbol_id in range(val_store.n_symbols):
|
||||
end_positions = val_store.end_idx[val_store.symbol_idx == symbol_id]
|
||||
if len(end_positions) == 0:
|
||||
continue
|
||||
first_end = end_positions.min()
|
||||
first_target_ts = val_store.timestamps[symbol_id][first_end]
|
||||
assert pd.Timestamp(first_target_ts) == val_start_ts, (
|
||||
f"Symbol {val_store.entities[symbol_id]!r}: first val sequence "
|
||||
f"predicts {first_target_ts}, expected {val_start_ts}. "
|
||||
f"This indicates the warmup-drop bug — the first {lookback} "
|
||||
f"trading days of val are being silently skipped."
|
||||
)
|
||||
|
||||
|
||||
def test_val_sequence_count_matches_val_calendar_days():
|
||||
"""Number of val sequences per symbol == number of val-period rows."""
|
||||
from case_studies.utils.sequence_dataset import prepare_fold_sequence_stores
|
||||
|
||||
df, train_mask, val_mask, val_start_ts, val_end_ts = _synthetic_fold_df()
|
||||
lookback = 20
|
||||
|
||||
_, val_store, fold_info = prepare_fold_sequence_stores(
|
||||
df,
|
||||
train_mask=train_mask,
|
||||
val_mask=val_mask,
|
||||
feature_names=["feat0", "feat1"],
|
||||
label_col="y",
|
||||
date_col="timestamp",
|
||||
entity_col="symbol",
|
||||
lookback=lookback,
|
||||
val_start=val_start_ts,
|
||||
)
|
||||
|
||||
expected_per_symbol = int(
|
||||
df[(df["timestamp"] >= val_start_ts) & (df["timestamp"] <= val_end_ts)]
|
||||
.groupby("symbol")
|
||||
.size()
|
||||
.iloc[0]
|
||||
)
|
||||
actual_per_symbol = fold_info["val_sequences"] // val_store.n_symbols
|
||||
assert actual_per_symbol == expected_per_symbol, (
|
||||
f"Each symbol should have {expected_per_symbol} val sequences "
|
||||
f"(one per val trading day); got {actual_per_symbol}. "
|
||||
f"Shortfall indicates warmup drop."
|
||||
)
|
||||
|
||||
|
||||
def test_val_sequence_targets_never_include_train_period():
|
||||
"""No val sequence should have a target timestamp < val_start.
|
||||
|
||||
Train-tail rows are used for priming input features only; their
|
||||
labels must not appear as val targets (that would be leakage).
|
||||
"""
|
||||
from case_studies.utils.sequence_dataset import prepare_fold_sequence_stores
|
||||
|
||||
df, train_mask, val_mask, val_start_ts, _ = _synthetic_fold_df()
|
||||
lookback = 20
|
||||
|
||||
_, val_store, _ = prepare_fold_sequence_stores(
|
||||
df,
|
||||
train_mask=train_mask,
|
||||
val_mask=val_mask,
|
||||
feature_names=["feat0", "feat1"],
|
||||
label_col="y",
|
||||
date_col="timestamp",
|
||||
entity_col="symbol",
|
||||
lookback=lookback,
|
||||
val_start=val_start_ts,
|
||||
)
|
||||
|
||||
for symbol_id in range(val_store.n_symbols):
|
||||
end_positions = val_store.end_idx[val_store.symbol_idx == symbol_id]
|
||||
for pos in end_positions:
|
||||
target_ts = val_store.timestamps[symbol_id][pos]
|
||||
assert pd.Timestamp(target_ts) >= val_start_ts, (
|
||||
f"Val sequence target {target_ts} predates val_start "
|
||||
f"{val_start_ts} — train-tail priming is leaking into "
|
||||
f"predictions."
|
||||
)
|
||||
|
||||
|
||||
def test_backwards_compatible_without_val_start():
|
||||
"""Omitting val_start should preserve the legacy behavior exactly.
|
||||
|
||||
This ensures existing callers that don't pass val_start get the
|
||||
same (buggy, but known) output — the fix is opt-in via val_start.
|
||||
The legacy path may be removed in a later commit.
|
||||
"""
|
||||
from case_studies.utils.sequence_dataset import prepare_fold_sequence_stores
|
||||
|
||||
df, train_mask, val_mask, _, _ = _synthetic_fold_df()
|
||||
lookback = 20
|
||||
|
||||
_, val_store, fold_info = prepare_fold_sequence_stores(
|
||||
df,
|
||||
train_mask=train_mask,
|
||||
val_mask=val_mask,
|
||||
feature_names=["feat0", "feat1"],
|
||||
label_col="y",
|
||||
date_col="timestamp",
|
||||
entity_col="symbol",
|
||||
lookback=lookback,
|
||||
# val_start intentionally omitted — legacy behavior
|
||||
)
|
||||
|
||||
# In legacy mode, first val sequence should be at position `lookback`
|
||||
# within the val slice (the bug we're documenting).
|
||||
for symbol_id in range(val_store.n_symbols):
|
||||
end_positions = val_store.end_idx[val_store.symbol_idx == symbol_id]
|
||||
if len(end_positions) == 0:
|
||||
continue
|
||||
assert int(end_positions.min()) == lookback, (
|
||||
"Legacy path should start sequences at position=lookback"
|
||||
)
|
||||
@@ -0,0 +1,494 @@
|
||||
"""Tests for case_studies/utils/signals.py — prediction → weight contracts.
|
||||
|
||||
Signal construction sits on the critical path between every model and every
|
||||
backtest. A silent behavior change here would corrupt every Ch16-20
|
||||
strategy result. These tests pin the observable contracts:
|
||||
|
||||
- threshold / percentile cutoffs are applied with the documented
|
||||
inequality semantics (``>`` for fixed threshold, ``>=`` for cross-
|
||||
sectional percentile, ``>`` for rolling)
|
||||
- long-short variants produce symmetric signals and weights
|
||||
- equal-weight top-K weights sum to 1 (or 0 for excluded assets), and
|
||||
score-weighted weights sum to 1 with score-proportional magnitudes
|
||||
- the config dispatcher routes every documented method and raises on
|
||||
unknowns
|
||||
- ``direction=short_only`` is a pure sign flip of the weight column
|
||||
- zero-weight rows are filtered from the output
|
||||
- outputs are deterministic across repeated calls on the same input
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import polars as pl
|
||||
import pytest
|
||||
from polars.testing import assert_frame_equal
|
||||
|
||||
from case_studies.utils.signals import (
|
||||
build_target_weights,
|
||||
build_target_weights_from_config,
|
||||
cross_sectional_percentile_signal,
|
||||
fixed_threshold_signal,
|
||||
per_symbol_rolling_percentile_signal,
|
||||
rolling_percentile_signal,
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def predictions_2d5s() -> pl.DataFrame:
|
||||
"""2 timestamps × 5 symbols (A–E), y_score ascending per date."""
|
||||
return pl.DataFrame(
|
||||
{
|
||||
"timestamp": ["2024-01-01"] * 5 + ["2024-01-02"] * 5,
|
||||
"symbol": list("ABCDE") * 2,
|
||||
"y_score": [0.1, 0.3, 0.5, 0.7, 0.9, 0.2, 0.4, 0.6, 0.8, 1.0],
|
||||
}
|
||||
).with_columns(pl.col("timestamp").str.to_date())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def predictions_2d6s() -> pl.DataFrame:
|
||||
"""2 timestamps × 6 symbols (A–F) for even-split top/bottom tests."""
|
||||
return pl.DataFrame(
|
||||
{
|
||||
"timestamp": ["2024-01-01"] * 6 + ["2024-01-02"] * 6,
|
||||
"symbol": list("ABCDEF") * 2,
|
||||
"y_score": [0.1, 0.2, 0.3, 0.7, 0.8, 0.9, 0.1, 0.3, 0.5, 0.6, 0.8, 1.0],
|
||||
}
|
||||
).with_columns(pl.col("timestamp").str.to_date())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def predictions_rolling() -> pl.DataFrame:
|
||||
"""50 timestamps × 2 symbols for rolling-window tests."""
|
||||
rng = np.random.default_rng(42)
|
||||
ts = pl.date_range(pl.date(2024, 1, 1), pl.date(2024, 2, 19), "1d", eager=True)
|
||||
rows = [(t, s, float(rng.random())) for s in ("A", "B") for t in ts]
|
||||
return pl.DataFrame(rows, schema=["timestamp", "symbol", "y_score"], orient="row").sort(
|
||||
"timestamp", "symbol"
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# fixed_threshold_signal
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fixed_threshold_long_only_strict_greater_than(predictions_2d5s) -> None:
|
||||
"""signal=1 iff score > threshold (strict). At-threshold scores get 0."""
|
||||
out = fixed_threshold_signal(predictions_2d5s, threshold=0.5, signal_type="long_only")
|
||||
# 0.5 → 0 (not strictly >); 0.7/0.9/0.6/0.8/1.0 → 1
|
||||
expected = [0, 0, 0, 1, 1, 0, 0, 1, 1, 1]
|
||||
assert out["signal"].to_list() == expected
|
||||
|
||||
|
||||
def test_fixed_threshold_signal_is_int8(predictions_2d5s) -> None:
|
||||
out = fixed_threshold_signal(predictions_2d5s, threshold=0.5)
|
||||
assert out["signal"].dtype == pl.Int8
|
||||
|
||||
|
||||
def test_fixed_threshold_preserves_row_count_and_columns(predictions_2d5s) -> None:
|
||||
out = fixed_threshold_signal(predictions_2d5s, threshold=0.5)
|
||||
assert out.height == predictions_2d5s.height
|
||||
assert set(predictions_2d5s.columns) <= set(out.columns)
|
||||
|
||||
|
||||
def test_fixed_threshold_long_short_uses_symmetric_mirror() -> None:
|
||||
"""long_short with threshold=0.7 → above 0.7 → 1, below (1-0.7)=0.3 → -1."""
|
||||
df = pl.DataFrame({"y_score": [0.1, 0.4, 0.5, 0.6, 0.9]})
|
||||
out = fixed_threshold_signal(df, threshold=0.7, signal_type="long_short")
|
||||
assert out["signal"].to_list() == [-1, 0, 0, 0, 1]
|
||||
|
||||
|
||||
def test_fixed_threshold_deterministic_across_calls(predictions_2d5s) -> None:
|
||||
a = fixed_threshold_signal(predictions_2d5s, threshold=0.5)
|
||||
b = fixed_threshold_signal(predictions_2d5s, threshold=0.5)
|
||||
assert_frame_equal(a, b)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# rolling_percentile_signal
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rolling_percentile_adds_threshold_column(predictions_rolling) -> None:
|
||||
out = rolling_percentile_signal(predictions_rolling, window=10, percentile=80.0)
|
||||
assert "rolling_threshold" in out.columns
|
||||
|
||||
|
||||
def test_rolling_percentile_early_window_has_null_threshold(predictions_rolling) -> None:
|
||||
"""First window-1 rows per asset have insufficient history → null threshold."""
|
||||
out = rolling_percentile_signal(predictions_rolling, window=10, percentile=80.0)
|
||||
# 2 symbols × (window-1=9) early rows = 18 nulls
|
||||
assert out["rolling_threshold"].null_count() == 18
|
||||
|
||||
|
||||
def test_rolling_percentile_long_short_adds_both_thresholds(predictions_rolling) -> None:
|
||||
out = rolling_percentile_signal(
|
||||
predictions_rolling, window=10, percentile=80.0, signal_type="long_short"
|
||||
)
|
||||
assert "rolling_threshold" in out.columns
|
||||
assert "rolling_lower_threshold" in out.columns
|
||||
# Must produce at least one long and one short signal with random data
|
||||
counts = dict(out.group_by("signal").len().iter_rows())
|
||||
assert counts.get(1, 0) > 0
|
||||
assert counts.get(-1, 0) > 0
|
||||
|
||||
|
||||
def test_rolling_percentile_per_asset_independence() -> None:
|
||||
"""Each asset computes its own rolling quantile — asset ordering shouldn't
|
||||
change its own signal sequence."""
|
||||
ts = pl.date_range(pl.date(2024, 1, 1), pl.date(2024, 1, 20), "1d", eager=True)
|
||||
rows_a = [(t, "A", float(i)) for i, t in enumerate(ts)]
|
||||
rows_b = [(t, "B", float(-i)) for i, t in enumerate(ts)]
|
||||
df = pl.DataFrame(rows_a + rows_b, schema=["timestamp", "symbol", "y_score"], orient="row")
|
||||
a_only_thresholds = rolling_percentile_signal(
|
||||
df.filter(pl.col("symbol") == "A"), window=5, percentile=80.0
|
||||
)["rolling_threshold"]
|
||||
with_both = rolling_percentile_signal(df, window=5, percentile=80.0).filter(
|
||||
pl.col("symbol") == "A"
|
||||
)["rolling_threshold"]
|
||||
assert a_only_thresholds.to_list() == with_both.to_list()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# cross_sectional_percentile_signal
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cs_percentile_long_only_at_or_above_cutoff(predictions_2d5s) -> None:
|
||||
"""cs_percentile uses ``>=`` — score equal to the threshold gets a signal.
|
||||
|
||||
At percentile=80 with 5 symbols, the 80th percentile interpolates to the
|
||||
second-highest score. With ascending scores D=0.7, E=0.9 for date 1,
|
||||
cs_threshold=0.7 and both D and E get signal=1.
|
||||
"""
|
||||
out = cross_sectional_percentile_signal(predictions_2d5s, percentile=80.0).sort(
|
||||
"timestamp", "symbol"
|
||||
)
|
||||
# Per date, top 2 by score should be selected
|
||||
assert out.filter(pl.col("signal") == 1).height == 4 # 2 dates × 2 winners
|
||||
|
||||
|
||||
def test_cs_percentile_threshold_differs_per_timestamp(predictions_2d5s) -> None:
|
||||
"""Different dates have different score distributions → different thresholds."""
|
||||
out = cross_sectional_percentile_signal(predictions_2d5s, percentile=80.0)
|
||||
per_date = out.group_by("timestamp").agg(pl.col("cs_threshold").first()).sort("timestamp")
|
||||
thresholds = per_date["cs_threshold"].to_list()
|
||||
assert thresholds[0] != thresholds[1]
|
||||
|
||||
|
||||
def test_cs_percentile_long_short_produces_both_signs(predictions_2d5s) -> None:
|
||||
out = cross_sectional_percentile_signal(
|
||||
predictions_2d5s, percentile=80.0, signal_type="long_short"
|
||||
)
|
||||
signs = set(out["signal"].to_list())
|
||||
assert 1 in signs
|
||||
assert -1 in signs
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# build_target_weights — equal_weight_top_k
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_equal_weight_top_k_long_only_weights_sum_to_1(predictions_2d5s) -> None:
|
||||
out = build_target_weights(predictions_2d5s, method="equal_weight_top_k", top_k=2)
|
||||
per_date = out.group_by("timestamp").agg(pl.col("weight").sum()).sort("timestamp")
|
||||
for w in per_date["weight"].to_list():
|
||||
assert abs(w - 1.0) < 1e-9
|
||||
|
||||
|
||||
def test_equal_weight_top_k_selects_exactly_k_assets_per_date(predictions_2d5s) -> None:
|
||||
out = build_target_weights(predictions_2d5s, method="equal_weight_top_k", top_k=2)
|
||||
per_date = out.group_by("timestamp").agg(pl.col("symbol").count().alias("n")).sort("timestamp")
|
||||
assert per_date["n"].to_list() == [2, 2]
|
||||
|
||||
|
||||
def test_equal_weight_top_k_picks_highest_scores(predictions_2d5s) -> None:
|
||||
"""With ascending scores A..E, top 2 should always be D and E."""
|
||||
out = build_target_weights(predictions_2d5s, method="equal_weight_top_k", top_k=2)
|
||||
selected = set(out["symbol"].unique().to_list())
|
||||
assert selected == {"D", "E"}
|
||||
|
||||
|
||||
def test_equal_weight_top_k_long_short_weights_are_symmetric(predictions_2d6s) -> None:
|
||||
"""long_short top_k=2 with 6 symbols: 2 long @+0.5, 2 short @-0.5, 2 zero (dropped)."""
|
||||
out = build_target_weights(
|
||||
predictions_2d6s, method="equal_weight_top_k", top_k=2, long_short=True
|
||||
)
|
||||
longs = out.filter(pl.col("weight") > 0)
|
||||
shorts = out.filter(pl.col("weight") < 0)
|
||||
assert longs.height == 4 # 2 dates × 2 longs
|
||||
assert shorts.height == 4
|
||||
# Magnitudes equal
|
||||
assert all(abs(w - 0.5) < 1e-9 for w in longs["weight"])
|
||||
assert all(abs(w + 0.5) < 1e-9 for w in shorts["weight"])
|
||||
|
||||
|
||||
def test_equal_weight_top_k_clamps_when_k_exceeds_n_assets(predictions_2d5s) -> None:
|
||||
"""Asking for top_k=100 with 5 assets per date → selects all 5, weights = 1/5."""
|
||||
out = build_target_weights(predictions_2d5s, method="equal_weight_top_k", top_k=100)
|
||||
assert out.height == 10 # all rows survive
|
||||
assert all(abs(w - 0.2) < 1e-9 for w in out["weight"])
|
||||
|
||||
|
||||
def test_equal_weight_top_k_filters_zero_weights(predictions_2d5s) -> None:
|
||||
"""The helper strips zero-weight rows from the output."""
|
||||
out = build_target_weights(predictions_2d5s, method="equal_weight_top_k", top_k=2)
|
||||
assert (out["weight"] == 0.0).sum() == 0
|
||||
|
||||
|
||||
def test_equal_weight_top_k_output_sorted_by_time_then_asset(predictions_2d5s) -> None:
|
||||
out = build_target_weights(predictions_2d5s, method="equal_weight_top_k", top_k=2)
|
||||
pairs = list(zip(out["timestamp"].to_list(), out["symbol"].to_list(), strict=True))
|
||||
assert pairs == sorted(pairs)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# build_target_weights — score_weighted_top_k
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_score_weighted_top_k_long_only_weights_sum_to_1(predictions_2d5s) -> None:
|
||||
out = build_target_weights(predictions_2d5s, method="score_weighted_top_k", top_k=2)
|
||||
per_date = out.group_by("timestamp").agg(pl.col("weight").sum())
|
||||
for w in per_date["weight"].to_list():
|
||||
assert abs(w - 1.0) < 1e-9
|
||||
|
||||
|
||||
def test_score_weighted_top_k_weight_proportional_to_abs_score(predictions_2d6s) -> None:
|
||||
"""Top 2 of [0.8, 0.9] → weights 0.8/1.7 ≈ 0.4706 and 0.9/1.7 ≈ 0.5294."""
|
||||
out = build_target_weights(predictions_2d6s, method="score_weighted_top_k", top_k=2).sort(
|
||||
"timestamp", "symbol"
|
||||
)
|
||||
date1 = out.filter(pl.col("timestamp") == pl.date(2024, 1, 1)).sort("symbol")
|
||||
weights = dict(zip(date1["symbol"].to_list(), date1["weight"].to_list(), strict=True))
|
||||
assert abs(weights["E"] - 0.8 / 1.7) < 1e-9
|
||||
assert abs(weights["F"] - 0.9 / 1.7) < 1e-9
|
||||
|
||||
|
||||
def test_score_weighted_top_k_deterministic(predictions_2d6s) -> None:
|
||||
a = build_target_weights(predictions_2d6s, method="score_weighted_top_k", top_k=2)
|
||||
b = build_target_weights(predictions_2d6s, method="score_weighted_top_k", top_k=2)
|
||||
assert_frame_equal(a.sort("timestamp", "symbol"), b.sort("timestamp", "symbol"))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# build_target_weights — inverse_vol (placeholder path: equal weight)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_inverse_vol_placeholder_uses_equal_weight(predictions_2d5s) -> None:
|
||||
"""inverse_vol is documented as a placeholder — same output as equal_weight_top_k."""
|
||||
eq = build_target_weights(predictions_2d5s, method="equal_weight_top_k", top_k=2)
|
||||
iv = build_target_weights(predictions_2d5s, method="inverse_vol", top_k=2)
|
||||
assert_frame_equal(
|
||||
eq.sort("timestamp", "symbol"),
|
||||
iv.sort("timestamp", "symbol"),
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# build_target_weights_from_config — dispatcher
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_from_config_equal_weight_top_k_matches_direct_call(predictions_2d5s) -> None:
|
||||
direct = build_target_weights(predictions_2d5s, method="equal_weight_top_k", top_k=2)
|
||||
via = build_target_weights_from_config(
|
||||
predictions_2d5s, {"method": "equal_weight_top_k", "top_k": 2}
|
||||
)
|
||||
assert_frame_equal(direct.sort("timestamp", "symbol"), via.sort("timestamp", "symbol"))
|
||||
|
||||
|
||||
def test_from_config_decile_long_short_on_small_universe(predictions_2d6s) -> None:
|
||||
"""6 symbols, decile → top_cutoff=floor(6/10)=0 clipped to 1 → 1 long, 1 short."""
|
||||
out = build_target_weights_from_config(predictions_2d6s, {"method": "decile_long_short"}).sort(
|
||||
"timestamp", "symbol"
|
||||
)
|
||||
# Per date: 1 long @ +1.0 (top score), 1 short @ -1.0 (bottom score)
|
||||
assert out.height == 4
|
||||
assert sorted(out["weight"].unique().to_list()) == [-1.0, 1.0]
|
||||
|
||||
|
||||
def test_from_config_cross_sectional_percentile(predictions_2d5s) -> None:
|
||||
out = build_target_weights_from_config(
|
||||
predictions_2d5s,
|
||||
{"method": "cross_sectional_percentile", "percentile": 80.0},
|
||||
)
|
||||
# Top 2 assets per date → weights sum to 1 per date
|
||||
per_date = out.group_by("timestamp").agg(pl.col("weight").sum())
|
||||
for w in per_date["weight"].to_list():
|
||||
assert abs(w - 1.0) < 1e-9
|
||||
|
||||
|
||||
def test_from_config_fixed_threshold_selects_above_cutoff(predictions_2d5s) -> None:
|
||||
out = build_target_weights_from_config(
|
||||
predictions_2d5s, {"method": "fixed_threshold", "threshold": 0.5}
|
||||
)
|
||||
# Per date: D (0.7), E (0.9) → 2 assets @ 0.5 each, sum to 1 on date 1.
|
||||
# Date 2: C (0.6), D (0.8), E (1.0) → 3 assets @ 1/3 each.
|
||||
date1 = out.filter(pl.col("timestamp") == pl.date(2024, 1, 1))
|
||||
date2 = out.filter(pl.col("timestamp") == pl.date(2024, 1, 2))
|
||||
assert date1.height == 2 and abs(date1["weight"].sum() - 1.0) < 1e-9
|
||||
assert date2.height == 3 and abs(date2["weight"].sum() - 1.0) < 1e-9
|
||||
|
||||
|
||||
def test_from_config_short_only_negates_weights(predictions_2d5s) -> None:
|
||||
"""direction=short_only flips signs; magnitudes identical to long_only."""
|
||||
long_w = build_target_weights_from_config(
|
||||
predictions_2d5s, {"method": "equal_weight_top_k", "top_k": 2}
|
||||
)
|
||||
short_w = build_target_weights_from_config(
|
||||
predictions_2d5s,
|
||||
{"method": "equal_weight_top_k", "top_k": 2, "direction": "short_only"},
|
||||
)
|
||||
# Sort and pair up, then verify the negation contract
|
||||
long_sorted = long_w.sort("timestamp", "symbol")
|
||||
short_sorted = short_w.sort("timestamp", "symbol")
|
||||
assert long_sorted["symbol"].to_list() == short_sorted["symbol"].to_list()
|
||||
for lw, sw in zip(
|
||||
long_sorted["weight"].to_list(), short_sorted["weight"].to_list(), strict=True
|
||||
):
|
||||
assert abs(lw + sw) < 1e-9
|
||||
|
||||
|
||||
def test_from_config_rejects_unknown_method(predictions_2d5s) -> None:
|
||||
with pytest.raises(ValueError, match="Unknown signal method"):
|
||||
build_target_weights_from_config(predictions_2d5s, {"method": "bogus"})
|
||||
|
||||
|
||||
def test_from_config_rejects_unknown_direction(predictions_2d5s) -> None:
|
||||
with pytest.raises(ValueError, match="Unknown signal direction"):
|
||||
build_target_weights_from_config(
|
||||
predictions_2d5s,
|
||||
{"method": "equal_weight_top_k", "top_k": 2, "direction": "bogus"},
|
||||
)
|
||||
|
||||
|
||||
def test_from_config_quintile_long_short_uses_5_buckets(predictions_2d5s) -> None:
|
||||
"""quintile with 5 assets → top_cutoff=1 → 1 long, 1 short per date."""
|
||||
out = build_target_weights_from_config(predictions_2d5s, {"method": "quintile_long_short"})
|
||||
per_date = out.group_by("timestamp").agg(pl.col("symbol").count().alias("n"))
|
||||
assert per_date["n"].to_list() == [2, 2] # 1 long + 1 short each date
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Determinism
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cross_sectional_percentile_deterministic(predictions_2d5s) -> None:
|
||||
a = cross_sectional_percentile_signal(predictions_2d5s, percentile=80.0)
|
||||
b = cross_sectional_percentile_signal(predictions_2d5s, percentile=80.0)
|
||||
assert_frame_equal(a, b)
|
||||
|
||||
|
||||
def test_rolling_percentile_deterministic(predictions_rolling) -> None:
|
||||
a = rolling_percentile_signal(predictions_rolling, window=10, percentile=80.0)
|
||||
b = rolling_percentile_signal(predictions_rolling, window=10, percentile=80.0)
|
||||
assert_frame_equal(a, b)
|
||||
|
||||
|
||||
def test_build_target_weights_deterministic(predictions_2d5s) -> None:
|
||||
a = build_target_weights(predictions_2d5s, method="equal_weight_top_k", top_k=2)
|
||||
b = build_target_weights(predictions_2d5s, method="equal_weight_top_k", top_k=2)
|
||||
assert_frame_equal(a, b)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# per_symbol_rolling_percentile_signal — stay_q extension
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def per_symbol_intraday() -> pl.DataFrame:
|
||||
"""30 days × 2 symbols × 14 bars/day; deterministic seeded scores."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
rng = np.random.default_rng(11)
|
||||
rows = []
|
||||
for d in range(30):
|
||||
for i in range(14):
|
||||
ts = datetime(2024, 1, 2, 9, 30) + timedelta(days=d, minutes=15 * i)
|
||||
for sym in ("AAA", "BBB"):
|
||||
rows.append((ts, sym, float(rng.standard_normal())))
|
||||
return pl.DataFrame(rows, schema=["timestamp", "symbol", "y_score"], orient="row").sort(
|
||||
"symbol", "timestamp"
|
||||
)
|
||||
|
||||
|
||||
def test_per_symbol_default_excludes_stay_thresh(per_symbol_intraday) -> None:
|
||||
"""When stay_q is None, stay_thresh column is NOT present (back-compat)."""
|
||||
out = per_symbol_rolling_percentile_signal(
|
||||
per_symbol_intraday,
|
||||
long_q=0.80,
|
||||
lookback_days=10,
|
||||
bars_per_day=14,
|
||||
)
|
||||
assert "stay_thresh" not in out.columns
|
||||
assert "signal" in out.columns
|
||||
|
||||
|
||||
def test_per_symbol_stay_q_adds_stay_thresh(per_symbol_intraday) -> None:
|
||||
"""When stay_q is set, stay_thresh column is added; non-null after warm-up."""
|
||||
out = per_symbol_rolling_percentile_signal(
|
||||
per_symbol_intraday,
|
||||
long_q=0.80,
|
||||
lookback_days=10,
|
||||
bars_per_day=14,
|
||||
stay_q=0.40,
|
||||
)
|
||||
assert "stay_thresh" in out.columns
|
||||
# After warm-up (~5 sessions = 70 bars per symbol), stay_thresh should be non-null
|
||||
by_sym = out.group_by("symbol").agg(pl.col("stay_thresh").is_not_null().sum().alias("n"))
|
||||
for n in by_sym["n"].to_list():
|
||||
assert n > 100 # well past warm-up of W//2 = 70
|
||||
|
||||
|
||||
def test_per_symbol_stay_thresh_monotonic_in_stay_q(per_symbol_intraday) -> None:
|
||||
"""stay_thresh must increase monotonically with stay_q, and thus stay below
|
||||
the entry threshold (long_q) for any stay_q < long_q.
|
||||
|
||||
long_thresh is dropped from the output, and the function forbids
|
||||
``stay_q == long_q``, so we cannot read long_thresh directly. Instead we
|
||||
verify the underlying invariant black-box: a lower stay_q must yield a
|
||||
stay_thresh at or below a higher stay_q's on the same row. Since the entry
|
||||
threshold is the long_q quantile, monotonicity transitively guarantees
|
||||
every ``stay_q < long_q`` threshold sits below it. This catches a sign flip
|
||||
between stay_thresh and long_thresh, unlike the previous tautological
|
||||
"score exceeds the threshold that fired it" check.
|
||||
"""
|
||||
kw = dict(long_q=0.80, lookback_days=10, bars_per_day=14)
|
||||
lo = per_symbol_rolling_percentile_signal(per_symbol_intraday, stay_q=0.40, **kw)
|
||||
hi = per_symbol_rolling_percentile_signal(per_symbol_intraday, stay_q=0.79, **kw)
|
||||
|
||||
joined = (
|
||||
lo.select(["symbol", "timestamp", "stay_thresh"])
|
||||
.join(
|
||||
hi.select(["symbol", "timestamp", pl.col("stay_thresh").alias("stay_thresh_hi")]),
|
||||
on=["symbol", "timestamp"],
|
||||
how="inner",
|
||||
)
|
||||
.filter(pl.col("stay_thresh").is_not_null() & pl.col("stay_thresh_hi").is_not_null())
|
||||
)
|
||||
|
||||
assert joined.height > 100 # well past warm-up
|
||||
# q=0.40 quantile must never exceed the q=0.79 quantile (< the q=0.80 entry).
|
||||
assert (joined["stay_thresh"] - joined["stay_thresh_hi"]).max() <= 1e-9
|
||||
|
||||
|
||||
def test_per_symbol_rejects_stay_q_at_or_above_long_q(per_symbol_intraday) -> None:
|
||||
with pytest.raises(ValueError, match="stay_q must be < long_q"):
|
||||
per_symbol_rolling_percentile_signal(
|
||||
per_symbol_intraday,
|
||||
long_q=0.60,
|
||||
lookback_days=10,
|
||||
bars_per_day=14,
|
||||
stay_q=0.60,
|
||||
)
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Tests for case_studies/utils/slot_strategy.py — persistent-slot selection.
|
||||
|
||||
The slot mechanism is a new selection method introduced for intraday case
|
||||
studies where per-symbol score distributions and signal-based exits matter.
|
||||
These tests pin the observable contracts of the high-level
|
||||
``build_persistent_slot_weights_hybrid`` entry plus the underlying
|
||||
``_run_slot_simulation`` mechanism:
|
||||
|
||||
- max-hold caps position age regardless of score
|
||||
- signal-exit fires when current score < stay threshold
|
||||
- capacity is respected (max_slots concurrent holdings)
|
||||
- new entries are score-ordered when capacity is constrained
|
||||
- short_only flips the weight sign
|
||||
- stale-pred rows (older than freshness tolerance) are dropped before entry
|
||||
- empty input returns empty frame with canonical schema
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import numpy as np
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
from case_studies.utils.slot_strategy import (
|
||||
_align_predictions_to_bars,
|
||||
_run_slot_simulation,
|
||||
build_persistent_slot_weights_hybrid,
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _bars(n: int, start: datetime | None = None, step: timedelta | None = None) -> list[datetime]:
|
||||
"""Generate ``n`` evenly spaced bar timestamps."""
|
||||
start = start or datetime(2024, 1, 2, 9, 30)
|
||||
step = step or timedelta(minutes=15)
|
||||
return [start + i * step for i in range(n)]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# _run_slot_simulation — pure mechanism
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_simulation_single_symbol_fill_then_maxhold_exit() -> None:
|
||||
"""One symbol enters at bar 0, must exit at bar 4 when hold_bars=4."""
|
||||
bars = _bars(8)
|
||||
signals = {bars[0]: [("AAA", 1.0)]}
|
||||
weights, stats = _run_slot_simulation(
|
||||
signals_by_ts=signals,
|
||||
all_bars_sorted=bars,
|
||||
max_slots=1,
|
||||
weight_per_slot=1.0,
|
||||
hold_bars=4,
|
||||
score_by_ts_sym=None,
|
||||
stay_threshold_by_ts_sym=None,
|
||||
)
|
||||
# Held bars 0..3 inclusive, exits at bar 4 (entry_i=0, i-entry_i=4 >= hold_bars)
|
||||
held_ts = weights["timestamp"].to_list()
|
||||
assert held_ts == bars[:4]
|
||||
assert (weights["symbol"] == "AAA").all()
|
||||
assert stats["n_entries"] == 1
|
||||
assert stats["n_exits_maxhold"] == 1
|
||||
assert stats["n_exits_signal"] == 0
|
||||
|
||||
|
||||
def test_simulation_max_slots_caps_concurrent_holdings() -> None:
|
||||
"""5 symbols signal simultaneously, max_slots=2 keeps top-2 by score."""
|
||||
bars = _bars(3)
|
||||
signals = {bars[0]: [(s, sc) for s, sc in zip("ABCDE", [0.1, 0.9, 0.5, 0.7, 0.3])]}
|
||||
weights, stats = _run_slot_simulation(
|
||||
signals_by_ts=signals,
|
||||
all_bars_sorted=bars,
|
||||
max_slots=2,
|
||||
weight_per_slot=0.5,
|
||||
hold_bars=10,
|
||||
score_by_ts_sym=None,
|
||||
stay_threshold_by_ts_sym=None,
|
||||
)
|
||||
held_first_bar = set(weights.filter(pl.col("timestamp") == bars[0])["symbol"].to_list())
|
||||
assert held_first_bar == {"B", "D"} # top-2 scores 0.9 and 0.7
|
||||
assert stats["n_entries"] == 2
|
||||
|
||||
|
||||
def test_simulation_signal_exit_fires_when_score_below_stay() -> None:
|
||||
"""Signal-exit triggers when current score drops below stay threshold."""
|
||||
bars = _bars(5)
|
||||
signals = {bars[0]: [("AAA", 0.9)]}
|
||||
score_lookup = {(bars[i], "AAA"): 0.9 if i < 2 else 0.1 for i in range(5)}
|
||||
stay_lookup = {(bars[i], "AAA"): 0.5 for i in range(5)}
|
||||
weights, stats = _run_slot_simulation(
|
||||
signals_by_ts=signals,
|
||||
all_bars_sorted=bars,
|
||||
max_slots=1,
|
||||
weight_per_slot=1.0,
|
||||
hold_bars=10,
|
||||
score_by_ts_sym=score_lookup,
|
||||
stay_threshold_by_ts_sym=stay_lookup,
|
||||
)
|
||||
held_ts = weights["timestamp"].to_list()
|
||||
# Held at bars 0, 1; at bar 2 score (0.1) < stay (0.5) → exit at start of bar 2
|
||||
assert held_ts == bars[:2]
|
||||
assert stats["n_exits_signal"] == 1
|
||||
assert stats["n_exits_maxhold"] == 0
|
||||
|
||||
|
||||
def test_simulation_signal_exit_skipped_when_score_unknown() -> None:
|
||||
"""If a (ts,sym) is missing from score_lookup, signal-exit must not fire."""
|
||||
bars = _bars(4)
|
||||
signals = {bars[0]: [("AAA", 0.9)]}
|
||||
score_lookup = {(bars[0], "AAA"): 0.9} # only bar 0
|
||||
stay_lookup = {(bars[i], "AAA"): 0.5 for i in range(4)}
|
||||
weights, stats = _run_slot_simulation(
|
||||
signals_by_ts=signals,
|
||||
all_bars_sorted=bars,
|
||||
max_slots=1,
|
||||
weight_per_slot=1.0,
|
||||
hold_bars=10,
|
||||
score_by_ts_sym=score_lookup,
|
||||
stay_threshold_by_ts_sym=stay_lookup,
|
||||
)
|
||||
# Missing scores at bars 1,2,3 → never signal-exit. Held all 4 bars.
|
||||
assert weights.height == 4
|
||||
assert stats["n_exits_signal"] == 0
|
||||
|
||||
|
||||
def test_simulation_validates_positive_max_slots() -> None:
|
||||
with pytest.raises(ValueError, match="max_slots must be positive"):
|
||||
_run_slot_simulation(
|
||||
signals_by_ts={},
|
||||
all_bars_sorted=[],
|
||||
max_slots=0,
|
||||
weight_per_slot=1.0,
|
||||
hold_bars=1,
|
||||
score_by_ts_sym=None,
|
||||
stay_threshold_by_ts_sym=None,
|
||||
)
|
||||
|
||||
|
||||
def test_simulation_validates_weight_per_slot_range() -> None:
|
||||
with pytest.raises(ValueError, match="weight_per_slot must be in"):
|
||||
_run_slot_simulation(
|
||||
signals_by_ts={},
|
||||
all_bars_sorted=[],
|
||||
max_slots=1,
|
||||
weight_per_slot=1.5,
|
||||
hold_bars=1,
|
||||
score_by_ts_sym=None,
|
||||
stay_threshold_by_ts_sym=None,
|
||||
)
|
||||
|
||||
|
||||
def test_simulation_empty_signals_returns_empty_frame_with_schema() -> None:
|
||||
weights, stats = _run_slot_simulation(
|
||||
signals_by_ts={},
|
||||
all_bars_sorted=_bars(3),
|
||||
max_slots=1,
|
||||
weight_per_slot=1.0,
|
||||
hold_bars=5,
|
||||
score_by_ts_sym=None,
|
||||
stay_threshold_by_ts_sym=None,
|
||||
)
|
||||
assert weights.is_empty()
|
||||
assert weights.columns == ["timestamp", "symbol", "weight"]
|
||||
assert weights.schema["timestamp"] == pl.Datetime("us")
|
||||
assert stats["n_entries"] == 0
|
||||
assert stats["n_exits_total"] == 0
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# _align_predictions_to_bars — backward-asof staleness filter
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_align_drops_predictions_older_than_freshness_tolerance() -> None:
|
||||
"""Predictions older than ``pred_freshness_max_min`` are filtered out."""
|
||||
bar_grid = pl.DataFrame(
|
||||
{
|
||||
"symbol": ["AAA"] * 3,
|
||||
"timestamp": _bars(3), # 09:30, 09:45, 10:00
|
||||
}
|
||||
)
|
||||
# One prediction at 09:30 (fresh), one at 09:20 (stale for 09:45 bar with 14m tol)
|
||||
preds = pl.DataFrame(
|
||||
{
|
||||
"symbol": ["AAA", "AAA"],
|
||||
"timestamp": [datetime(2024, 1, 2, 9, 30), datetime(2024, 1, 2, 9, 20)],
|
||||
"y_score": [0.5, 0.3],
|
||||
}
|
||||
)
|
||||
aligned = _align_predictions_to_bars(
|
||||
preds,
|
||||
bar_grid,
|
||||
pred_freshness_max_min=14,
|
||||
score_col="y_score",
|
||||
time_col="timestamp",
|
||||
asset_col="symbol",
|
||||
)
|
||||
# 09:30 bar: 09:30 pred (0m stale) -> 0.5
|
||||
# 09:45 bar: 09:30 pred (15m stale) -> dropped; 09:20 also too old
|
||||
# 10:00 bar: same — all preds >14m stale
|
||||
aligned_ts = aligned["timestamp"].to_list()
|
||||
assert aligned_ts == [datetime(2024, 1, 2, 9, 30)]
|
||||
assert aligned["y_score"].to_list() == [0.5]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# build_persistent_slot_weights_hybrid — high-level entry
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def predictions_dense() -> pl.DataFrame:
|
||||
"""50 days × 3 symbols × 14 bars/day; deterministic seeded scores."""
|
||||
rng = np.random.default_rng(7)
|
||||
rows = []
|
||||
for d in range(50):
|
||||
for i in range(14):
|
||||
ts = datetime(2024, 1, 2, 9, 30) + timedelta(days=d, minutes=15 * i)
|
||||
for sym in ("AAA", "BBB", "CCC"):
|
||||
rows.append((ts, sym, float(rng.standard_normal())))
|
||||
return pl.DataFrame(rows, schema=["timestamp", "symbol", "y_score"], orient="row").sort(
|
||||
"symbol", "timestamp"
|
||||
)
|
||||
|
||||
|
||||
def test_build_weights_returns_canonical_schema(predictions_dense) -> None:
|
||||
prices = predictions_dense.select(["symbol", "timestamp"]).with_columns(close=pl.lit(100.0))
|
||||
weights, stats = build_persistent_slot_weights_hybrid(
|
||||
predictions_dense,
|
||||
prices,
|
||||
long_q=0.90,
|
||||
lookback_days=10,
|
||||
bars_per_day=14,
|
||||
max_slots=2,
|
||||
hold_bars=4,
|
||||
)
|
||||
assert set(weights.columns) == {"timestamp", "symbol", "weight"}
|
||||
assert weights.schema["timestamp"] == pl.Datetime("us")
|
||||
# weight defaults to 1/max_slots
|
||||
if not weights.is_empty():
|
||||
assert (weights["weight"] - 0.5).abs().max() < 1e-9
|
||||
assert stats["max_slots"] == 2
|
||||
assert stats["direction"] == "long_only"
|
||||
|
||||
|
||||
def test_build_weights_short_only_flips_sign(predictions_dense) -> None:
|
||||
prices = predictions_dense.select(["symbol", "timestamp"]).with_columns(close=pl.lit(100.0))
|
||||
long_w, _ = build_persistent_slot_weights_hybrid(
|
||||
predictions_dense,
|
||||
prices,
|
||||
long_q=0.80,
|
||||
lookback_days=10,
|
||||
bars_per_day=14,
|
||||
max_slots=2,
|
||||
hold_bars=4,
|
||||
direction="long_only",
|
||||
)
|
||||
short_w, _ = build_persistent_slot_weights_hybrid(
|
||||
predictions_dense,
|
||||
prices,
|
||||
long_q=0.80,
|
||||
lookback_days=10,
|
||||
bars_per_day=14,
|
||||
max_slots=2,
|
||||
hold_bars=4,
|
||||
direction="short_only",
|
||||
)
|
||||
assert long_w.shape == short_w.shape
|
||||
if not long_w.is_empty():
|
||||
# short_only is a pure sign flip
|
||||
merged = long_w.join(short_w, on=["timestamp", "symbol"], suffix="_s")
|
||||
assert (merged["weight"] + merged["weight_s"]).abs().max() < 1e-9
|
||||
|
||||
|
||||
def test_build_weights_rejects_long_short_direction(predictions_dense) -> None:
|
||||
prices = predictions_dense.select(["symbol", "timestamp"]).with_columns(close=pl.lit(100.0))
|
||||
with pytest.raises(ValueError, match="long_short is not supported"):
|
||||
build_persistent_slot_weights_hybrid(
|
||||
predictions_dense,
|
||||
prices,
|
||||
long_q=0.80,
|
||||
lookback_days=10,
|
||||
bars_per_day=14,
|
||||
max_slots=2,
|
||||
hold_bars=4,
|
||||
direction="long_short", # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def test_build_weights_rejects_stay_q_at_or_above_long_q(predictions_dense) -> None:
|
||||
prices = predictions_dense.select(["symbol", "timestamp"]).with_columns(close=pl.lit(100.0))
|
||||
with pytest.raises(ValueError, match="must be < long_q"):
|
||||
build_persistent_slot_weights_hybrid(
|
||||
predictions_dense,
|
||||
prices,
|
||||
long_q=0.50,
|
||||
lookback_days=10,
|
||||
bars_per_day=14,
|
||||
max_slots=2,
|
||||
hold_bars=4,
|
||||
exit_signal_q=0.50,
|
||||
)
|
||||
|
||||
|
||||
def test_build_weights_with_stay_threshold_runs_clean(predictions_dense) -> None:
|
||||
"""End-to-end with signal-exit enabled — schema + non-degenerate stats."""
|
||||
prices = predictions_dense.select(["symbol", "timestamp"]).with_columns(close=pl.lit(100.0))
|
||||
_weights, stats = build_persistent_slot_weights_hybrid(
|
||||
predictions_dense,
|
||||
prices,
|
||||
long_q=0.80,
|
||||
lookback_days=10,
|
||||
bars_per_day=14,
|
||||
max_slots=2,
|
||||
hold_bars=8,
|
||||
exit_signal_q=0.40,
|
||||
)
|
||||
assert stats["exit_signal_q"] == 0.40
|
||||
# Either path can produce zero entries on a tiny synthetic sample, but the
|
||||
# mechanism must not crash and stats must be coherent.
|
||||
assert stats["n_exits_total"] == stats["n_exits_maxhold"] + stats["n_exits_signal"]
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Drift-detector tests for the setup.yaml-driven Ch16-19 sweep loader.
|
||||
|
||||
Pins the contract between ``case_studies/{cs}/config/setup.yaml::backtest.sweep``
|
||||
and the helpers in ``case_studies.utils.sweep_config``:
|
||||
|
||||
1. **Loader shape** — ``load_sweep`` and the ``*_for`` / ``get_*`` helpers
|
||||
return the expected types and values for migrated case studies.
|
||||
2. **Registry reconciliation** — the declared sweep covers the rank-1
|
||||
``(method, top_k)`` for every label in ``labels.{primary, variants}``.
|
||||
3. **Quarantine policy** — V3/V4 deprecated classes (``score_weighted_top_k``
|
||||
on the signal stage, ``cross_sectional_percentile``) must not appear in
|
||||
the declared sweep.
|
||||
|
||||
All 9 case studies have shipped ``backtest.sweep``; ``MIGRATED_CASES`` is
|
||||
now the full set.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from case_studies.utils.sweep_config import (
|
||||
get_allocators,
|
||||
get_cost_grid_bps,
|
||||
get_entry_schemes_for,
|
||||
get_portfolio_risk_controls,
|
||||
get_position_risk_controls,
|
||||
get_top_k_values_for,
|
||||
load_sweep,
|
||||
)
|
||||
from utils import CASE_STUDIES_DIR
|
||||
|
||||
# All case studies ship a ``backtest.sweep`` block in setup.yaml.
|
||||
MIGRATED_CASES: tuple[str, ...] = (
|
||||
"us_firm_characteristics",
|
||||
"etfs",
|
||||
"fx_pairs",
|
||||
"cme_futures",
|
||||
"nasdaq100_microstructure",
|
||||
"us_equities_panel",
|
||||
"sp500_equity_option_analytics",
|
||||
"crypto_perps_funding",
|
||||
"sp500_options",
|
||||
)
|
||||
|
||||
# Signal-stage methods that must never appear in a declared sweep. These
|
||||
# correspond to the V3/V4 quarantine list: ``score_weighted_top_k`` is an
|
||||
# allocator (Ch17), not a signal scheme; ``cross_sectional_percentile``
|
||||
# was retired during V3 cleanup.
|
||||
QUARANTINED_SIGNAL_METHODS: frozenset[str] = frozenset(
|
||||
{"score_weighted_top_k", "cross_sectional_percentile"}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Loader contract — us_firm_characteristics (the first migrated case study)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestUsFirmLoader:
|
||||
"""Pin the loader output for us_firm_characteristics."""
|
||||
|
||||
CS = "us_firm_characteristics"
|
||||
|
||||
def test_load_sweep_returns_expected_keys(self):
|
||||
sweep = load_sweep(self.CS)
|
||||
assert set(sweep.keys()) >= {
|
||||
"top_k_grid",
|
||||
"allocators",
|
||||
"cost_grid_bps",
|
||||
"risk_controls",
|
||||
}
|
||||
|
||||
@pytest.mark.parametrize("label", ["fwd_ret_1m", "fwd_ret_1m_win", "fwd_class_1m"])
|
||||
def test_top_k_grid_per_label(self, label):
|
||||
assert get_top_k_values_for(self.CS, label, n_assets=2500) == [5, 10, 20, 50]
|
||||
|
||||
@pytest.mark.parametrize("label", ["fwd_ret_1m", "fwd_ret_1m_win", "fwd_class_1m"])
|
||||
def test_entry_schemes_per_label(self, label):
|
||||
schemes = get_entry_schemes_for(self.CS, label, n_assets=2500, long_short=True)
|
||||
# Exactly the four equal_weight_top_k schemes declared in setup.yaml.
|
||||
assert [s["method"] for s in schemes] == ["equal_weight_top_k"] * 4
|
||||
assert [s["top_k"] for s in schemes] == [5, 10, 20, 50]
|
||||
assert all(s["long_short"] is True for s in schemes)
|
||||
|
||||
def test_allocators_strip_name(self):
|
||||
allocators = get_allocators(self.CS)
|
||||
methods = [a["method"] for a in allocators]
|
||||
# us_firm is a returns-only firm-characteristics panel with no per-symbol
|
||||
# price series, so the moment-based allocators (inverse_vol, risk_parity,
|
||||
# hrp, mvo_ledoit_wolf) are intentionally excluded — only the
|
||||
# lookback-free allocators are declared (see setup.yaml allocators block).
|
||||
assert methods == [
|
||||
"equal_weight",
|
||||
"score_weighted",
|
||||
"conformal_weighted",
|
||||
]
|
||||
# No allocator dict should carry the human-readable ``name`` key —
|
||||
# the spec hash is computed from the shape that the dispatcher sees.
|
||||
assert all("name" not in a for a in allocators)
|
||||
|
||||
def test_cost_grid_bps(self):
|
||||
assert get_cost_grid_bps(self.CS) == [0, 1, 2, 3, 5, 7, 10, 15, 20, 30, 50]
|
||||
|
||||
def test_risk_control_counts(self):
|
||||
# Position grid: stop_loss (4) + trailing_stop (7) + time_exit (3) = 14
|
||||
assert len(get_position_risk_controls(self.CS)) == 14
|
||||
# Portfolio-level overlays were removed from all setup.yaml (2026-05-17);
|
||||
# only position-level controls are retained.
|
||||
assert len(get_portfolio_risk_controls(self.CS)) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Quarantine policy — V3/V4 retired classes must not appear in any declared
|
||||
# sweep
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestQuarantinePolicy:
|
||||
"""Declared sweep must not include V3/V4 retired selection classes."""
|
||||
|
||||
@pytest.mark.parametrize("case_study", MIGRATED_CASES)
|
||||
def test_no_score_weighted_top_k_in_signal_sweep(self, case_study):
|
||||
sweep = load_sweep(case_study)
|
||||
# ``score_weighted_top_k`` should never appear as a top_k_grid /
|
||||
# percentile_grid / quantile_grid axis — it belongs in
|
||||
# ``allocators`` only.
|
||||
# The synthesized entry schemes are the SUT.
|
||||
for label in (
|
||||
(sweep.get("top_k_grid") or {}).keys()
|
||||
| (sweep.get("percentile_grid") or {}).keys()
|
||||
| (sweep.get("quantile_grid") or {}).keys()
|
||||
):
|
||||
schemes = get_entry_schemes_for(case_study, label, n_assets=10_000, long_short=False)
|
||||
methods = {s["method"] for s in schemes}
|
||||
assert methods.isdisjoint(QUARANTINED_SIGNAL_METHODS), (
|
||||
f"{case_study}/{label}: quarantined signal method appeared: "
|
||||
f"{methods & QUARANTINED_SIGNAL_METHODS}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry reconciliation — declared sweep covers rank-1 per (CS, label)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _registry_path(case_study: str) -> Path:
|
||||
return CASE_STUDIES_DIR / case_study / "run_log" / "registry.db"
|
||||
|
||||
|
||||
def _labels_for(case_study: str) -> list[str]:
|
||||
setup = yaml.safe_load((CASE_STUDIES_DIR / case_study / "config" / "setup.yaml").read_text())
|
||||
labels_block = setup.get("labels") or {}
|
||||
primary = labels_block.get("primary")
|
||||
variants = labels_block.get("variants") or []
|
||||
return [primary, *variants] if primary else list(variants)
|
||||
|
||||
|
||||
class TestRegistryReconciliation:
|
||||
"""For every label in setup.yaml::labels.{primary,variants}, the rank-1
|
||||
signal-stage row in ``backtest_runs`` must use a ``(method, top_k)`` that
|
||||
is in the declared sweep.
|
||||
|
||||
Skips a case study if its registry has no signal-stage rows (e.g.,
|
||||
immediately after a registry cleanup), since there is nothing to
|
||||
reconcile against yet. As each case study completes its Ch16-19 wrap-up,
|
||||
its registry rank-1 should reappear and this test should pass.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("case_study", MIGRATED_CASES)
|
||||
def test_rank1_signal_method_in_declared_sweep(self, case_study):
|
||||
reg_path = _registry_path(case_study)
|
||||
if not reg_path.exists():
|
||||
pytest.skip(f"{case_study}: registry.db not present")
|
||||
|
||||
sweep = load_sweep(case_study)
|
||||
top_k_by_label = sweep.get("top_k_grid") or {}
|
||||
qnt_by_label = sweep.get("quantile_grid") or {}
|
||||
pct_by_label = sweep.get("percentile_grid") or {}
|
||||
|
||||
labels = _labels_for(case_study)
|
||||
with sqlite3.connect(reg_path) as conn:
|
||||
cur = conn.cursor()
|
||||
for label in labels:
|
||||
row = cur.execute(
|
||||
"""
|
||||
SELECT json_extract(r.spec_json, '$.strategy.signal.method'),
|
||||
json_extract(r.spec_json, '$.strategy.signal.top_k'),
|
||||
json_extract(r.spec_json, '$.strategy.signal.n_quantiles'),
|
||||
json_extract(r.spec_json, '$.strategy.signal.max_slots'),
|
||||
bm.sharpe
|
||||
FROM backtest_metrics bm
|
||||
JOIN backtest_runs r ON r.backtest_hash = bm.backtest_hash
|
||||
JOIN prediction_sets p ON p.prediction_hash = r.prediction_hash
|
||||
JOIN training_runs t ON t.training_hash = p.training_hash
|
||||
WHERE t.label = ? AND p.split = 'validation' AND r.stage = 'signal'
|
||||
ORDER BY bm.sharpe DESC LIMIT 1
|
||||
""",
|
||||
(label,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
pytest.skip(f"{case_study}/{label}: no signal-stage rows in registry")
|
||||
|
||||
method, top_k, n_quantiles, max_slots, _sharpe = row
|
||||
if method == "equal_weight_top_k":
|
||||
declared_ks = list(top_k_by_label.get(label, []))
|
||||
assert top_k in declared_ks, (
|
||||
f"{case_study}/{label}: registry rank-1 top_k={top_k} "
|
||||
f"not in declared top_k_grid={declared_ks}"
|
||||
)
|
||||
elif method == "slot_persistent_signal_exit":
|
||||
# Slot-mechanism signal (nasdaq100 v4 microstructure sweep).
|
||||
# Slots ARE the allocation, so the swept parameter is
|
||||
# ``max_slots`` (declared under the ``signal_nasdaq100``
|
||||
# block), not top_k / n_quantiles.
|
||||
declared_slots = list(
|
||||
(sweep.get("signal_nasdaq100") or {}).get("max_slots", [])
|
||||
)
|
||||
assert max_slots in declared_slots, (
|
||||
f"{case_study}/{label}: registry rank-1 max_slots={max_slots} "
|
||||
f"not in declared signal_nasdaq100.max_slots={declared_slots}"
|
||||
)
|
||||
elif method in ("quintile_long_short", "decile_long_short"):
|
||||
declared_qs = list(qnt_by_label.get(label, []))
|
||||
assert n_quantiles in declared_qs, (
|
||||
f"{case_study}/{label}: registry rank-1 n_quantiles="
|
||||
f"{n_quantiles} not in declared quantile_grid={declared_qs}"
|
||||
)
|
||||
elif method in QUARANTINED_SIGNAL_METHODS:
|
||||
# The registry still has V3/V4 debris — Ch16-19 sweep
|
||||
# cleanup hasn't run yet for this (case_study, label).
|
||||
# Skip rather than fail; once cleanup runs, the rank-1
|
||||
# method will be canonical and the assertion above takes
|
||||
# over.
|
||||
pytest.skip(
|
||||
f"{case_study}/{label}: rank-1 is {method!r} (V3/V4 "
|
||||
f"quarantine class). Registry cleanup pending; "
|
||||
f"re-rank after task-6/task-7 land."
|
||||
)
|
||||
else:
|
||||
pytest.fail(
|
||||
f"{case_study}/{label}: rank-1 method {method!r} is "
|
||||
f"unrecognized by the seam test — extend the test or "
|
||||
f"the loader."
|
||||
)
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Tests for case_studies/utils/uncertainty.py CSCV partition + PBO smoke.
|
||||
|
||||
Covers two pieces P2.5 added:
|
||||
|
||||
1. ``_cscv_split_pairs`` — IS/OOS partition shape and balance for
|
||||
``n_folds`` in {2, 3, 4}, including the asymmetric odd-fold case.
|
||||
2. ``compute_cohort_metrics`` end-to-end with a ``fold_returns_by_hash``
|
||||
argument, asserting that ``pbo`` / ``pbo_median_oos_rank`` /
|
||||
``pbo_mean_degradation`` come back populated (i.e. the
|
||||
``compute_pbo`` field-name and partition wiring is intact).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from math import comb
|
||||
|
||||
import numpy as np
|
||||
import polars as pl
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"n_folds, is_half, oos_half",
|
||||
[
|
||||
(2, 1, 1), # balanced
|
||||
(3, 1, 2), # asymmetric — OOS gets the extra fold
|
||||
(4, 2, 2), # balanced
|
||||
],
|
||||
)
|
||||
def test_cscv_split_pairs_partition_shape(n_folds: int, is_half: int, oos_half: int) -> None:
|
||||
from case_studies.utils.uncertainty import _cscv_split_pairs
|
||||
|
||||
rng = np.random.default_rng(0)
|
||||
k_variants = 5
|
||||
fold_sharpes = rng.normal(size=(n_folds, k_variants))
|
||||
|
||||
is_perf, oos_perf = _cscv_split_pairs(fold_sharpes)
|
||||
|
||||
expected_n = comb(n_folds, n_folds // 2)
|
||||
assert is_perf.shape == (expected_n, k_variants)
|
||||
assert oos_perf.shape == (expected_n, k_variants)
|
||||
|
||||
# Every row must be the mean of `is_half` folds (IS) and
|
||||
# `oos_half` folds (OOS) of the original matrix — verified by
|
||||
# reconstructing the underlying sums.
|
||||
for row_is, row_oos in zip(is_perf, oos_perf, strict=True):
|
||||
# IS mean × is_half + OOS mean × oos_half == sum of all folds
|
||||
total = fold_sharpes.sum(axis=0)
|
||||
reconstructed = row_is * is_half + row_oos * oos_half
|
||||
np.testing.assert_allclose(reconstructed, total, atol=1e-12)
|
||||
|
||||
|
||||
def test_cscv_split_pairs_single_fold_returns_empty() -> None:
|
||||
from case_studies.utils.uncertainty import _cscv_split_pairs
|
||||
|
||||
is_perf, oos_perf = _cscv_split_pairs(np.array([[1.0, 2.0, 3.0]]))
|
||||
assert is_perf.shape == (0, 3)
|
||||
assert oos_perf.shape == (0, 3)
|
||||
|
||||
|
||||
def test_compute_cohort_metrics_populates_pbo_with_fold_returns() -> None:
|
||||
"""End-to-end smoke: PBO fields must come back non-null when
|
||||
``fold_returns_by_hash`` is supplied for >=2 variants with >=2 folds.
|
||||
|
||||
The pre-P2.5 code called ``compute_pbo(fs, fs)`` and read the wrong
|
||||
PBOResult attribute names — both bugs would surface here as NULLs.
|
||||
"""
|
||||
from case_studies.utils.uncertainty import compute_cohort_metrics
|
||||
|
||||
rng = np.random.default_rng(7)
|
||||
n_periods = 252
|
||||
timestamps = pl.datetime_range(
|
||||
start=pl.datetime(2020, 1, 1),
|
||||
end=pl.datetime(2020, 12, 31),
|
||||
interval="1d",
|
||||
eager=True,
|
||||
).head(n_periods)
|
||||
|
||||
def _make_frame(mu: float) -> pl.DataFrame:
|
||||
ret = rng.normal(loc=mu / 252, scale=0.01, size=n_periods)
|
||||
return pl.DataFrame({"timestamp": timestamps, "ret": ret})
|
||||
|
||||
# Three "variants" with hash-shaped keys (32 hex chars satisfies any
|
||||
# downstream FK convention; here we just need stable dict keys).
|
||||
returns_by_hash = {f"{i:032x}": _make_frame(mu=mu) for i, mu in enumerate([0.05, 0.08, 0.12])}
|
||||
|
||||
n_folds = 4
|
||||
fold_returns_by_hash = {
|
||||
h: rng.normal(loc=0.0, scale=1.0, size=n_folds) for h in returns_by_hash
|
||||
}
|
||||
|
||||
out = compute_cohort_metrics(
|
||||
returns_by_hash,
|
||||
periods_per_year=252.0,
|
||||
fold_returns_by_hash=fold_returns_by_hash,
|
||||
rademacher_n_simulations=50,
|
||||
rademacher_seed=0,
|
||||
)
|
||||
|
||||
assert out, "compute_cohort_metrics returned empty dict — alignment failed"
|
||||
assert out["leader_hash"] in returns_by_hash
|
||||
assert out["k_variants"] == 3
|
||||
|
||||
# PBO fields must be populated (the bug-surface check).
|
||||
assert out["pbo"] is not None
|
||||
assert 0.0 <= out["pbo"] <= 1.0
|
||||
assert out["pbo_n_combinations"] == float(comb(n_folds, n_folds // 2))
|
||||
assert out["pbo_median_oos_rank"] is not None
|
||||
assert out["pbo_mean_degradation"] is not None
|
||||
assert out["pbo_n_folds"] == float(n_folds)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Tests for ``case_studies.utils.backtest_loaders.warmup_periods_for`` +
|
||||
``_calendar_days_per_period`` — the helpers that replaced the hardcoded
|
||||
``warmup_periods=126`` constant duplicated across 16 call-sites in 5 CSes.
|
||||
|
||||
These tests close P2.8 of the roborev cleanup (review #2510 / #2511).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import yaml
|
||||
|
||||
from case_studies.utils.backtest_loaders import (
|
||||
_calendar_days_per_period,
|
||||
_load_case_setup_yaml,
|
||||
warmup_periods_for,
|
||||
)
|
||||
|
||||
# The expected per-CS warmup is the max over ``execution.allocator_lookback``
|
||||
# and any per-sweep allocator ``vol_window`` / ``lookback`` overrides. These
|
||||
# expectations are anchored on the current setup.yaml values; if a CS
|
||||
# tunes its allocator lookbacks, update the expected value here.
|
||||
_EXPECTED_WARMUP: dict[str, int] = {
|
||||
"etfs": 63,
|
||||
"crypto_perps_funding": 240,
|
||||
"nasdaq100_microstructure": 520,
|
||||
"us_equities_panel": 126, # mvo_ledoit_wolf lookback=126 > allocator_lookback=63
|
||||
"us_firm_characteristics": 12,
|
||||
"fx_pairs": 63,
|
||||
"cme_futures": 63,
|
||||
"sp500_options": 63,
|
||||
"sp500_equity_option_analytics": 126, # mvo_ledoit_wolf lookback=126
|
||||
}
|
||||
|
||||
|
||||
def test_warmup_periods_for_matches_setup_yaml() -> None:
|
||||
for cs, expected in _EXPECTED_WARMUP.items():
|
||||
actual = warmup_periods_for(cs)
|
||||
assert actual == expected, (
|
||||
f"warmup_periods_for({cs}) = {actual}, expected {expected} "
|
||||
f"(max of execution.allocator_lookback + sweep allocator overrides)"
|
||||
)
|
||||
|
||||
|
||||
def test_warmup_periods_for_unknown_returns_zero(tmp_path) -> None:
|
||||
# No setup.yaml → defaults to 0 (the unbounded fallback inside
|
||||
# load_backtest_prices_for then skips the prefix-day computation).
|
||||
assert warmup_periods_for("__nonexistent_cs__") == 0
|
||||
|
||||
|
||||
def test_warmup_periods_for_picks_max_over_overrides(tmp_path, monkeypatch) -> None:
|
||||
"""When a per-allocator override exceeds allocator_lookback, the helper
|
||||
must surface the override rather than the CS-level default."""
|
||||
fake_setup = {
|
||||
"execution": {"allocator_lookback": 50},
|
||||
"backtest": {
|
||||
"sweep": {
|
||||
"allocators": [
|
||||
{"name": "equal_weight"},
|
||||
{"name": "iv", "vol_window": 200},
|
||||
{"name": "mvo_lw", "lookback": 100},
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
cs_dir = tmp_path / "fake_cs" / "config"
|
||||
cs_dir.mkdir(parents=True)
|
||||
(cs_dir / "setup.yaml").write_text(yaml.safe_dump(fake_setup))
|
||||
|
||||
# Drop the cache so the synthetic CS gets a fresh read.
|
||||
_load_case_setup_yaml.cache_clear()
|
||||
|
||||
from case_studies.utils.backtest_loaders import warmup_periods_for as wpf
|
||||
from utils.paths import get_case_study_dir as orig_get_dir
|
||||
|
||||
def fake_get_dir(cs: str):
|
||||
if cs == "fake_cs":
|
||||
return tmp_path / "fake_cs"
|
||||
return orig_get_dir(cs)
|
||||
|
||||
monkeypatch.setattr("case_studies.utils.backtest_loaders.get_case_study_dir", fake_get_dir)
|
||||
_load_case_setup_yaml.cache_clear()
|
||||
assert wpf("fake_cs") == 200
|
||||
|
||||
|
||||
def test_calendar_days_per_period_cadence_aware() -> None:
|
||||
# Daily NYSE cadence: 1.5× (weekend + holiday allowance)
|
||||
assert abs(_calendar_days_per_period("fx_pairs") - 1.5) < 1e-9
|
||||
assert abs(_calendar_days_per_period("us_equities_panel") - 1.5) < 1e-9
|
||||
# Weekly cadence: 7 calendar days per bar
|
||||
assert abs(_calendar_days_per_period("cme_futures") - 7.0) < 1e-9
|
||||
assert abs(_calendar_days_per_period("sp500_equity_option_analytics") - 7.0) < 1e-9
|
||||
# 8-hour cadence: ~0.333 day per bar (3 bars / 24h day)
|
||||
assert abs(_calendar_days_per_period("crypto_perps_funding") - 1.0 / 3.0) < 1e-9
|
||||
# 15-minute cadence: ~0.054 day per bar (1/26 trading day × 1.4 calendar buffer)
|
||||
assert _calendar_days_per_period("nasdaq100_microstructure") < 0.1
|
||||
# Monthly cadence: ~31 calendar days per bar
|
||||
assert abs(_calendar_days_per_period("us_firm_characteristics") - 31.0) < 1e-9
|
||||
|
||||
|
||||
def test_calendar_days_per_period_default_for_unknown_cs() -> None:
|
||||
# Falls back to the daily 1.5× heuristic when no setup.yaml is present
|
||||
# or the cadence token isn't in the lookup table.
|
||||
assert _calendar_days_per_period("__nonexistent_cs__") == 1.5
|
||||
Reference in New Issue
Block a user