chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:26:28 +08:00
commit 0249a267e1
495 changed files with 1277094 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# Shared utilities
Library code imported across every chapter and case study. Notebooks run from the
repository root, so `import utils` and `from utils.x import y` resolve with no
installation. This is code you **import**, not run — for command-line tools, see
[`scripts/`](../scripts).
**Configuration and paths.** `config.py` loads and validates the paths and settings
in `.env` (and sorts out CUDA library paths); `paths.py` holds the chapter registry
and resolves chapter, case-study, and output directories so notebooks never hard-code
a location.
**Figures.** `style.py` defines the ML4T color palette and the matplotlib / Plotly
defaults that give every figure in the book one consistent look.
**Data.** `data_quality.py` summarizes coverage, checks OHLC invariants, and subsets
symbols for fast test runs; `downloading.py` is the shared backbone of the `data/`
download scripts (argument parsing, path/YAML resolution, atomic writes);
`artifact_specs.py` loads the per-case-study YAML sidecars that describe market data,
labels, and features.
**Modeling and cross-validation.** `modeling.py` is the workhorse — it loads a
modeling dataset, parses model configs, prepares folds, and detects the schema;
`cv_splits.py` builds the walk-forward splits (calendar-aware, leakage-safe);
`predictions_cache.py` caches long-form prediction frames so the teaching notebooks
don't recompute them.
**Reproducibility.** `reproducibility.py` seeds Python, NumPy, and Torch (CPU + CUDA)
in a single call; `storage_benchmarks.py` provides the synthetic data and timing
harness behind the Chapter 2 storage benchmarks.
+54
View File
@@ -0,0 +1,54 @@
"""ML4T utilities - configuration, paths, styling, and data quality.
This package provides:
- Configuration management (utils.config)
- Path utilities (utils.paths)
- Visualization styling (utils.style)
- Data quality checks (utils.data_quality)
Usage:
>>> from utils import ML4T_PATH, ML4T_DATA_PATH, DATA_DIR
>>> from utils.paths import CHAPTERS, get_output_dir
>>> from utils.style import COLORS
>>> from utils.data_quality import describe_coverage, check_ohlc_invariants
"""
from utils.config import (
ALPACA_API_KEY,
ALPACA_SECRET_KEY,
CASE_STUDIES_DIR,
DATABENTO_API_KEY,
ML4T_DATA_PATH,
ML4T_PATH,
OANDA_API_KEY,
REPO_ROOT,
)
# Backward compatibility alias
DATA_DIR = ML4T_DATA_PATH
# Plotly: include PNG in cell output so GitHub can render .ipynb figures.
# Override with PLOTLY_RENDERER env var (e.g. "json" for headless CI).
try:
import os as _os
import plotly.io as _pio
if not _os.environ.get("PLOTLY_RENDERER"):
_pio.renderers.default = "plotly_mimetype+png"
except ImportError:
pass
__all__ = [
# Core configuration
"ML4T_PATH",
"ML4T_DATA_PATH",
"DATA_DIR", # Backward compatibility
"CASE_STUDIES_DIR",
"REPO_ROOT",
# API keys
"DATABENTO_API_KEY",
"OANDA_API_KEY",
"ALPACA_API_KEY",
"ALPACA_SECRET_KEY",
]
+136
View File
@@ -0,0 +1,136 @@
"""Compatibility loaders for case-study artifact sidecars."""
from __future__ import annotations
from collections.abc import Mapping
from copy import deepcopy
from functools import cache
from pathlib import Path
from typing import Any
import yaml
from utils.paths import get_case_study_dir
try:
from ml4t.backtest.spec_io import load_spec as _load_shared_spec
except ImportError: # pragma: no cover - depends on install state
_load_shared_spec = None
def _artifact_root(case_study_id: str) -> Path:
return get_case_study_dir(case_study_id, create=False) / "config" / "artifacts"
def _to_mapping(spec: Any) -> dict[str, Any]:
if isinstance(spec, Mapping):
return dict(spec)
if hasattr(spec, "to_dict"):
return dict(spec.to_dict())
raise TypeError(f"Unsupported artifact spec type: {type(spec).__name__}")
def _load_spec(path: Path) -> dict[str, Any] | None:
if not path.exists():
return None
if _load_shared_spec is not None:
return _to_mapping(_load_shared_spec(path))
with path.open() as f:
data = yaml.safe_load(f)
return dict(data) if data else None
@cache
def _load_market_data_spec_cached(case_study_id: str) -> dict[str, Any] | None:
return _load_spec(_artifact_root(case_study_id) / "market_data.yaml")
@cache
def _load_label_spec_cached(case_study_id: str, label: str) -> dict[str, Any] | None:
return _load_spec(_artifact_root(case_study_id) / "labels" / f"{label}.yaml")
@cache
def _load_feature_spec_cached(case_study_id: str, feature_set: str) -> dict[str, Any] | None:
return _load_spec(_artifact_root(case_study_id) / "features" / f"{feature_set}.yaml")
def load_market_data_spec(case_study_id: str) -> dict[str, Any] | None:
spec = _load_market_data_spec_cached(case_study_id)
return deepcopy(spec) if spec is not None else None
def load_label_spec(case_study_id: str, label: str) -> dict[str, Any] | None:
spec = _load_label_spec_cached(case_study_id, label)
return deepcopy(spec) if spec is not None else None
def load_feature_spec(case_study_id: str, feature_set: str) -> dict[str, Any] | None:
spec = _load_feature_spec_cached(case_study_id, feature_set)
return deepcopy(spec) if spec is not None else None
def resolve_storage_path(
case_study_id: str,
spec: dict[str, Any] | None,
default_relative_path: str,
) -> Path:
if spec is None:
return get_case_study_dir(case_study_id, create=False) / default_relative_path
storage = spec.get("storage", {})
rel_path = storage.get("path", default_relative_path)
return get_case_study_dir(case_study_id, create=False) / str(rel_path)
def resolve_label_buffer(
case_study_id: str,
label: str,
setup: Mapping[str, Any] | None = None,
) -> str | None:
label_spec = load_label_spec(case_study_id, label)
if label_spec is not None:
definition = label_spec.get("definition", {})
if definition.get("buffer"):
return str(definition["buffer"])
labels = (setup or {}).get("labels", {})
if labels.get("primary") == label and labels.get("buffer"):
return str(labels["buffer"])
variant_buffers = labels.get("variant_buffers", {})
if label in variant_buffers:
return str(variant_buffers[label])
return None
def resolve_market_semantics(
case_study_id: str,
setup: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
market_spec = load_market_data_spec(case_study_id) or {}
semantics = market_spec.get("semantics", {})
evaluation = (setup or {}).get("evaluation", {})
return {
"calendar": semantics.get("calendar") or evaluation.get("calendar"),
"timezone": semantics.get("timezone"),
"data_frequency": semantics.get("data_frequency"),
"timestamp_semantics": semantics.get("timestamp_semantics"),
"session_start_time": semantics.get("session_start_time"),
"bar_type": semantics.get("bar_type"),
}
def resolve_market_runtime(case_study_id: str) -> dict[str, Any]:
market_spec = load_market_data_spec(case_study_id) or {}
runtime = market_spec.get("runtime", {})
return dict(runtime) if isinstance(runtime, Mapping) else {}
__all__ = [
"load_feature_spec",
"load_label_spec",
"load_market_data_spec",
"resolve_label_buffer",
"resolve_market_runtime",
"resolve_market_semantics",
"resolve_storage_path",
]
+121
View File
@@ -0,0 +1,121 @@
"""Centralized configuration using python-dotenv.
This module loads all configuration from the .env file in the repository root.
It provides explicit, fail-fast configuration with clear error messages.
Configuration priority:
1. .env file (ONLY source - no fallbacks)
2. Validation ensures paths exist or provides clear instructions
Usage:
from utils import ML4T_PATH, ML4T_DATA_PATH, REPO_ROOT
"""
import os
from pathlib import Path
from dotenv import load_dotenv
# Find repository root (where .env lives)
REPO_ROOT = Path(__file__).parent.parent.resolve()
ENV_FILE = REPO_ROOT / ".env"
# Auto-create .env from .env.example if missing (CI, first-time setup)
if not ENV_FILE.exists():
example = REPO_ROOT / ".env.example"
if example.exists():
import contextlib
import shutil
with contextlib.suppress(OSError):
shutil.copy(example, ENV_FILE)
else:
raise FileNotFoundError(
f".env file not found at {ENV_FILE}\n\n"
f"Copy .env.example to .env and configure paths:\n"
f" cd {REPO_ROOT}\n"
f" cp .env.example .env\n"
f" # Edit .env with your paths:\n"
f" # ML4T_PATH={REPO_ROOT}\n"
f" # ML4T_DATA_PATH=/path/to/data\n"
)
# Load environment variables from .env
# override=False means environment variables take precedence over .env file
load_dotenv(ENV_FILE, override=False)
# ============================================================================
# Required Paths
# ============================================================================
# Priority: 1. Environment variable, 2. .env file, 3. Default
ML4T_PATH = Path(os.getenv("ML4T_PATH", str(REPO_ROOT))).expanduser().resolve()
data_path = os.getenv("ML4T_DATA_PATH")
if data_path is None:
ML4T_DATA_PATH = ML4T_PATH / "data"
else:
ML4T_DATA_PATH = Path(data_path).expanduser().resolve()
# Case studies directory - centralized artifact store for all strategies
# Default: case_studies/ in repo root (git-tracked configs, gitignored binaries)
case_studies_path = os.getenv("CASE_STUDIES_DIR")
if case_studies_path is None:
CASE_STUDIES_DIR = REPO_ROOT / "case_studies"
else:
CASE_STUDIES_DIR = Path(case_studies_path).expanduser().resolve()
# ============================================================================
# Validation
# ============================================================================
# Validate ML4T_PATH exists
if not ML4T_PATH.exists():
raise FileNotFoundError(
f"ML4T_PATH does not exist: {ML4T_PATH}\n\n"
f"Update ML4T_PATH in .env to point to the repository root."
)
# Validate ML4T_DATA_PATH exists
if not ML4T_DATA_PATH.exists():
raise FileNotFoundError(
f"Data directory not found: {ML4T_DATA_PATH}\n\n"
f"Options:\n"
f" 1. Download data:\n"
f" python data/download_all.py\n"
f" 2. Update ML4T_DATA_PATH in .env to point to existing data\n"
)
# ============================================================================
# CUDA Library Path (local uv environments)
# ============================================================================
# PyTorch bundles its own CUDA libraries which may be newer than system ones.
# Without this, torch imports fail with "undefined symbol" errors on systems
# where the system libcudart.so is older than what torch expects.
# Docker images don't need this (CUDA is installed system-wide).
if not os.environ.get("LD_LIBRARY_PATH", "").startswith("/usr/local/cuda"):
try:
import torch as _torch
_torch_root = Path(_torch.__file__).parent
_cuda_paths = [str(_torch_root / "lib")]
_nvidia_dir = _torch_root.parent / "nvidia"
if _nvidia_dir.exists():
_cuda_paths.extend(str(p) for p in _nvidia_dir.glob("*/lib"))
_existing_ld = os.environ.get("LD_LIBRARY_PATH", "")
os.environ["LD_LIBRARY_PATH"] = ":".join(
_cuda_paths + ([_existing_ld] if _existing_ld else [])
)
del _torch, _torch_root, _cuda_paths, _nvidia_dir, _existing_ld
except ImportError:
pass
# ============================================================================
# API Keys (optional - only needed for data downloads)
# ============================================================================
# These are optional and only needed if you're downloading data
DATABENTO_API_KEY = os.getenv("DATABENTO_API_KEY", "")
OANDA_API_KEY = os.getenv("OANDA_API_KEY", "")
ALPACA_API_KEY = os.getenv("ALPACA_API_KEY", "")
ALPACA_SECRET_KEY = os.getenv("ALPACA_SECRET_KEY", "")
+338
View File
@@ -0,0 +1,338 @@
"""Cross-validation split generation for case study pipelines.
Reads the ``evaluation`` section from ``setup.yaml`` and generates
walk-forward date boundaries by delegating to ml4t-diagnostic's
``WalkForwardCV``. This is the single source of truth for CV splits
used by all case studies (Ch11+).
Usage:
from utils.cv_splits import generate_cv_splits, load_evaluation_config, make_walk_forward_config
# Date-boundary splits
splits = generate_cv_splits(dataset, case_study_id="etfs", label_buffer="21D")
for split in splits:
train_mask = (df[date_col] >= split["train_start"]) & (df[date_col] <= split["train_end"])
val_mask = (df[date_col] >= split["val_start"]) & (df[date_col] <= split["val_end"])
# WalkForwardConfig for library integration
config = make_walk_forward_config("etfs", label_horizon="21D")
Design decisions:
- Delegates fold generation to ml4t-diagnostic's WalkForwardCV
- Calendar-aware splitting (NYSE, CME, etc.) replaces broken ppd arithmetic
- Operates on unique dates (handles panel data correctly)
- Rolling training windows (respects train_size from config)
- Backward stepping from holdout boundary
- label_buffer is provided at call time (depends on label, not config)
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import TYPE_CHECKING, Any
import numpy as np
import pandas as pd
import polars as pl
import yaml
from utils.artifact_specs import resolve_market_semantics
from utils.paths import get_case_study_dir
if TYPE_CHECKING:
from ml4t.diagnostic.splitters.config import WalkForwardConfig
# ---------------------------------------------------------------------------
# Calendar name mapping: setup.yaml → pandas_market_calendars exchange names
# ---------------------------------------------------------------------------
_CALENDAR_MAP: dict[str, str | None] = {
"NYSE": "NYSE",
"CME": "CME_Equity",
"FX": "CME_FX",
"crypto": None, # 24/7 trading, no calendar
}
def _map_calendar_id(calendar: str | None) -> str | None:
"""Map setup.yaml calendar name to pandas_market_calendars exchange name.
Returns None for 24/7 markets (crypto) to disable calendar-aware splitting.
Unknown names are passed through unchanged (will error in the library if invalid).
"""
if calendar is None:
return None
return _CALENDAR_MAP.get(calendar, calendar)
def _normalize_duration(s: str) -> str:
"""Strip ISO 8601 prefix (P, PT) and normalize unit aliases.
Examples: P5Y → 5Y, P1Y → 1Y, PT8H → 8h, 21D → 21D (unchanged).
"""
s = re.sub(r"^P?T?", "", s)
s = re.sub(r"(\d+)H$", r"\1h", s)
s = re.sub(r"(\d+)T$", r"\1min", s)
return s
def _normalize_label_buffer(s: str) -> str:
"""Normalize label buffer for pd.Timedelta compatibility.
Strips ISO prefix, normalizes units, and converts month-based
durations to day equivalents since pd.Timedelta rejects 'M' as ambiguous.
"""
s = _normalize_duration(s)
m = re.match(r"^(\d+)M$", s)
if m:
return f"{int(m.group(1)) * 30}D"
return s
def load_evaluation_config(case_study_id: str) -> dict[str, Any]:
"""Read the evaluation section from setup.yaml.
Parameters
----------
case_study_id : str
Case study identifier (e.g., "etfs", "crypto_perps_funding").
Returns
-------
dict
Evaluation config with keys: n_splits, train_size, val_size,
holdout_start, holdout_end, calendar.
"""
import os
setup_path = get_case_study_dir(case_study_id) / "config" / "setup.yaml"
setup: dict[str, Any] = {}
if setup_path.exists():
with open(setup_path) as f:
setup = yaml.safe_load(f) or {}
if "evaluation" not in setup:
# Under ML4T_OUTPUT_DIR isolation, the redirected setup.yaml may
# be absent or lack hand-curated sections. Fall back to source.
test_output = os.environ.get("ML4T_OUTPUT_DIR")
if test_output:
from utils import CASE_STUDIES_DIR
source_path = CASE_STUDIES_DIR / case_study_id / "config" / "setup.yaml"
if source_path.exists():
with open(source_path) as f:
setup = yaml.safe_load(f) or {}
if "evaluation" not in setup:
raise KeyError(
f"No 'evaluation' section in {setup_path}. "
f"Expected keys: n_splits, train_size, val_size, holdout_start, holdout_end, calendar."
)
evaluation = dict(setup["evaluation"])
market_semantics = resolve_market_semantics(case_study_id, setup)
if market_semantics.get("calendar") and not evaluation.get("calendar"):
evaluation["calendar"] = market_semantics["calendar"]
return evaluation
def make_walk_forward_config(
case_study_id: str,
label_horizon: str = "0D",
date_col: str = "timestamp",
) -> WalkForwardConfig:
"""Create a WalkForwardConfig from a case study's setup.yaml.
Bridges the setup.yaml evaluation section to the ml4t-diagnostic
library's WalkForwardConfig, using its built-in aliases
(val_size→test_size, holdout_start→test_start, etc.).
Parameters
----------
case_study_id : str
Case study identifier (e.g., "etfs").
label_horizon : str, default "0D"
Label buffer as duration string (e.g., "21D" for fwd_ret_21d).
date_col : str, default "timestamp"
Timestamp column name for the dataset.
Returns
-------
WalkForwardConfig
Configured for the case study's walk-forward protocol.
"""
from ml4t.diagnostic.splitters import WalkForwardConfig
eval_config = load_evaluation_config(case_study_id)
calendar_id = _map_calendar_id(eval_config.get("calendar"))
# For D-unit buffers with a calendar, pass as int (trading days)
normalized_horizon: int | str = _normalize_label_buffer(label_horizon)
if calendar_id is not None and isinstance(normalized_horizon, str):
d_match = re.match(r"^(\d+)D$", normalized_horizon)
if d_match:
normalized_horizon = int(d_match.group(1))
return WalkForwardConfig(
n_splits=eval_config["n_splits"],
train_size=_normalize_duration(str(eval_config["train_size"])),
val_size=_normalize_duration(str(eval_config["val_size"])),
holdout_start=eval_config.get("holdout_start"),
holdout_end=eval_config.get("holdout_end"),
label_horizon=normalized_horizon,
calendar_id=calendar_id,
timestamp_col=date_col,
fold_direction="backward",
)
def make_wf_config(
case_study_id: str,
label_horizon: str = "0D",
date_col: str = "timestamp",
) -> WalkForwardConfig:
"""Backward-compatible alias for make_walk_forward_config."""
return make_walk_forward_config(
case_study_id=case_study_id,
label_horizon=label_horizon,
date_col=date_col,
)
def generate_cv_splits(
dataset: pl.DataFrame | pd.DataFrame,
case_study_id: str | None = None,
setup_path: Path | None = None,
label_buffer: str = "0D",
date_col: str = "timestamp",
*,
cv_config: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""Generate walk-forward date splits from evaluation config.
Delegates to ml4t-diagnostic's ``WalkForwardCV`` for calendar-aware
fold generation. Reads the ``evaluation`` section from ``setup.yaml``
(via ``case_study_id`` or ``setup_path``).
Parameters
----------
dataset : pl.DataFrame or pd.DataFrame
Dataset with a date/timestamp column. Only used to extract unique
timestamps -- the full panel rows are not needed.
case_study_id : str, optional
Case study identifier. Used to locate setup.yaml.
setup_path : Path, optional
Explicit path to setup.yaml. Takes precedence over case_study_id.
label_buffer : str, default "0D"
Gap between train_end and val_start sized to the label horizon.
Determined by the label being trained on (e.g., "21D" for fwd_ret_21d).
date_col : str, default "timestamp"
Name of the date/timestamp column.
cv_config : dict, optional
Pass a cv_config dict directly (e.g. from cv_config.json).
If provided, case_study_id and setup_path are ignored.
Returns
-------
list[dict]
List of split dicts with keys: ``fold``, ``train_start``,
``train_end``, ``val_start``, ``val_end``.
"""
from ml4t.diagnostic.splitters import WalkForwardCV
from ml4t.diagnostic.splitters.config import WalkForwardConfig as LibWalkForwardConfig
# Legacy path: pre-computed explicit splits
if cv_config is not None and "splits" in cv_config:
return cv_config["splits"]
# Normalize label buffer (strip ISO prefix, convert M → days)
label_buffer = _normalize_label_buffer(label_buffer)
# Load evaluation config
if cv_config is not None:
# Legacy cv_config dict
test_size_key = "val_size" if "val_size" in cv_config else "test_size"
holdout_start_key = "holdout_start" if "holdout_start" in cv_config else "test_start"
holdout_end_key = "holdout_end" if "holdout_end" in cv_config else "test_end"
eval_config = {
"n_splits": cv_config["n_splits"],
"train_size": str(cv_config["train_size"]),
"val_size": str(cv_config[test_size_key]),
"holdout_start": cv_config.get(holdout_start_key),
"holdout_end": cv_config.get(holdout_end_key),
"calendar": cv_config.get("calendar"),
}
elif setup_path is not None:
with open(setup_path) as f:
setup = yaml.safe_load(f)
eval_config = dict(setup["evaluation"])
elif case_study_id is not None:
eval_config = load_evaluation_config(case_study_id)
else:
raise ValueError("Provide either case_study_id, setup_path, or cv_config")
# Map calendar name to library exchange name
calendar_id = _map_calendar_id(eval_config.get("calendar"))
# For D-unit buffers with a calendar, pass label_horizon as int so the
# library interprets it as trading days (not calendar days). This fixes
# the under-buffering where "21D" → pd.Timedelta("21 days") → ~15 trading
# days instead of the intended 21 trading days.
label_horizon: int | str = label_buffer
if calendar_id is not None:
d_match = re.match(r"^(\d+)D$", label_buffer)
if d_match:
label_horizon = int(d_match.group(1))
# Build WalkForwardConfig (library Pydantic model)
config = LibWalkForwardConfig(
n_splits=eval_config["n_splits"],
train_size=_normalize_duration(str(eval_config["train_size"])),
val_size=_normalize_duration(str(eval_config["val_size"])),
holdout_start=eval_config.get("holdout_start"),
holdout_end=eval_config.get("holdout_end"),
label_horizon=label_horizon,
calendar_id=calendar_id,
fold_direction="backward",
)
# Extract sorted unique timestamps from the dataset
if isinstance(dataset, pl.DataFrame):
unique_ts = dataset.select(date_col).unique().sort(date_col).to_series().to_pandas()
else:
unique_ts = pd.Series(sorted(dataset[date_col].dropna().unique()))
if len(unique_ts) == 0:
raise ValueError("No timestamps found in dataset")
# Build a single-column DataFrame with DatetimeIndex for the splitter
ts_index = pd.DatetimeIndex(unique_ts)
input_tz_naive = ts_index.tz is None
if input_tz_naive:
ts_index = ts_index.tz_localize("UTC")
ts_df = pd.DataFrame(
{"_dummy": np.zeros(len(ts_index), dtype=np.int8)},
index=ts_index,
)
# Create WalkForwardCV with rolling window (expanding=False)
cv = WalkForwardCV(config=config)
cv.expanding = False
# Generate splits and extract date boundaries.
# Match tz-awareness to the input data so comparisons work.
def _ts(idx):
t = ts_index[idx]
return t.tz_localize(None) if input_tz_naive else t
splits = []
for fold_i, (train_idx, val_idx) in enumerate(cv.split(ts_df)):
splits.append(
{
"fold": fold_i,
"train_start": _ts(train_idx[0]),
"train_end": _ts(train_idx[-1]),
"val_start": _ts(val_idx[0]),
"val_end": _ts(val_idx[-1]),
}
)
return splits
+567
View File
@@ -0,0 +1,567 @@
"""Data quality and filtering utilities for data loading.
This module provides centralized functions for:
- Coverage summaries (rows, symbols, date range)
- OHLC invariant checks
- Null rate analysis
- Gap detection in time series
- Symbol subsetting for test-mode execution
Usage:
>>> from utils.data_quality import describe_coverage, check_ohlc_invariants
>>> coverage = describe_coverage(df, time_col="timestamp", asset_col="symbol")
>>> invariants = check_ohlc_invariants(df)
"""
from __future__ import annotations
import random
from datetime import timedelta
from typing import TYPE_CHECKING
import polars as pl
if TYPE_CHECKING:
from collections.abc import Sequence
def apply_max_symbols(
data: pl.DataFrame | pl.LazyFrame,
max_symbols: int,
symbol_col: str = "symbol",
seed: int = 42,
) -> pl.DataFrame | pl.LazyFrame:
"""Limit data to a random subset of symbols for fast-path testing.
Selects a reproducible random sample of symbols using a fixed seed.
Returns data unchanged if max_symbols <= 0 or >= total symbols.
"""
if max_symbols <= 0:
return data
if isinstance(data, pl.LazyFrame):
all_symbols = data.select(pl.col(symbol_col).unique()).collect()[symbol_col].to_list()
else:
all_symbols = data[symbol_col].unique().to_list()
if max_symbols >= len(all_symbols):
return data
rng = random.Random(seed)
selected = rng.sample(sorted(all_symbols), max_symbols)
return data.filter(pl.col(symbol_col).is_in(selected))
def describe_coverage(
df: pl.DataFrame,
time_col: str = "timestamp",
asset_col: str = "symbol",
) -> dict:
"""Return coverage summary for a dataset.
Args:
df: DataFrame with time and asset columns
time_col: Name of the timestamp/date column
asset_col: Name of the asset identifier column
Returns:
Dictionary with rows, assets, time_min, time_max, unique_times
"""
return {
"rows": df.height,
"assets": df[asset_col].n_unique() if asset_col in df.columns else 0,
"time_min": df[time_col].min(),
"time_max": df[time_col].max(),
"unique_times": df[time_col].n_unique(),
}
def print_coverage(
df: pl.DataFrame,
time_col: str = "timestamp",
asset_col: str = "symbol",
dataset_name: str = "Dataset",
) -> None:
"""Print formatted coverage summary."""
cov = describe_coverage(df, time_col, asset_col)
print(f"=== {dataset_name} Coverage ===")
print(f" Rows: {cov['rows']:,}")
print(f" Assets: {cov['assets']:,}")
print(f" Time range: {cov['time_min']} to {cov['time_max']}")
print(f" Unique times: {cov['unique_times']:,}")
def check_ohlc_invariants(
df: pl.DataFrame,
open_col: str = "open",
high_col: str = "high",
low_col: str = "low",
close_col: str = "close",
volume_col: str = "volume",
) -> pl.DataFrame:
"""Check OHLC data quality invariants.
Validates:
- high >= low
- high >= open
- high >= close
- low <= open
- low <= close
- volume >= 0 (if volume column exists)
For each check, only rows where all relevant columns are non-null are
considered. This prevents null comparisons from distorting percentages
(important for TAQ data where trade columns may be null for no-trade bars).
Args:
df: DataFrame with OHLC columns
open_col, high_col, low_col, close_col: Column names for OHLC
volume_col: Column name for volume (optional)
Returns:
DataFrame with check names and valid_pct columns
"""
results = []
total_rows = df.height
cols = set(df.columns)
def _check_invariant(name: str, condition: pl.Expr, required_cols: list[str]) -> None:
"""Check an invariant on rows where all required columns are non-null."""
# Filter to rows where all required columns are non-null
not_null_filter = pl.all_horizontal([pl.col(c).is_not_null() for c in required_cols])
applicable = df.filter(not_null_filter)
n_applicable = applicable.height
if n_applicable == 0:
return # Skip if no applicable rows
valid_pct = applicable.select(condition.mean()).item() * 100
results.append(
{
"check": name,
"valid_pct": valid_pct,
"applicable_rows": n_applicable,
"total_rows": total_rows,
}
)
# Define checks with their required columns
if {high_col, low_col}.issubset(cols):
_check_invariant(
"high_gte_low",
pl.col(high_col) >= pl.col(low_col),
[high_col, low_col],
)
if {high_col, open_col}.issubset(cols):
_check_invariant(
"high_gte_open",
pl.col(high_col) >= pl.col(open_col),
[high_col, open_col],
)
if {high_col, close_col}.issubset(cols):
_check_invariant(
"high_gte_close",
pl.col(high_col) >= pl.col(close_col),
[high_col, close_col],
)
if {low_col, open_col}.issubset(cols):
_check_invariant(
"low_lte_open",
pl.col(low_col) <= pl.col(open_col),
[low_col, open_col],
)
if {low_col, close_col}.issubset(cols):
_check_invariant(
"low_lte_close",
pl.col(low_col) <= pl.col(close_col),
[low_col, close_col],
)
if volume_col in cols:
_check_invariant(
"volume_non_negative",
pl.col(volume_col) >= 0,
[volume_col],
)
if not results:
return pl.DataFrame({"check": [], "valid_pct": [], "applicable_rows": [], "total_rows": []})
return pl.DataFrame(results)
def print_ohlc_invariants(
df: pl.DataFrame,
open_col: str = "open",
high_col: str = "high",
low_col: str = "low",
close_col: str = "close",
volume_col: str = "volume",
show_coverage: bool = False,
) -> None:
"""Print OHLC invariant check results.
Args:
show_coverage: If True, show how many rows each check applies to
"""
result = check_ohlc_invariants(df, open_col, high_col, low_col, close_col, volume_col)
print("=== OHLC Invariants ===")
for row in result.iter_rows(named=True):
status = "[OK]" if row["valid_pct"] >= 99.99 else "[WARN]"
coverage = ""
if show_coverage and row["applicable_rows"] < row["total_rows"]:
coverage = f" ({row['applicable_rows']:,}/{row['total_rows']:,} rows)"
print(f" {status} {row['check']}: {row['valid_pct']:.2f}%{coverage}")
def null_rate(
df: pl.DataFrame,
cols: Sequence[str] | None = None,
) -> pl.DataFrame:
"""Calculate null rates for specified columns.
Args:
df: DataFrame to analyze
cols: Columns to check (default: all columns)
Returns:
DataFrame with column names and null_pct
"""
if cols is None:
cols = df.columns
else:
cols = [c for c in cols if c in df.columns]
if not cols:
return pl.DataFrame({"column": [], "null_pct": []})
rates = df.select([pl.col(c).is_null().mean().alias(c) for c in cols])
return pl.DataFrame(
{
"column": list(rates.columns),
"null_pct": [rates[col].item() * 100 for col in rates.columns],
}
)
def print_null_rates(
df: pl.DataFrame,
cols: Sequence[str] | None = None,
threshold: float = 0.0,
) -> None:
"""Print null rates for columns exceeding threshold.
Args:
df: DataFrame to analyze
cols: Columns to check (default: all columns)
threshold: Only print columns with null_pct > threshold
"""
result = null_rate(df, cols)
result = result.filter(pl.col("null_pct") > threshold)
print("=== Null Rates ===")
if result.height == 0:
print(" No nulls detected")
else:
for row in result.iter_rows(named=True):
print(f" {row['column']}: {row['null_pct']:.2f}%")
def gap_summary(
df: pl.DataFrame,
time_col: str = "timestamp",
group_col: str | None = "symbol",
expected_delta: timedelta | None = None,
) -> pl.DataFrame:
"""Identify gaps in time series data.
Args:
df: DataFrame with time series data
time_col: Name of timestamp column
group_col: Column to group by (e.g., symbol). None for ungrouped.
expected_delta: Expected time between rows (e.g., timedelta(hours=1))
Returns:
DataFrame with gap statistics per group (if grouped) or overall
"""
df_sorted = df.sort([group_col, time_col] if group_col else [time_col])
# Calculate time differences
if group_col:
df_gaps = df_sorted.with_columns(pl.col(time_col).diff().over(group_col).alias("time_diff"))
else:
df_gaps = df_sorted.with_columns(pl.col(time_col).diff().alias("time_diff"))
# If expected_delta provided, filter to gaps exceeding it
if expected_delta is not None:
df_gaps = df_gaps.filter(
(pl.col("time_diff") > expected_delta) | pl.col("time_diff").is_null()
)
# Aggregate
if group_col:
return (
df_gaps.filter(pl.col("time_diff").is_not_null())
.group_by(group_col)
.agg(
pl.len().alias("gap_count"),
pl.col("time_diff").max().alias("max_gap"),
)
.sort(group_col)
)
else:
gaps = df_gaps.filter(pl.col("time_diff").is_not_null())
if gaps.height == 0:
return pl.DataFrame({"gap_count": [0], "max_gap": [None]})
return pl.DataFrame(
{
"gap_count": [gaps.height],
"max_gap": [gaps["time_diff"].max()],
}
)
def per_asset_stats(
df: pl.DataFrame,
time_col: str = "timestamp",
asset_col: str = "symbol",
price_col: str = "close",
volume_col: str | None = "volume",
) -> pl.DataFrame:
"""Calculate per-asset summary statistics.
Args:
df: DataFrame with time series data
time_col: Timestamp column name
asset_col: Asset identifier column name
price_col: Price column for mean calculation
volume_col: Volume column (optional)
Returns:
DataFrame with rows, start, end, avg_price per asset
"""
aggs = [
pl.len().alias("rows"),
pl.col(time_col).min().alias("start"),
pl.col(time_col).max().alias("end"),
pl.col(price_col).mean().alias("avg_price"),
]
if volume_col and volume_col in df.columns:
aggs.append(pl.col(volume_col).mean().alias("avg_volume"))
return df.group_by(asset_col).agg(aggs).sort(asset_col)
# ---------------------------------------------------------------------------
# Modeling pipeline quality gates
# ---------------------------------------------------------------------------
def validate_prices(
df: pl.DataFrame,
price_cols: Sequence[str] = ("open", "high", "low", "close"),
asset_col: str = "symbol",
time_col: str = "timestamp",
) -> list[str]:
"""Check price columns for negative values, infinities, and NaN.
Returns a list of warning/error strings. Empty list = all clean.
"""
issues: list[str] = []
cols_present = [c for c in price_cols if c in df.columns]
for col in cols_present:
n_neg = df.filter(pl.col(col) < 0).height
n_inf = df.filter(pl.col(col).is_infinite()).height
n_nan = df.filter(pl.col(col).is_nan()).height
if n_neg > 0:
# Show which assets have negative prices
neg_assets = df.filter(pl.col(col) < 0).select(asset_col).unique().to_series().to_list()
issues.append(
f"CRITICAL: {col} has {n_neg} negative values "
f"(assets: {neg_assets[:5]}{'...' if len(neg_assets) > 5 else ''})"
)
if n_inf > 0:
issues.append(f"CRITICAL: {col} has {n_inf} infinite values")
if n_nan > 0:
issues.append(f"WARNING: {col} has {n_nan} NaN values")
return issues
def validate_labels(
df: pl.DataFrame,
label_col: str,
max_abs_return: float = 0.5,
) -> list[str]:
"""Check forward return labels for data quality issues.
Args:
df: DataFrame containing the label column
label_col: Name of the forward return column
max_abs_return: Maximum plausible absolute return (e.g., 0.5 = 50%)
Returns list of warning/error strings.
"""
issues: list[str] = []
vals = df[label_col].drop_nulls()
n_inf = vals.filter(vals.is_infinite()).len()
n_nan = vals.filter(vals.is_nan()).len()
n_extreme = vals.filter(vals.abs() > max_abs_return).len()
n_total = vals.len()
if n_inf > 0:
issues.append(f"CRITICAL: {label_col} has {n_inf} infinite values")
if n_nan > 0:
issues.append(f"CRITICAL: {label_col} has {n_nan} NaN values")
if n_extreme > 0:
pct = n_extreme / n_total * 100
issues.append(
f"WARNING: {label_col} has {n_extreme} values with |ret| > {max_abs_return:.0%} "
f"({pct:.2f}% of {n_total:,} rows)"
)
return issues
def validate_features(
df: pl.DataFrame,
feature_cols: Sequence[str],
max_abs_value: float = 1e6,
) -> list[str]:
"""Check feature columns for infinities, all-null, and extreme values.
Args:
df: DataFrame containing feature columns
feature_cols: List of feature column names to validate
max_abs_value: Threshold for flagging extreme values
Returns list of warning/error strings.
"""
issues: list[str] = []
n_rows = df.height
inf_cols = []
null_cols = []
extreme_cols = []
for col in feature_cols:
if col not in df.columns:
continue
series = df[col]
n_null = series.null_count()
non_null = series.drop_nulls()
if n_null == n_rows:
null_cols.append(col)
continue
if non_null.len() > 0:
n_inf = non_null.filter(non_null.is_infinite()).len()
if n_inf > 0:
inf_cols.append((col, n_inf))
n_extreme = non_null.filter(non_null.abs() > max_abs_value).len()
if n_extreme > 0:
extreme_cols.append((col, n_extreme))
if inf_cols:
details = ", ".join(f"{c}({n})" for c, n in inf_cols[:10])
issues.append(f"CRITICAL: {len(inf_cols)} features have infinite values: {details}")
if null_cols:
issues.append(
f"WARNING: {len(null_cols)} features are entirely null: "
f"{null_cols[:10]}{'...' if len(null_cols) > 10 else ''}"
)
if extreme_cols:
details = ", ".join(f"{c}({n})" for c, n in extreme_cols[:10])
issues.append(
f"WARNING: {len(extreme_cols)} features have values |x| > {max_abs_value:.0e}: {details}"
)
return issues
def validate_modeling_inputs(
features_df: pl.DataFrame,
label_df: pl.DataFrame,
feature_cols: Sequence[str],
label_col: str,
join_cols: Sequence[str] = ("timestamp", "symbol"),
price_cols: Sequence[str] = (),
asset_col: str = "symbol",
max_abs_return: float = 0.5,
max_abs_feature: float = 1e6,
fail_on_critical: bool = True,
) -> dict:
"""Run all data quality checks before modeling.
This is the gate between data preparation (labels + features) and
model training. Call this at the start of evaluation notebooks.
Args:
features_df: Feature DataFrame
label_df: Label DataFrame with forward returns
feature_cols: Feature column names to validate
label_col: Forward return column name
join_cols: Columns used to join features and labels
price_cols: Price columns to check (if present in features_df)
asset_col: Asset identifier column name
max_abs_return: Max plausible absolute return for labels
max_abs_feature: Max plausible absolute feature value
fail_on_critical: If True, raise ValueError on CRITICAL issues
Returns:
Dict with 'issues' (list of strings), 'n_critical', 'n_warning'
Raises:
ValueError: If fail_on_critical=True and any CRITICAL issues found
"""
all_issues: list[str] = []
# 1. Price checks (if price columns present)
if price_cols:
all_issues.extend(validate_prices(features_df, price_cols, asset_col=asset_col))
# 2. Label checks
all_issues.extend(validate_labels(label_df, label_col, max_abs_return))
# 3. Feature checks
all_issues.extend(validate_features(features_df, feature_cols, max_abs_feature))
# Summarize
n_critical = sum(1 for i in all_issues if i.startswith("CRITICAL"))
n_warning = sum(1 for i in all_issues if i.startswith("WARNING"))
# Print results
if all_issues:
print(f"Data Quality Gate: {n_critical} CRITICAL, {n_warning} WARNING")
for issue in all_issues:
marker = "[X]" if issue.startswith("CRITICAL") else "[!]"
print(f" {marker} {issue}")
else:
print("Data Quality Gate: ALL CLEAR")
result = {
"issues": all_issues,
"n_critical": n_critical,
"n_warning": n_warning,
}
if fail_on_critical and n_critical > 0:
raise ValueError(
f"Data quality gate FAILED: {n_critical} critical issues. "
f"Fix upstream data before modeling."
)
return result
+369
View File
@@ -0,0 +1,369 @@
"""Shared utilities for ML4T data download scripts.
Provides:
- Standardized argument parsing with --dry-run, --data-path, --force, --verbose
- Standardized path resolution via utils.config
- YAML config helpers for dataset download scripts
- Import checking with helpful errors
- Consistent output formatting
- Atomic file writes
- Download summary reporting
- DataBento cost acknowledgment
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
from typing import Any
_repo_root = Path(__file__).parent.parent
# ---------------------------------------------------------------------------
# Path resolution
# ---------------------------------------------------------------------------
def resolve_data_dir(cli_arg: Path | None = None) -> Path:
"""Resolve data directory with standardized precedence.
Priority:
1. CLI argument (highest)
2. ML4T_DATA_PATH environment variable (from utils.config)
3. <repo>/data (default)
"""
if cli_arg is not None:
path = Path(cli_arg).expanduser().resolve()
print(f"Using data path (CLI): {path}")
return path
try:
from utils.config import ML4T_DATA_PATH
print(f"Using data path (ML4T_DATA_PATH): {ML4T_DATA_PATH}")
return ML4T_DATA_PATH
except (ImportError, FileNotFoundError):
pass
env_dir = os.environ.get("ML4T_DATA_PATH")
if env_dir:
path = Path(env_dir).expanduser().resolve()
print(f"Using data path (env): {path}")
return path
default_path = _repo_root / "data"
print(f"Using data path (default): {default_path}")
default_path.mkdir(parents=True, exist_ok=True)
return default_path
# ---------------------------------------------------------------------------
# Environment
# ---------------------------------------------------------------------------
def load_dotenv(env_file: Path | None = None):
"""Load environment variables from .env file."""
if env_file is None:
env_file = _repo_root / ".env"
if not env_file.exists():
return
with open(env_file) as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if value and not os.getenv(key):
os.environ[key] = value
def require_env(var: str, hint: str | None = None) -> str:
"""Get required environment variable or exit with helpful message."""
value = os.getenv(var)
if not value:
print(f"ERROR: {var} not set")
if hint:
print(f" {hint}")
print(f" Add to .env file: {var}=your-value")
sys.exit(1)
assert value is not None
return value
def check_import(module: str, install_hint: str):
"""Check if module can be imported, exit with helpful message if not."""
try:
__import__(module)
except ImportError as e:
print(f"ERROR: {module} not available")
print(f" Run: {install_hint}")
print(f" ({e})")
sys.exit(1)
# ---------------------------------------------------------------------------
# YAML config helpers (from book_config)
# ---------------------------------------------------------------------------
def load_section(config_path: str | Path, section: str) -> dict[str, Any]:
"""Load a top-level YAML section from a config file."""
import yaml
path = Path(config_path).expanduser()
with open(path) as f:
raw = yaml.safe_load(f) or {}
return raw.get(section, {})
def resolve_storage_path(data_root: Path, configured_path: str | None, fallback: str) -> Path:
"""Resolve storage path relative to the selected ML4T data root."""
raw_path = configured_path or fallback
path = Path(raw_path).expanduser()
return path if path.is_absolute() else data_root / path
def flatten_group_values(groups: dict[str, Any], values_key: str) -> list[str]:
"""Flatten grouped config values like symbols or pairs into a unique ordered list."""
values: list[str] = []
seen: set[str] = set()
for group in groups.values():
if not isinstance(group, dict):
continue
for value in group.get(values_key, []):
if value not in seen:
values.append(value)
seen.add(value)
return values
def save_dataset_profile(
df,
data_path: str | Path,
*,
source: str,
timestamp_col: str = "timestamp",
symbol_col: str | None = "symbol",
) -> Path:
"""Generate and save a dataset profile next to a data file."""
from ml4t.data.storage.data_profile import generate_profile, get_profile_path, save_profile
path = Path(data_path)
profile = generate_profile(
df, source=source, timestamp_col=timestamp_col, symbol_col=symbol_col
)
profile_path = get_profile_path(path)
save_profile(profile, profile_path)
return profile_path
# ---------------------------------------------------------------------------
# DataBento
# ---------------------------------------------------------------------------
def patch_databento_symbology():
"""Patch databento 0.72.0 bug where insert_metadata expects 'asset' key."""
try:
import databento.common.symbology as sym
except ImportError:
return
if getattr(sym.InstrumentMap.insert_metadata, "_ml4t_patched", False):
return
_orig = sym.InstrumentMap.insert_metadata
def _patched(self, metadata):
mappings = metadata.mappings
if mappings:
for _symbol_in, entries in mappings.items():
for entry in entries:
if "asset" not in entry and "symbol" in entry:
entry["asset"] = entry["symbol"]
class _PatchedMeta:
"""Thin wrapper that returns fixed mappings."""
def __init__(self, orig, fixed_mappings):
self._orig = orig
self._fixed = fixed_mappings
@property
def mappings(self):
return self._fixed
def __getattr__(self, name):
return getattr(self._orig, name)
return _orig(self, _PatchedMeta(metadata, mappings))
_patched._ml4t_patched = True
sym.InstrumentMap.insert_metadata = _patched
DATABENTO_WARNING = """
================================================================================
DATABENTO API - PAID SERVICE WARNING
================================================================================
This download uses the DataBento API which is a PAID service.
IMPORTANT INFORMATION:
- DataBento requires registration with a credit card on file
- As of February 2026, DataBento offers a $125 sign-up credit for new accounts
- This credit is sufficient to cover the ML4T book datasets:
* CME Futures (continuous contracts): ~$75
* MBO Tick Data (3 symbols, 10 days): ~$10-15
WARNINGS:
- If you have already used your sign-up credit on other downloads,
this download WILL BE CHARGED to your credit card
- DataBento may change or remove the sign-up credit at any time
- Cost estimates are approximate; actual costs may vary
- YOU are responsible for managing your DataBento account and costs
ESTIMATED COST FOR THIS DOWNLOAD: ${estimated_cost:.2f}
================================================================================
"""
def databento_acknowledge(estimated_cost: float, force: bool = False) -> bool:
"""Display DataBento cost warning and require explicit acknowledgment."""
print(DATABENTO_WARNING.format(estimated_cost=estimated_cost))
if force:
print("--force flag set: Proceeding without interactive confirmation.")
print("By using --force, you acknowledge the above warnings.")
return True
print("To proceed with this download, type exactly: I UNDERSTAND")
print("To cancel, press Ctrl+C or type anything else.")
print()
try:
response = input("Your response: ").strip()
except (KeyboardInterrupt, EOFError):
print("\nDownload cancelled.")
return False
if response == "I UNDERSTAND":
print("\nAcknowledgment received. Proceeding with download...")
return True
else:
print(f"\nResponse '{response}' does not match 'I UNDERSTAND'.")
print("Download cancelled for your protection.")
return False
def databento_estimate_only_notice(estimated_cost: float) -> None:
"""Print cost estimate without prompting for download."""
print("\n" + "=" * 70)
print("DATABENTO COST ESTIMATE (No download - estimate only)")
print("=" * 70)
print(f"\n Estimated cost: ${estimated_cost:.2f}")
print()
print(" Note: As of Feb 2026, new DataBento accounts receive $125 credit.")
print(" If your credit is exhausted, this amount will be charged.")
print()
print(" To proceed with download, run without --estimate-only flag.")
print("=" * 70 + "\n")
# ---------------------------------------------------------------------------
# Output formatting
# ---------------------------------------------------------------------------
def print_section(title: str, char: str = "=", width: int = 60):
"""Print a formatted section header."""
print("\n" + char * width)
print(title)
print(char * width)
def atomic_write_parquet(df, path: Path):
"""Write Polars DataFrame to parquet with atomic rename."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
tmp_file = path.parent / f".{path.name}.tmp"
try:
df.write_parquet(tmp_file)
tmp_file.replace(path)
except Exception as e:
if tmp_file.exists():
tmp_file.unlink()
raise e
def get_repo_root() -> Path:
"""Get repository root directory."""
return _repo_root
def create_base_parser(description: str) -> argparse.ArgumentParser:
"""Create argument parser with standard ML4T download flags."""
parser = argparse.ArgumentParser(
description=description,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--data-path",
type=Path,
default=None,
help="Data storage location (default: $ML4T_DATA_PATH or repo/data)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be done without downloading",
)
parser.add_argument(
"--force",
action="store_true",
help="Force re-download even if data exists",
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help="Verbose output",
)
return parser
def print_download_summary(stats: dict, dry_run: bool = False) -> None:
"""Print standardized download summary."""
prefix = "[DRY RUN] " if dry_run else ""
print_section(f"{prefix}SUMMARY")
for key, value in stats.items():
display_key = key.replace("_", " ").title()
if isinstance(value, int) and value > 1000:
print(f" {display_key}: {value:,}")
elif isinstance(value, float):
print(f" {display_key}: {value:.2f}")
else:
print(f" {display_key}: {value}")
def print_dry_run_notice() -> None:
"""Print notice that this is a dry run."""
print("\n" + "=" * 60)
print("DRY RUN - No data will be downloaded")
print("Remove --dry-run to actually download")
print("=" * 60 + "\n")
+1312
View File
File diff suppressed because it is too large Load Diff
+392
View File
@@ -0,0 +1,392 @@
"""Chapter and data path management for ML4T notebooks.
This module provides:
1. CHAPTERS - Canonical registry of chapter numbers to directory names
2. get_chapter_dir() - Type-safe chapter path access
3. get_case_study_dir() - Centralized case study artifact store
4. get_output_dir() - Chapter-local outputs (non-case-study data)
5. CH01-CH27 - Direct chapter path constants
Naming Convention:
- Dataset IDs: etf, crypto, futures, fx, equities_us, equities_nasdaq100, options_sp500
- Strategy IDs: etfs, crypto_perps_funding, cme_futures, fx_pairs, us_equities_panel,
nasdaq100_microstructure, sp500_equity_option_analytics, sp500_options,
us_firm_characteristics
Usage:
from utils.paths import get_case_study_dir
# Write features in Ch7
CASE_DIR = get_case_study_dir("etfs")
features.write_parquet(CASE_DIR / "features" / "features.parquet")
# Read features in Ch9
CASE_DIR = get_case_study_dir("etfs")
features = pl.read_parquet(CASE_DIR / "features" / "features.parquet")
predictions.write_parquet(CASE_DIR / "models" / "linear" / "predictions.parquet")
# Chapter-local outputs (benchmarks, demonstrations - NOT case studies)
from utils.paths import get_output_dir
OUTPUT_DIR = get_output_dir(7, "benchmark_results")
"""
from pathlib import Path
# =============================================================================
# Core Paths
# =============================================================================
REPO_ROOT = Path(__file__).parent.parent.resolve()
# =============================================================================
# Chapter Registry (Single Source of Truth)
# =============================================================================
CHAPTERS: dict[int, str] = {
1: "01_process_is_edge",
2: "02_financial_data_universe",
3: "03_market_microstructure",
4: "04_fundamental_alternative_data",
5: "05_synthetic_data",
6: "06_strategy_definition",
7: "07_defining_the_learning_task",
8: "08_financial_features",
9: "09_model_based_features",
10: "10_text_feature_engineering",
11: "11_ml_pipeline",
12: "12_gradient_boosting",
13: "13_dl_time_series",
14: "14_latent_factors",
15: "15_causal_estimation",
16: "16_strategy_simulation",
17: "17_portfolio_construction",
18: "18_transaction_costs",
19: "19_risk_management",
20: "20_strategy_synthesis",
21: "21_rl_execution_hedging",
22: "22_rag_financial_research",
23: "23_knowledge_graphs",
24: "24_autonomous_agents",
25: "25_live_trading",
26: "26_mlops_governance",
27: "27_systematic_edge",
}
# Sub-chapters (not in main sequence)
SUB_CHAPTERS: dict[str, str] = {}
# Strategy IDs (Tier 3 naming)
# Updated 2026-02-12 to match case_studies/ directory structure
STRATEGY_IDS = frozenset(
{
"etfs",
"crypto_perps_funding",
"nasdaq100_microstructure",
"sp500_equity_option_analytics",
"us_firm_characteristics",
"fx_pairs",
"cme_futures",
"sp500_options",
"us_equities_panel",
}
)
# Stage mapping: chapter number → subdirectory within case_studies/{strategy}/
# Used as documentation reference; notebooks use string literals directly.
# Updated 2026-02-25 for 27-chapter scheme (old Ch16+21 merged → Ch20)
CHAPTER_STAGES: dict[int, str] = {
6: "exploration",
7: "labels",
8: "features",
9: "features", # Temporal features
10: "features/text",
11: "models/linear",
12: "models/gbm",
13: "models/deep_learning",
14: "models/latent_factors",
15: "models/causal",
16: "backtest/simulation",
17: "backtest/portfolio",
18: "backtest/costs",
19: "backtest/risk",
20: "synthesis",
}
# =============================================================================
# Chapter Path Functions
# =============================================================================
def display_path(path: Path | str) -> str:
"""Return `path` relative to REPO_ROOT when possible, else as-is.
Use in `print(...)` statements inside notebooks so the committed cell
output never bakes in machine-specific absolute paths (e.g. `/home/<user>/...`).
"""
p = Path(path)
try:
return str(p.relative_to(REPO_ROOT))
except ValueError:
return str(p)
def get_chapter_dir(chapter: int | str) -> Path:
"""Get the directory path for a chapter.
Args:
chapter: Chapter number (1-27)
Returns:
Absolute path to chapter directory
Raises:
ValueError: If chapter number is invalid
Examples:
>>> get_chapter_dir(7)
ML4T_PATH / "code/07_feature_engineering"
"""
if isinstance(chapter, int):
if chapter not in CHAPTERS:
raise ValueError(f"Invalid chapter number: {chapter}. Valid: 1-27")
return REPO_ROOT / CHAPTERS[chapter]
# String: check sub-chapters
if chapter in SUB_CHAPTERS:
return REPO_ROOT / SUB_CHAPTERS[chapter]
raise ValueError(
f"Invalid chapter: {chapter!r}. "
f"Use integers 1-27 or sub-chapter IDs: {list(SUB_CHAPTERS.keys())}"
)
def get_output_dir(
chapter: int | str,
strategy_id: str,
*,
create: bool = True,
) -> Path:
"""Get output directory for cross-chapter data flow.
This is the primary function for case study data that flows between chapters:
- Ch7 creates features.parquet -> Ch9 reads for signal evaluation
- Ch9 creates signals.parquet -> Ch12 reads for ML pipeline
- Ch12 creates predictions.parquet -> Ch18 reads for backtest
When ML4T_OUTPUT_DIR is set (e.g., by pytest), outputs are redirected to
a temporary directory to prevent tests from overwriting production data.
Args:
chapter: Chapter number (1-27) or sub-chapter ID
strategy_id: Strategy identifier (e.g., "etfs", "crypto_perps_funding")
create: If True, create directory if it doesn't exist
Returns:
Path to output directory: {chapter_dir}/output/{strategy_id}/
Examples:
>>> get_output_dir(7, "etfs")
PosixPath('.../07_defining_the_learning_task/output/etfs')
>>> # Read from previous chapter
>>> features = pl.read_parquet(get_output_dir(7, "etfs") / "features.parquet")
>>> # In test mode with ML4T_OUTPUT_DIR=/tmp/test
>>> get_output_dir(7, "etfs")
PosixPath('/tmp/test/ch07_etfs')
"""
import os
# Test mode: allow chapter outputs to redirect independently from case studies.
test_output = os.environ.get("ML4T_CHAPTER_OUTPUT_DIR") or os.environ.get("ML4T_OUTPUT_DIR")
if test_output:
# Get chapter number for directory naming
if isinstance(chapter, int):
ch_num = chapter
else:
# Sub-chapter: use string as-is
ch_num = chapter
output_dir = (
Path(test_output) / f"ch{ch_num:02d}_{strategy_id}"
if isinstance(ch_num, int)
else Path(test_output) / f"{ch_num}_{strategy_id}"
)
if create:
output_dir.mkdir(parents=True, exist_ok=True)
return output_dir
# Production mode: normal chapter output
chapter_dir = get_chapter_dir(chapter)
output_dir = chapter_dir / "output" / strategy_id
if create:
output_dir.mkdir(parents=True, exist_ok=True)
return output_dir
def get_case_study_source_dir(strategy_id: str) -> Path:
"""Get the source-controlled case study directory, preferring sibling dev assets."""
dev_case_dir = REPO_ROOT.parent / "dev" / "case_studies" / strategy_id
if dev_case_dir.exists():
return dev_case_dir
return REPO_ROOT / "case_studies" / strategy_id
def get_case_study_dir(strategy_id: str, *, create: bool = True) -> Path:
"""Get the case study directory for a strategy.
Case studies are centralized under CASE_STUDIES_DIR (default: repo_root/case_studies/).
Each strategy has its own directory with stage-based subdirectories:
case_studies/{strategy_id}/
├── exploration/ # Ch6 priors & EDA
├── labels/ # Ch7 labels & evaluation
├── features/ # Ch8-10 feature engineering
├── models/ # Ch11-15 ML models
│ ├── linear/ # Ch11
│ ├── gbm/ # Ch12
│ ├── deep_learning/ # Ch13
│ ├── latent_factors/ # Ch14
│ └── causal/ # Ch15
├── backtest/ # Ch16-19 strategy implementation
│ ├── simulation/ # Ch16
│ ├── portfolio/ # Ch17
│ ├── costs/ # Ch18
│ └── risk/ # Ch19
└── synthesis/ # Ch20 strategy development synthesis
When ML4T_OUTPUT_DIR is set (e.g., by pytest), outputs are redirected to
a temporary directory to prevent tests from overwriting production data.
Args:
strategy_id: Strategy identifier (e.g., "etfs", "crypto_perps_funding")
create: If True, create directory if it doesn't exist
Returns:
Path to case study directory: case_studies/{strategy_id}/
Examples:
>>> get_case_study_dir("etfs")
PosixPath('.../case_studies/etfs')
>>> # Typical usage with stage subdirectory
>>> CASE_DIR = get_case_study_dir("etfs")
>>> features.write_parquet(CASE_DIR / "features" / "features.parquet")
>>> # In test mode with ML4T_OUTPUT_DIR=/tmp/test
>>> get_case_study_dir("etfs")
PosixPath('/tmp/test/etfs')
"""
import os
# Test mode: redirect to temp directory (prevents overwriting production data)
test_output = os.environ.get("ML4T_OUTPUT_DIR")
if test_output:
output_dir = Path(test_output) / strategy_id
else:
from utils import CASE_STUDIES_DIR
output_dir = CASE_STUDIES_DIR / strategy_id
if create:
output_dir.mkdir(parents=True, exist_ok=True)
return output_dir
# =============================================================================
# Dataset IDs (Tier 2 naming)
# =============================================================================
DATASET_IDS = frozenset(
{
"etf",
"crypto",
"futures",
"fx",
"equities_us",
"equities_nasdaq100",
"equities_sp500",
"options_sp500",
}
)
# =============================================================================
# Chapter Path Constants (for direct imports)
# =============================================================================
CH01 = REPO_ROOT / CHAPTERS[1]
CH02 = REPO_ROOT / CHAPTERS[2]
CH03 = REPO_ROOT / CHAPTERS[3]
CH04 = REPO_ROOT / CHAPTERS[4]
CH05 = REPO_ROOT / CHAPTERS[5]
CH06 = REPO_ROOT / CHAPTERS[6]
CH07 = REPO_ROOT / CHAPTERS[7]
CH08 = REPO_ROOT / CHAPTERS[8]
CH09 = REPO_ROOT / CHAPTERS[9]
CH10 = REPO_ROOT / CHAPTERS[10]
CH11 = REPO_ROOT / CHAPTERS[11]
CH12 = REPO_ROOT / CHAPTERS[12]
CH13 = REPO_ROOT / CHAPTERS[13]
CH14 = REPO_ROOT / CHAPTERS[14]
CH15 = REPO_ROOT / CHAPTERS[15]
CH16 = REPO_ROOT / CHAPTERS[16]
CH17 = REPO_ROOT / CHAPTERS[17]
CH18 = REPO_ROOT / CHAPTERS[18]
CH19 = REPO_ROOT / CHAPTERS[19]
CH20 = REPO_ROOT / CHAPTERS[20]
CH21 = REPO_ROOT / CHAPTERS[21]
CH22 = REPO_ROOT / CHAPTERS[22]
CH23 = REPO_ROOT / CHAPTERS[23]
CH24 = REPO_ROOT / CHAPTERS[24]
CH25 = REPO_ROOT / CHAPTERS[25]
CH26 = REPO_ROOT / CHAPTERS[26]
CH27 = REPO_ROOT / CHAPTERS[27]
__all__ = [
# Registry
"CHAPTERS",
"SUB_CHAPTERS",
"STRATEGY_IDS",
"CHAPTER_STAGES",
"CASE_STUDIES", # Legacy alias
"DATASET_IDS",
"REPO_ROOT",
# Functions
"get_chapter_dir",
"get_output_dir",
"get_case_study_dir",
# Constants
"CH01",
"CH02",
"CH03",
"CH04",
"CH05",
"CH06",
"CH07",
"CH08",
"CH09",
"CH10",
"CH11",
"CH12",
"CH13",
"CH14",
"CH15",
"CH16",
"CH17",
"CH18",
"CH19",
"CH20",
"CH21",
"CH22",
"CH23",
"CH24",
"CH25",
"CH26",
"CH27",
]
+92
View File
@@ -0,0 +1,92 @@
"""Predictions cache for chapter teaching notebooks.
Cache long-form prediction frames keyed by a content-addressed spec hash so
re-running visualization / interpretation cells doesn't require re-training
the upstream stages. Cache files live under
{chapter_dir}/output/predictions/{notebook_id}/{spec_hash}.parquet
which is gitignored.
Frame schema (required columns):
date -- value identifying the prediction period
symbol -- value identifying the asset
y_pred -- float, model prediction
y_true -- float, realised forward return
Optional column:
forecaster -- string label, when one notebook stacks multiple forecasters
(e.g. Constant / AR(1) / EWMA) into a single frame.
Typical use::
from utils.predictions_cache import load_predictions, save_predictions
spec = {
"data": {"source": "etfs", "start": START_DATE, "end": END_DATE,
"max_symbols": MAX_SYMBOLS},
"model": {"name": "rp_pca", "n_factors": N_FACTORS,
"focus_gamma": focus_gamma},
"forecasters": ["Constant", "AR(1)", "EWMA"],
}
cached = load_predictions(chapter=14, notebook_id="rp_pca", spec=spec)
if cached is None:
# ... expensive Stage 1-3 work, build long-form `frame` ...
save_predictions(chapter=14, notebook_id="rp_pca", spec=spec, frame=frame)
cached = frame
# downstream code consumes `cached` for plotting / IC summaries.
The spec dict is the contract: any value that materially changes the
predictions must appear in it. Two runs that share a spec hash will share a
cache entry, so changing a hyperparameter without updating the spec will
silently reuse stale predictions.
"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
import polars as pl
from utils.paths import get_chapter_dir
REQUIRED_COLUMNS = ("date", "symbol", "y_pred", "y_true")
KEY_LENGTH = 12
def predictions_cache_key(spec: dict) -> str:
"""SHA-1 hash of the canonicalised spec; first 12 hex digits."""
payload = json.dumps(spec, sort_keys=True, default=str).encode("utf-8")
return hashlib.sha1(payload).hexdigest()[:KEY_LENGTH]
def predictions_cache_path(chapter: int | str, notebook_id: str, spec: dict) -> Path:
"""Deterministic parquet path; does not create the file or its parents."""
root = get_chapter_dir(chapter) / "output" / "predictions" / notebook_id
return root / f"{predictions_cache_key(spec)}.parquet"
def load_predictions(chapter: int | str, notebook_id: str, spec: dict) -> pl.DataFrame | None:
"""Return the cached predictions frame, or None if no cache entry exists."""
path = predictions_cache_path(chapter, notebook_id, spec)
if not path.exists():
return None
return pl.read_parquet(path)
def save_predictions(chapter: int | str, notebook_id: str, spec: dict, frame: pl.DataFrame) -> Path:
"""Write predictions frame to its content-addressed cache location."""
missing = [c for c in REQUIRED_COLUMNS if c not in frame.columns]
if missing:
raise ValueError(
f"predictions frame missing required columns {missing}; have {list(frame.columns)}"
)
path = predictions_cache_path(chapter, notebook_id, spec)
path.parent.mkdir(parents=True, exist_ok=True)
frame.write_parquet(path)
return path
+47
View File
@@ -0,0 +1,47 @@
"""One-call seed initialization for reproducible notebook runs.
Notebooks that produce any random output should call ``set_global_seeds()``
in their preamble, between imports and the first computation. Monte Carlo
demos that *want* per-run variability should still call it, with the seed
declared in their parameters cell so readers can change it explicitly.
"""
from __future__ import annotations
import os
import random
def set_global_seeds(seed: int = 42) -> None:
"""Seed Python ``random``, NumPy, Torch (CPU+CUDA), and ``PYTHONHASHSEED``.
Polars and pandas operations that need a seed accept it per-call
(e.g. ``df.sample(seed=seed)``) — there is no global polars seed to set.
Scikit-learn estimators take ``random_state=`` per-instance.
Returns ``None`` rather than the seed so that a bare
``set_global_seeds(SEED)`` in a notebook preamble does not render a
spurious ``42`` execute_result. Notebooks that want to echo the seed back
to the reader should ``print(SEED)`` explicitly.
``PYTHONHASHSEED`` is set on ``os.environ`` for the benefit of subprocesses
spawned after the call; it has **no effect** on hash randomization in the
currently running interpreter (that value is read once at startup). For
end-to-end hash determinism the kernel must be launched with
``PYTHONHASHSEED=<seed>`` already set in the environment.
"""
os.environ["PYTHONHASHSEED"] = str(seed)
random.seed(seed)
import numpy as np
np.random.seed(seed)
try:
import torch
except ImportError:
pass
else:
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
+883
View File
@@ -0,0 +1,883 @@
"""Shared utilities for storage benchmarks.
This module provides common functionality used by both local and server benchmarks:
- Synthetic data generation (OHLCV, tick data)
- Timing infrastructure with warm-up and percentiles
- Result validation with forced materialization
- Configuration via YAML or environment variables
- Memory estimation utilities
"""
import gc
import os
import time
import warnings
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import polars as pl
import yaml
warnings.filterwarnings("ignore")
# =============================================================================
# CONFIGURATION
# =============================================================================
# Benchmark scale configuration
# BENCHMARK_SCALE: XS, S, M, L, XL, XXL
BENCHMARK_SCALE = os.environ.get("BENCHMARK_SCALE", "").upper()
# Chapter directory paths
from utils.paths import get_chapter_dir
CHAPTER_DIR = get_chapter_dir(2)
CODE_DIR = CHAPTER_DIR
# Load configuration from YAML if available
CONFIG_PATH = CODE_DIR / "benchmark_config.yaml"
def load_config() -> dict:
"""Load benchmark configuration from YAML file."""
if CONFIG_PATH.exists():
with open(CONFIG_PATH) as f:
return yaml.safe_load(f)
return {}
CONFIG = load_config()
# New scale configurations (from YAML or defaults)
# Format: {scale: {ohlcv: {symbols, rows_per_symbol, total_rows}, tick: {...}}}
SCALE_CONFIGS_NEW = {}
if CONFIG.get("scales"):
for scale_name, scale_cfg in CONFIG["scales"].items():
ohlcv = scale_cfg.get("ohlcv", {})
tick = scale_cfg.get("tick", {})
SCALE_CONFIGS_NEW[scale_name] = {
"ohlcv": {
"symbols": ohlcv.get("symbols", 10),
"rows_per_symbol": ohlcv.get("rows_per_symbol", 1000),
"total_rows": ohlcv.get("total_rows", 10000),
},
"tick": {
"symbols": tick.get("symbols", 5),
"trades": tick.get("trades", 5000),
"quotes": tick.get("quotes", 25000),
},
"target_memory": scale_cfg.get("target_memory", "1MB"),
"description": scale_cfg.get("description", ""),
}
else:
# Default scale configs if YAML not present
SCALE_CONFIGS_NEW = {
"XS": {
"ohlcv": {"symbols": 5, "rows_per_symbol": 200, "total_rows": 1000},
"tick": {"symbols": 3, "trades": 500, "quotes": 2500},
"target_memory": "100KB",
"description": "Unit tests",
},
"S": {
"ohlcv": {"symbols": 10, "rows_per_symbol": 1000, "total_rows": 10000},
"tick": {"symbols": 5, "trades": 5000, "quotes": 25000},
"target_memory": "1MB",
"description": "Quick validation",
},
"M": {
"ohlcv": {"symbols": 50, "rows_per_symbol": 2000, "total_rows": 100000},
"tick": {"symbols": 10, "trades": 50000, "quotes": 250000},
"target_memory": "10MB",
"description": "Development",
},
"L": {
"ohlcv": {"symbols": 100, "rows_per_symbol": 10000, "total_rows": 1000000},
"tick": {"symbols": 50, "trades": 500000, "quotes": 2500000},
"target_memory": "100MB",
"description": "Standard benchmark",
},
"XL": {
"ohlcv": {"symbols": 500, "rows_per_symbol": 20000, "total_rows": 10000000},
"tick": {"symbols": 100, "trades": 5000000, "quotes": 25000000},
"target_memory": "1GB",
"description": "Scale testing",
},
"XXL": {
"ohlcv": {"symbols": 1000, "rows_per_symbol": 100000, "total_rows": 100000000},
"tick": {"symbols": 500, "trades": 50000000, "quotes": 250000000},
"target_memory": "10GB",
"description": "Production-scale",
},
}
def get_scale_config(scale: str) -> dict:
"""Get configuration for a scale level.
Args:
scale: Scale name (XS, S, M, L, XL, XXL)
Returns:
Dict with ohlcv and tick configuration
"""
if scale not in SCALE_CONFIGS_NEW:
raise ValueError(f"Unknown scale {scale!r}, expected one of {list(SCALE_CONFIGS_NEW)}")
return SCALE_CONFIGS_NEW[scale]
# Determine active scale
# BENCHMARK_VERBOSE controls whether to print on import (default: False for clean imports)
BENCHMARK_VERBOSE = os.environ.get("BENCHMARK_VERBOSE", "0") == "1"
if BENCHMARK_SCALE and BENCHMARK_SCALE in SCALE_CONFIGS_NEW:
ACTIVE_SCALE = BENCHMARK_SCALE
scale_cfg = get_scale_config(BENCHMARK_SCALE)
N_SYMBOLS = scale_cfg["ohlcv"]["symbols"]
N_ROWS_PER_SYMBOL = scale_cfg["ohlcv"]["rows_per_symbol"]
N_TICKS_TRADES = scale_cfg["tick"]["trades"]
N_TICKS_QUOTES = scale_cfg["tick"]["quotes"]
TIMING_RUNS = CONFIG.get("execution", {}).get("iterations", {}).get(ACTIVE_SCALE, 3)
else:
# Default: S scale for quick iteration
ACTIVE_SCALE = "S"
scale_cfg = get_scale_config("S")
N_SYMBOLS = scale_cfg["ohlcv"]["symbols"]
N_ROWS_PER_SYMBOL = scale_cfg["ohlcv"]["rows_per_symbol"]
N_TICKS_TRADES = scale_cfg["tick"]["trades"]
N_TICKS_QUOTES = scale_cfg["tick"]["quotes"]
TIMING_RUNS = 3
# Database connection configuration (environment variable overrides)
DB_CONFIG = {
"clickhouse": {
"host": os.environ.get("CLICKHOUSE_HOST", "localhost"),
"port": int(os.environ.get("CLICKHOUSE_PORT", "8123")),
},
"questdb": {
"host": os.environ.get("QUESTDB_HOST", "localhost"),
"http_port": int(os.environ.get("QUESTDB_HTTP_PORT", "9000")),
"ilp_port": int(os.environ.get("QUESTDB_ILP_PORT", "9009")),
"pg_port": int(os.environ.get("QUESTDB_PG_PORT", "8812")),
},
"timescaledb": {
"host": os.environ.get("TIMESCALE_HOST", "localhost"),
"port": int(os.environ.get("TIMESCALE_PORT", "5437")),
"user": os.environ.get("TIMESCALE_USER", "postgres"),
"password": os.environ.get("TIMESCALE_PASSWORD", "benchmark"),
"database": os.environ.get("TIMESCALE_DB", "postgres"),
},
"influxdb": {
"host": os.environ.get("INFLUXDB_HOST", "localhost"),
"port": int(os.environ.get("INFLUXDB_PORT", "8086")),
"org": os.environ.get("INFLUXDB_ORG", "benchmark"),
"token": os.environ.get("INFLUXDB_TOKEN", "benchmark-token"),
"bucket": os.environ.get("INFLUXDB_BUCKET", "benchmark"),
},
"postgres": {
"host": os.environ.get("POSTGRES_HOST", "localhost"),
"port": int(os.environ.get("POSTGRES_PORT", "5436")),
"user": os.environ.get("POSTGRES_USER", "postgres"),
"password": os.environ.get("POSTGRES_PASSWORD", "benchmark"),
"database": os.environ.get("POSTGRES_DB", "ml4t"),
},
}
# WAL flush timeout (seconds) - adjustable for slower systems
WAL_FLUSH_TIMEOUT = int(os.environ.get("WAL_FLUSH_TIMEOUT", "3"))
# =============================================================================
# OUTPUT DIRECTORIES
# =============================================================================
# Directory structure:
# .tmp/ - Transient data (gitignored), regenerated each run
# ../output/benchmark/ - Results CSVs for book tables and citation
# ../figures/ - Book figures (numbered: figure_3_N_slug.png)
# Transient benchmark data (synthetic OHLCV, trades, quotes)
TMP_DIR = CHAPTER_DIR / ".tmp"
TMP_DIR.mkdir(exist_ok=True)
BENCHMARK_DIR = TMP_DIR # Alias used by benchmark notebooks
# Working charts (transient, not book figures)
CHARTS_DIR = TMP_DIR / "charts"
CHARTS_DIR.mkdir(exist_ok=True)
# Results for book tables (CSV files for citation in prose)
RESULTS_DIR = CHAPTER_DIR / "output" / "benchmark"
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
# =============================================================================
# DATA CLASSES
# =============================================================================
@dataclass
class BenchmarkResult:
"""Container for benchmark results."""
name: str
operation: str
time_seconds: float
size_bytes: int = 0
rows: int = 0
@property
def rows_per_second(self) -> float:
return self.rows / self.time_seconds if self.time_seconds > 0 else 0
@property
def mb_per_second(self) -> float:
return (self.size_bytes / 1e6) / self.time_seconds if self.time_seconds > 0 else 0
# =============================================================================
# TIMING & VALIDATION
# =============================================================================
def drop_os_caches() -> bool:
"""Drop OS page caches for accurate cold-cache benchmarking.
Requires sudo access. Returns True if successful, False otherwise.
On Linux: sync; echo 3 > /proc/sys/vm/drop_caches
"""
import subprocess
try:
# Sync first to flush dirty pages
subprocess.run(["sync"], check=True, timeout=30)
# Drop caches (requires sudo or appropriate permissions)
result = subprocess.run(
["sudo", "-n", "sh", "-c", "echo 3 > /proc/sys/vm/drop_caches"],
capture_output=True,
timeout=10,
)
return result.returncode == 0
except Exception:
return False
def time_operation(func, n_runs: int = TIMING_RUNS, warmup: bool = True) -> tuple[float, Any]:
"""Time a function with warm-up and percentile tracking.
Args:
func: Function to time
n_runs: Number of timing runs (default: TIMING_RUNS)
warmup: Whether to run once before timing to warm up caches/JIT (default: True)
Returns:
(mean_time, result): Mean execution time and last result
Note: Timing stats (percentiles) are stored in result.timing_stats if available
"""
# Warm-up run (exclude from timing)
if warmup:
from contextlib import suppress
with suppress(Exception): # Warm-up failure is non-critical
func()
# Timing runs
times = []
result = None
for _ in range(n_runs):
start = time.perf_counter()
result = func()
elapsed = time.perf_counter() - start
times.append(elapsed)
# Calculate statistics
times_array = np.array(times)
mean_time = float(np.mean(times_array))
# Store timing stats as metadata (if result supports it)
try:
if hasattr(result, "__dict__"):
result.timing_stats = {
"mean": mean_time,
"std": float(np.std(times_array)),
"min": float(np.min(times_array)),
"max": float(np.max(times_array)),
"p50": float(np.percentile(times_array, 50)),
"p95": float(np.percentile(times_array, 95)),
"p99": float(np.percentile(times_array, 99)),
}
except Exception:
pass # Not all result types support metadata
return mean_time, result
def time_cold_cache(func, drop_caches: bool = True) -> tuple[float, Any]:
"""Time a single cold-cache read operation.
For fair comparison of file formats, drops OS page caches before reading.
This ensures memory-mapped formats (Feather) don't benefit from cached pages.
Args:
func: Function to time (should be a file read operation)
drop_caches: Whether to drop OS caches first (requires sudo)
Returns:
(time, result): Execution time and result
"""
if drop_caches:
cache_dropped = drop_os_caches()
if not cache_dropped:
print("Warning: Could not drop OS caches (requires sudo)")
gc.collect()
start = time.perf_counter()
result = func()
elapsed = time.perf_counter() - start
return elapsed, result
def validate_result(
result: Any, expected_rows: int, operation: str, tolerance: float = 0.1
) -> None:
"""Validate benchmark result has reasonable row count.
Args:
result: Result to validate (DataFrame, list, dict with 'dataset', or None)
expected_rows: Expected number of rows
operation: Operation name for error messages
tolerance: Fraction tolerance (0.1 = 10% deviation acceptable)
Raises:
AssertionError: If row count is unreasonable
"""
if result is None:
return # Skip validation for None results (optional databases)
# Extract row count based on result type
if hasattr(result, "shape"): # DataFrame
actual_rows = result.shape[0]
elif isinstance(result, dict) and "dataset" in result: # QuestDB result
actual_rows = len(result["dataset"])
elif isinstance(result, list):
actual_rows = len(result)
else:
return # Unknown type, skip validation
# Check row count is within tolerance
min_rows = int(expected_rows * (1 - tolerance))
max_rows = int(expected_rows * (1 + tolerance))
if not (min_rows <= actual_rows <= max_rows):
raise AssertionError(
f"{operation}: Expected {expected_rows:,} rows (±{tolerance:.0%}), got {actual_rows:,}"
)
# =============================================================================
# MATERIALIZATION HELPERS
# =============================================================================
def force_materialize_polars(df: pl.DataFrame | pl.LazyFrame) -> pl.DataFrame:
"""Force full materialization of a Polars DataFrame by scanning ALL columns.
Memory-mapped formats (like Feather/Arrow IPC) may return handles without
loading data. This function forces actual data access by touching every column.
Args:
df: Polars DataFrame or LazyFrame
Returns:
Materialized DataFrame with data actually loaded into memory
"""
if isinstance(df, pl.LazyFrame):
df = df.collect()
# Force materialization by touching EVERY column (not just first 3 numeric)
# This ensures all data is actually loaded, not just memory-mapped
exprs = []
for col_name in df.columns:
dtype = df[col_name].dtype
if dtype in (pl.Float64, pl.Float32, pl.Int64, pl.Int32, pl.Int16, pl.Int8):
# Numeric: compute sum
exprs.append(pl.col(col_name).sum().alias(f"{col_name}_sum"))
elif dtype == pl.String:
# String: compute length sum (forces read of all string data)
exprs.append(pl.col(col_name).str.len_bytes().sum().alias(f"{col_name}_len"))
elif dtype in (pl.Datetime, pl.Date):
# Datetime: compute min/max (forces read)
exprs.append(pl.col(col_name).min().alias(f"{col_name}_min"))
else:
# Other types: count non-null (forces read)
exprs.append(pl.col(col_name).count().alias(f"{col_name}_count"))
if exprs:
_ = df.select(exprs).to_dict()
return df
def force_materialize_pandas(df: pd.DataFrame) -> pd.DataFrame:
"""Force full materialization of a pandas DataFrame by scanning ALL columns.
Args:
df: pandas DataFrame
Returns:
Materialized DataFrame with all data accessed
"""
# Force read of ALL columns, not just first 3 numeric
result = {}
# Numeric columns: compute sum
numeric_cols = df.select_dtypes(include=[np.number]).columns
if len(numeric_cols) > 0:
result["numeric_sums"] = df[numeric_cols].sum().to_dict()
# String/object columns: compute total string length
object_cols = df.select_dtypes(include=["object", "string"]).columns
if len(object_cols) > 0:
result["string_lens"] = {col: df[col].astype(str).str.len().sum() for col in object_cols}
# Datetime columns: compute min
datetime_cols = df.select_dtypes(include=["datetime64"]).columns
if len(datetime_cols) > 0:
result["datetime_mins"] = {col: df[col].min() for col in datetime_cols}
# Force evaluation
_ = result
return df
def read_with_materialization(
read_func,
path: Path,
library: str = "polars",
) -> tuple[float, Any]:
"""Time a read operation with forced materialization.
Args:
read_func: Function to read data (e.g., pl.read_parquet, pd.read_csv)
path: Path to file
library: "polars" or "pandas"
Returns:
(time_seconds, result): Tuple of read time and DataFrame
"""
gc.collect()
start = time.perf_counter()
df = read_func(path)
# Force materialization
if library == "polars":
df = force_materialize_polars(df)
else:
df = force_materialize_pandas(df)
elapsed = time.perf_counter() - start
return elapsed, df
# =============================================================================
# MEMORY UTILITIES
# =============================================================================
def estimate_memory_mb(df: pl.DataFrame | pd.DataFrame) -> float:
"""Estimate memory usage of a DataFrame in MB.
Args:
df: Polars or pandas DataFrame
Returns:
Estimated memory in megabytes
"""
if isinstance(df, pl.DataFrame):
return df.estimated_size("mb")
else:
return df.memory_usage(deep=True).sum() / 1_000_000
def get_memory_usage_mb() -> float:
"""Get current process memory usage in MB.
Returns:
Memory usage in megabytes
"""
import psutil
process = psutil.Process()
return process.memory_info().rss / 1_000_000
def run_with_gc(func):
"""Run function with garbage collection before and after.
Args:
func: Function to run
Returns:
Function result
"""
gc.collect()
result = func()
gc.collect()
return result
def save_chart(fig: go.Figure, name: str) -> None:
"""Save chart to HTML file instead of opening browser."""
path = CHARTS_DIR / f"{name}.html"
fig.write_html(str(path), include_plotlyjs="cdn")
print(f"Chart saved: {path}")
# =============================================================================
# DATA GENERATION
# =============================================================================
def generate_ohlcv_data(
n_symbols: int = N_SYMBOLS,
n_rows: int = N_ROWS_PER_SYMBOL,
seed: int = 42,
) -> pl.DataFrame:
"""Generate realistic synthetic OHLCV panel data (fully vectorized).
Creates a panel dataset with multiple symbols and time-series bars.
OHLCV constraints are enforced: H >= max(O,C), L <= min(O,C).
This implementation is fully vectorized using numpy arrays, enabling
generation of 10M+ rows in seconds (required for XL/XXL scales).
Args:
n_symbols: Number of unique symbols
n_rows: Number of rows per symbol
seed: Random seed for reproducibility
Returns:
Polars DataFrame with columns: timestamp, symbol, open, high, low, close, volume, vwap, num_trades
"""
np.random.seed(seed)
total_rows = n_symbols * n_rows
# Generate all timestamps at once (vectorized)
base_time = np.datetime64("2024-01-01T00:00:00", "us")
minute_offsets = np.arange(n_rows, dtype="timedelta64[m]")
single_symbol_times = base_time + minute_offsets
# Tile timestamps for all symbols
timestamps = np.tile(single_symbol_times, n_symbols)
# Generate symbol array (repeat each symbol n_rows times)
symbol_names = np.array([f"SYM_{i:03d}" for i in range(n_symbols)])
symbols = np.repeat(symbol_names, n_rows)
# Generate base prices per symbol, then broadcast to all rows
base_prices = 100 + np.random.randn(n_symbols) * 10 # (n_symbols,)
# Generate returns for all rows at once: (n_symbols, n_rows)
returns = np.random.randn(n_symbols, n_rows) * 0.001
# Cumulative sum along rows axis, then flatten
cumret = np.cumsum(returns, axis=1) # (n_symbols, n_rows)
prices = (base_prices[:, np.newaxis] * np.exp(cumret)).flatten() # (total_rows,)
# Generate intrabar noise for all rows
noise = np.abs(np.random.randn(total_rows)) * 0.002
# Generate OHLC
opens = prices * (1 - noise * 0.5)
closes = prices * (1 + noise * 0.5)
highs_raw = prices * (1 + noise)
lows_raw = prices * (1 - noise)
# Enforce OHLC constraints: H >= max(O,C), L <= min(O,C)
highs = np.maximum(np.maximum(opens, closes), highs_raw)
lows = np.minimum(np.minimum(opens, closes), lows_raw)
# Generate volume (lognormal distribution)
volumes = np.exp(np.random.randn(total_rows) * 0.5 + 10).astype(np.int64)
# VWAP approximation: typical price (H+L+C)/3
vwap = (highs + lows + closes) / 3
# Number of trades (proportional to volume with noise)
num_trades = (volumes / 1000 + np.random.randint(10, 100, total_rows)).astype(np.int32)
# Build DataFrame directly from numpy arrays (zero-copy where possible)
df = pl.DataFrame(
{
"timestamp": timestamps,
"symbol": symbols,
"open": opens,
"high": highs,
"low": lows,
"close": closes,
"volume": volumes,
"vwap": vwap,
"num_trades": num_trades,
},
schema={
"timestamp": pl.Datetime("us"),
"symbol": pl.String,
"open": pl.Float64,
"high": pl.Float64,
"low": pl.Float64,
"close": pl.Float64,
"volume": pl.Int64,
"vwap": pl.Float64,
"num_trades": pl.Int32,
},
)
return df
def generate_tick_data(
n_trades: int = N_TICKS_TRADES,
n_quotes: int = N_TICKS_QUOTES,
n_symbols: int = N_SYMBOLS,
seed: int = 42,
) -> tuple[pl.DataFrame, pl.DataFrame]:
"""Generate realistic synthetic tick data (fully vectorized).
Trades are correlated with quotes (occur near bid/ask) for realistic simulation.
This implementation is fully vectorized using numpy arrays, enabling
generation of millions of ticks in seconds (required for XL/XXL scales).
Args:
n_trades: Total number of trade ticks
n_quotes: Total number of quote ticks
n_symbols: Number of unique symbols
seed: Random seed for reproducibility
Returns:
(trades_df, quotes_df): Tuple of Polars DataFrames
"""
np.random.seed(seed)
symbol_names = np.array([f"SYM_{i:03d}" for i in range(n_symbols)])
base_time = np.datetime64("2024-01-01T09:30:00", "us")
# =========================================================================
# QUOTES: Fully vectorized generation
# =========================================================================
# Assign quotes to symbols (weighted: some symbols more active)
# Use exponential weights so first symbols get more quotes
weights = np.exp(-np.arange(n_symbols) * 0.1)
weights = weights / weights.sum()
quote_symbol_indices = np.random.choice(n_symbols, size=n_quotes, p=weights)
quote_symbols = symbol_names[quote_symbol_indices]
# Generate timestamps (100 microseconds apart)
quote_timestamps = base_time + (np.arange(n_quotes) * 100).astype("timedelta64[us]")
# Generate mid prices per symbol using random walk
# Strategy: for each symbol, generate cumulative random walk, then gather
quotes_per_symbol = np.bincount(quote_symbol_indices, minlength=n_symbols)
max_quotes_per_symbol = quotes_per_symbol.max()
# Generate random walk increments for all symbols at once
base_mids = 150 + np.random.randn(n_symbols) * 20 # Starting mid price per symbol
walk_increments = np.random.randn(n_symbols, max_quotes_per_symbol) * 0.01
walk_paths = base_mids[:, np.newaxis] + np.cumsum(walk_increments, axis=1)
# For each quote, look up the appropriate price from that symbol's walk
# Track position within each symbol's sequence
symbol_counters = np.zeros(n_symbols, dtype=np.int64)
mid_prices = np.empty(n_quotes, dtype=np.float64)
# Vectorized lookup using cumulative counts
for sym_idx in range(n_symbols):
mask = quote_symbol_indices == sym_idx
count = mask.sum()
if count > 0:
mid_prices[mask] = walk_paths[sym_idx, :count]
# Generate spreads (0.01-0.05% of mid)
spread_pct = 0.0001 + np.abs(np.random.randn(n_quotes)) * 0.0002
spreads = mid_prices * spread_pct
bids = mid_prices - spreads / 2
asks = mid_prices + spreads / 2
# Generate sizes (lognormal)
bid_sizes = np.exp(np.random.randn(n_quotes) * 0.3 + 5).astype(np.int64)
ask_sizes = np.exp(np.random.randn(n_quotes) * 0.3 + 5).astype(np.int64)
quotes_df = pl.DataFrame(
{
"timestamp": quote_timestamps,
"symbol": quote_symbols,
"bid": bids,
"ask": asks,
"bid_size": bid_sizes,
"ask_size": ask_sizes,
},
schema={
"timestamp": pl.Datetime("us"),
"symbol": pl.String,
"bid": pl.Float64,
"ask": pl.Float64,
"bid_size": pl.Int64,
"ask_size": pl.Int64,
},
)
# =========================================================================
# TRADES: Fully vectorized generation
# =========================================================================
# Assign trades to symbols (same distribution as quotes)
trade_symbol_indices = np.random.choice(n_symbols, size=n_trades, p=weights)
trade_symbols = symbol_names[trade_symbol_indices]
# Generate timestamps (100 microseconds apart, offset by 50us from quotes)
trade_timestamps = base_time + (np.arange(n_trades) * 100 + 50).astype("timedelta64[us]")
# Generate sizes
trade_sizes = np.exp(np.random.randn(n_trades) * 0.3 + 5).astype(np.int64)
# Build preliminary trades DataFrame
trades_prelim = pl.DataFrame(
{
"timestamp": trade_timestamps,
"symbol": trade_symbols,
"price": np.zeros(n_trades), # Placeholder
"size": trade_sizes,
},
schema={
"timestamp": pl.Datetime("us"),
"symbol": pl.String,
"price": pl.Float64,
"size": pl.Int64,
},
)
# Use ASOF join to match each trade with most recent quote
trades_with_quotes = trades_prelim.join_asof(
quotes_df.sort(["symbol", "timestamp"]),
on="timestamp",
by="symbol",
strategy="backward",
)
# Calculate trade prices: 25% at ask, 25% at bid, 50% at mid
trade_sides = np.random.rand(n_trades)
bid_arr = trades_with_quotes["bid"].to_numpy()
ask_arr = trades_with_quotes["ask"].to_numpy()
trade_prices = np.where(
trade_sides < 0.25,
ask_arr, # Buy at ask (25%)
np.where(
trade_sides < 0.5,
bid_arr, # Sell at bid (25%)
(bid_arr + ask_arr) / 2, # Mid (50%)
),
)
# Handle NaN prices (trades before first quote)
trade_prices = np.where(np.isnan(trade_prices), 150.0, trade_prices)
# Create final trades DataFrame
trades_df = pl.DataFrame(
{
"timestamp": trades_with_quotes["timestamp"],
"symbol": trades_with_quotes["symbol"],
"price": trade_prices,
"size": trades_with_quotes["size"],
}
)
return trades_df, quotes_df
# =============================================================================
# RESULTS STORAGE
# =============================================================================
# Map benchmark_type to clean filename prefixes
BENCHMARK_TYPE_TO_FILENAME = {
"formats": "file_formats",
"embedded": "embedded_dbs",
"pandas_polars": "pandas_polars",
"servers": "server_dbs",
# Legacy mappings
"local": "local",
"server": "server",
}
def save_benchmark_results(
results: list[BenchmarkResult], benchmark_type: str, scale: str | None = None
) -> Path:
"""Save benchmark results to CSV for book publication.
Results are saved to output/benchmark/ with clean filenames for citation in prose.
Example: file_formats_l.csv, embedded_dbs_xl.csv
Args:
results: List of BenchmarkResult objects
benchmark_type: "formats", "embedded", "pandas_polars", "servers"
scale: Scale level (S, L, XL, etc.) or None for auto-detect
Returns:
Path to saved CSV file
"""
if scale is None:
scale = ACTIVE_SCALE
# Clean filename prefix
filename_prefix = BENCHMARK_TYPE_TO_FILENAME.get(benchmark_type, benchmark_type)
# Create results DataFrame
df = pl.DataFrame(
[
{
"benchmark_type": benchmark_type,
"scale": scale,
"technology": r.name,
"operation": r.operation,
"time_seconds": r.time_seconds,
"size_mb": r.size_bytes / 1_000_000 if r.size_bytes else None,
"rows": r.rows if r.rows else None,
"rows_per_second": r.rows_per_second if r.rows else None,
"mb_per_second": r.mb_per_second if r.size_bytes else None,
"timestamp": pd.Timestamp.now().isoformat(),
"n_symbols": N_SYMBOLS,
"n_rows_per_symbol": N_ROWS_PER_SYMBOL,
}
for r in results
]
)
# Save as CSV (for book tables and prose citation)
csv_path = RESULTS_DIR / f"{filename_prefix}_{scale.lower()}.csv"
df.write_csv(csv_path)
print(f"\n📁 Results saved to: {csv_path}")
return csv_path
# =============================================================================
# PRINT CONFIGURATION (only when BENCHMARK_VERBOSE=1 or running as main)
# =============================================================================
def print_config() -> None:
"""Print current benchmark configuration."""
print(f"📊 Scale: {ACTIVE_SCALE} ({scale_cfg['target_memory']} target)")
print(f" OHLCV: {N_SYMBOLS} symbols × {N_ROWS_PER_SYMBOL:,} rows/symbol")
print(f" Ticks: {N_TICKS_TRADES:,} trades, {N_TICKS_QUOTES:,} quotes")
print(f" Timing runs: {TIMING_RUNS}")
# Only print on import if explicitly requested
if BENCHMARK_VERBOSE:
print_config()
+794
View File
@@ -0,0 +1,794 @@
"""ML4T Visualization Style.
Canonical color palette, matplotlib rcParams, Plotly template, and chart
helpers for all book visualizations.
## Automatic Styling (Matplotlib)
ML4T style is applied automatically when running from repo root.
The ``matplotlibrc`` file in the repo root is loaded by matplotlib
before any other config. No imports or function calls needed.
## Explicit Color References
from utils.style import COLORS
ax.plot(x, y, color=COLORS['blue'])
ax.axhline(0, color=COLORS['amber'], linestyle='--')
## Plotly
import plotly.io as pio
pio.templates.default = "ml4t" # Auto-registered on import
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Literal
import matplotlib.pyplot as plt
import numpy as np
if TYPE_CHECKING:
from matplotlib.axes import Axes
# =============================================================================
# ML4T COLOR PALETTE
# =============================================================================
# Aligned with ml4t.io website identity
COLORS = {
# Primary blues (core identity)
"blue": "#0a1628", # Deep blue - primary emphasis, main data
"blue_light": "#152238", # Lighter blue - secondary elements
"slate": "#1a2d4a", # Mid-blue - tertiary, gridlines
# Silver tones (backgrounds, text)
"silver": "#F8F8F6", # Light silver - text on dark, highlights
"silver_muted": "#e8e8e6", # Muted silver - borders, subtle elements
# Warm accents (highlights, emphasis)
"amber": "#D4A84B", # Warm amber - CTAs, important highlights
"amber_light": "#E4B85B", # Lighter amber - hover states
"copper": "#C87533", # Copper - secondary accent
# Semantic (for data meaning)
"positive": "#10b981", # Success green - profits, gains
"negative": "#ef4444", # Error red - losses (use sparingly!)
"neutral": "#334155", # Slate gray - neutral elements
# Backgrounds
"bg_light": "#FAFAF9", # Warm off-white (light mode)
"bg_dark": "#0a1628", # Deep blue (dark mode)
}
# Grayscale equivalents for print
GRAYSCALE = {
"blue": 0.10, # ~10% gray (very dark)
"slate": 0.25, # ~25% gray
"amber": 0.65, # ~65% gray
"silver": 0.97, # ~97% gray (nearly white)
}
# =============================================================================
# MATPLOTLIB STYLE CONFIGURATIONS
# =============================================================================
_BASE_STYLE = {
# Figure
"figure.dpi": 100,
"figure.figsize": (10, 6),
"savefig.dpi": 150,
"savefig.bbox": "tight",
"savefig.pad_inches": 0.1,
# Axes
"axes.spines.top": False,
"axes.spines.right": False,
"axes.titlesize": 14,
"axes.titleweight": "semibold",
"axes.titlepad": 12,
"axes.labelsize": 11,
"axes.labelpad": 8,
# Grid
"axes.grid": True,
"grid.alpha": 0.4,
"grid.linewidth": 0.5,
# Ticks
"xtick.labelsize": 10,
"ytick.labelsize": 10,
"xtick.major.pad": 4,
"ytick.major.pad": 4,
# Lines
"lines.linewidth": 2,
"lines.markersize": 6,
# Legend
"legend.frameon": False,
"legend.fontsize": 10,
# Font (prefer DM Sans, fallback to system sans)
"font.family": ["sans-serif"],
"font.sans-serif": ["DM Sans", "DejaVu Sans", "Helvetica", "Arial"],
"font.size": 10,
}
ML4T_LIGHT_STYLE = {
**_BASE_STYLE,
"figure.facecolor": COLORS["bg_light"],
"axes.facecolor": "white",
"axes.edgecolor": COLORS["silver_muted"],
"axes.labelcolor": COLORS["neutral"],
"axes.titlecolor": COLORS["blue"],
"xtick.color": COLORS["neutral"],
"ytick.color": COLORS["neutral"],
"grid.color": COLORS["silver_muted"],
"text.color": COLORS["neutral"],
}
ML4T_DARK_STYLE = {
**_BASE_STYLE,
"figure.facecolor": COLORS["bg_dark"],
"axes.facecolor": COLORS["blue_light"],
"axes.edgecolor": COLORS["slate"],
"axes.labelcolor": COLORS["silver"],
"axes.titlecolor": COLORS["silver"],
"xtick.color": COLORS["silver_muted"],
"ytick.color": COLORS["silver_muted"],
"grid.color": COLORS["slate"],
"text.color": COLORS["silver"],
}
# =============================================================================
# STYLE APPLICATION
# =============================================================================
def apply_ml4t_style(mode: Literal["light", "dark"] = "light") -> None:
"""Apply ML4T style to both Matplotlib and Plotly.
Args:
mode: 'light' (default) for white backgrounds, 'dark' for blue backgrounds
"""
if mode == "light":
plt.rcParams.update(ML4T_LIGHT_STYLE)
else:
plt.rcParams.update(ML4T_DARK_STYLE)
# Apply Plotly template if available
import contextlib
with contextlib.suppress(ImportError):
_register_plotly_template()
# =============================================================================
# PALETTE HELPERS
# =============================================================================
def ml4t_palette(n: int = 5, categorical: bool = False) -> list[str]:
"""Return colors from the ML4T palette.
Args:
n: Number of colors to return (max 5)
categorical: If True, returns distinct colors for categories.
If False, returns blue gradient for sequential data.
Returns:
List of hex color strings
"""
if categorical:
colors = [
COLORS["blue"],
COLORS["amber"],
COLORS["slate"],
COLORS["copper"],
COLORS["silver_muted"],
]
else:
colors = [
COLORS["blue"],
COLORS["slate"],
COLORS["blue_light"],
COLORS["silver_muted"],
COLORS["silver"],
]
return colors[:n]
def ml4t_diverging() -> list[str]:
"""Return diverging palette (negative to positive).
Use for data with meaningful zero point (e.g., returns, correlations).
Returns:
List of 3 colors: [negative, neutral, positive]
"""
return [COLORS["negative"], COLORS["silver_muted"], COLORS["positive"]]
# =============================================================================
# CHART HELPERS
# =============================================================================
def annotate_peak(ax: Axes, x: object, y: object, label: str, offset: tuple = (10, 10)) -> None:
"""Annotate a peak/trough with ML4T styling.
Args:
ax: matplotlib axes
x, y: Coordinates of the point
label: Text label
offset: (x, y) offset in points
"""
ax.annotate(
label,
xy=(x, y),
xytext=offset,
textcoords="offset points",
fontsize=9,
color=COLORS["neutral"],
arrowprops={
"arrowstyle": "->",
"color": COLORS["amber"],
"connectionstyle": "arc3,rad=0.2",
},
bbox={
"boxstyle": "round,pad=0.3",
"facecolor": COLORS["silver"],
"edgecolor": COLORS["silver_muted"],
},
)
def add_regime_shading(ax: Axes, periods: list[tuple], label: str = "Crisis") -> None:
"""Add regime shading to a time series plot.
Args:
ax: matplotlib axes
periods: List of (start, end) tuples defining regime periods
label: Label for legend
"""
for i, (start, end) in enumerate(periods):
ax.axvspan(
start,
end,
alpha=0.15,
color=COLORS["amber"],
label=label if i == 0 else None,
)
def format_pct_axis(ax: Axes, axis: Literal["x", "y", "both"] = "y") -> None:
"""Format axis as percentage with ML4T styling.
Args:
ax: matplotlib axes
axis: Which axis to format ('x', 'y', or 'both')
"""
from matplotlib.ticker import PercentFormatter
formatter = PercentFormatter(xmax=1, decimals=0)
if axis in ("y", "both"):
ax.yaxis.set_major_formatter(formatter)
if axis in ("x", "both"):
ax.xaxis.set_major_formatter(formatter)
# =============================================================================
# PLOTLY TEMPLATE (optional — only used if Plotly is installed)
# =============================================================================
def _register_plotly_template() -> None:
"""Register the ML4T template with Plotly."""
import plotly.graph_objects as go
import plotly.io as pio
template = go.layout.Template(
layout=go.Layout(
font=dict(
family="DM Sans, DejaVu Sans, sans-serif",
size=11,
color=COLORS["neutral"],
),
paper_bgcolor=COLORS["bg_light"],
plot_bgcolor="white",
title=dict(
font=dict(size=14, color=COLORS["blue"]),
x=0.5,
xanchor="center",
),
xaxis=dict(
gridcolor=COLORS["silver_muted"],
linecolor=COLORS["silver_muted"],
tickfont=dict(size=10),
title=dict(font=dict(size=11)),
showgrid=True,
gridwidth=0.5,
),
yaxis=dict(
gridcolor=COLORS["silver_muted"],
linecolor=COLORS["silver_muted"],
tickfont=dict(size=10),
title=dict(font=dict(size=11)),
showgrid=True,
gridwidth=0.5,
),
colorway=[
COLORS["blue"],
COLORS["amber"],
COLORS["slate"],
COLORS["copper"],
COLORS["positive"],
COLORS["negative"],
],
legend=dict(
bgcolor="rgba(255,255,255,0.8)",
bordercolor=COLORS["silver_muted"],
borderwidth=1,
font=dict(size=10),
),
hoverlabel=dict(
bgcolor="white",
font_size=11,
font_family="DM Sans, DejaVu Sans, sans-serif",
),
)
)
pio.templates["ml4t"] = template
# Auto-register Plotly template on import
_register_plotly_template()
HAS_PLOTLY = True
# =============================================================================
# PUBLICATION (BOOK) STYLE — MIT Press, dual-track (grayscale print + color web)
# =============================================================================
# Used by `~/ml4t/book/<ch>/figures/scripts/generate_figure_*.py`.
# Notebooks may also call `apply_book_style()` to render the same look.
#
# Two tracks, same data, same script:
# - "print": grayscale-first, semantic fills, varied linestyles. Top-level PNG.
# - "color": ML4T palette overlay. `color/` subdir.
# The grayscale track is the source of truth: data must be legible without color.
# Semantic grayscale fills — vocabulary mirrors `visualization-style/SKILL.md`.
# Use these by ROLE, not by hex. The print track resolves them to grays;
# the color track resolves them to the ML4T palette.
GRAY_FILLS = {
"primary": "#000000", # titles, lead data series, key emphasis
"secondary": "#808080", # second series — widened from #404040 for print contrast
"tertiary": "#c8c8c8", # third series, supporting elements
"quaternary": "#e8e8e8", # fourth series only — keep grayscale separable
"muted": "#a8a8a8", # de-emphasized, comparison baselines
"border": "#666666", # connectors, axis lines (data side)
"highlight": "#d9d9d9", # ~85% white — emphasis band fill
"container": "#f2f2f2", # ~95% white — phase container fill
"foundation": "#b3b3b3", # ~70% white — foundation layer fill
"canvas": "#ffffff", # page background
}
COLOR_FILLS = {
"primary": COLORS["blue"], # #0a1628 navy — primary series
"secondary": COLORS["amber"], # #D4A84B amber — secondary series
"tertiary": COLORS["copper"], # #C87533 copper — tertiary (kept distinct from navy)
"quaternary": COLORS["slate"], # #1a2d4a mid-blue — fourth series only
"muted": COLORS["silver_muted"], # #e8e8e6
"border": COLORS["neutral"], # #334155
"highlight": COLORS["amber_light"],
"container": COLORS["bg_light"],
"foundation": COLORS["silver"],
"canvas": "#ffffff",
}
# Categorical cyclers for `axes.prop_cycle`. The print track pairs GRAY_CYCLER
# with LINESTYLE_CYCLER so a B&W readout stays legible; the color track relies
# on hue alone (no linestyle pairing — see apply_book_style). Color order
# prioritizes perceptual separation for the first 4 entries (most figures use
# ≤4 series); slate is positioned last because it reads as a second navy next
# to blue. GRAY_CYCLER mirrors the GRAY_FILLS weight order (secondary widened
# to #808080 for print contrast) while keeping every entry dark enough to read
# as a line on white.
COLOR_CYCLER = [
COLORS["blue"], # navy — primary
COLORS["amber"], # gold — secondary
COLORS["copper"], # orange — tertiary
COLORS["positive"], # green — fourth
COLORS["negative"], # red — fifth (semantic, use sparingly)
COLORS["slate"], # navy — sixth (only when ≥6 series; reads close to blue)
]
GRAY_CYCLER = ["#000000", "#808080", "#404040", "#a8a8a8", "#666666", "#c8c8c8"]
LINESTYLE_CYCLER = ["-", "--", ":", "-.", "-", "--"]
MARKER_CYCLER = ["o", "s", "^", "D", "v", "P"]
# =============================================================================
# CANONICAL FIGURE SIZES (Packt embed width = 5.833")
# =============================================================================
# Width is fixed at 5.833" — the typeset width Packt uses in the manuscript
# template. Heights are picked per layout so panels render at proportions
# that don't dominate page vertical space. Use these in generate scripts;
# do NOT introduce ad-hoc figsize tuples per figure.
PAGE_WIDTH = 5.833 # Packt typeset embed width in inches
FIGSIZE = {
"single_wide": (PAGE_WIDTH, 2.6), # short time series, comparisons
"single": (PAGE_WIDTH, 3.4), # default single panel (~1.7:1)
"single_tall": (PAGE_WIDTH, 4.0), # detail-heavy single panel
"dual_h": (PAGE_WIDTH, 2.6), # two side-by-side panels
"dual_h_tall": (PAGE_WIDTH, 3.2), # two side-by-side, taller panels
"dual_v": (PAGE_WIDTH, 5.0), # two stacked panels
"triple_h": (PAGE_WIDTH, 2.2), # three side-by-side panels, short
"triple_h_tall": (PAGE_WIDTH, 3.0), # three side-by-side, detail
"grid_2x2": (PAGE_WIDTH, 4.0), # 2 rows × 2 cols, simple axes
"grid_2x3": (PAGE_WIDTH, 3.5), # 2 rows × 3 cols
"grid_3x2": (PAGE_WIDTH, 5.5), # 3 rows × 2 cols (square-ish grid)
"dashboard_2x2": (PAGE_WIDTH, 5.5), # 2×2 with date axes / rotated labels
"dashboard_2x3": (PAGE_WIDTH, 4.5), # 2×3 with date axes / rotated labels
}
_BOOK_BASE_STYLE = {
# Kept in sync with matplotlibrc at repo root. The auto-applied
# matplotlibrc covers all default runs; this dict is the explicit-apply
# override for book-figure scripts that swap between print and color
# tracks via ``apply_book_style()``.
"figure.dpi": 100,
"figure.figsize": FIGSIZE["single"],
"figure.facecolor": COLORS["bg_light"],
"figure.constrained_layout.use": True,
"savefig.dpi": 300,
"savefig.bbox": "tight",
"savefig.pad_inches": 0.05,
"savefig.facecolor": COLORS["bg_light"],
"axes.facecolor": COLORS["bg_light"],
"axes.spines.top": False,
"axes.spines.right": False,
"axes.titlesize": 10,
"axes.titleweight": "normal",
"axes.titlelocation": "left",
"axes.titlepad": 6,
"axes.labelsize": 9,
"axes.labelpad": 4,
"axes.linewidth": 0.75,
"axes.grid": False,
"axes.axisbelow": True,
"grid.linewidth": 0.5,
"grid.alpha": 0.6,
"grid.linestyle": "--",
"xtick.labelsize": 8,
"ytick.labelsize": 8,
"xtick.major.size": 3,
"ytick.major.size": 3,
"xtick.major.width": 0.6,
"ytick.major.width": 0.6,
"xtick.direction": "out",
"ytick.direction": "out",
"lines.linewidth": 1.4,
"lines.markersize": 4,
"lines.markeredgewidth": 0,
"legend.frameon": False,
"legend.fontsize": 8,
"legend.handlelength": 2.0,
"font.family": ["sans-serif"],
"font.sans-serif": ["Source Sans 3", "DejaVu Sans", "Helvetica", "Arial"],
"font.size": 9,
"image.cmap": "cividis",
}
def _cycler(colors: list[str], linestyles: list[str] | None = None):
"""Build a prop_cycle from colors + optional linestyles. Local import keeps
the module top-level cheap."""
from cycler import cycler as cy
cyc = cy(color=colors)
if linestyles is not None:
cyc = cyc + cy(linestyle=linestyles[: len(colors)])
return cyc
BOOK_PRINT_STYLE = {
# PRINT track is for the printed book — on white paper, so revert
# the warm-cream backgrounds back to plain white.
**_BOOK_BASE_STYLE,
"figure.facecolor": "white",
"savefig.facecolor": "white",
"axes.facecolor": "white",
"axes.edgecolor": "#333333",
"axes.labelcolor": "#000000",
"axes.titlecolor": "#000000",
"xtick.color": "#000000",
"ytick.color": "#000000",
"grid.color": "#cccccc",
"text.color": "#000000",
}
BOOK_COLOR_STYLE = {
# COLOR track is for web/README/Google Drive — matches the website's
# warm-cream bg_light surface.
**_BOOK_BASE_STYLE,
"axes.edgecolor": COLORS["neutral"],
"axes.labelcolor": COLORS["neutral"],
"axes.titlecolor": COLORS["neutral"],
"xtick.color": COLORS["neutral"],
"ytick.color": COLORS["neutral"],
"grid.color": COLORS["silver_muted"],
"text.color": COLORS["neutral"],
}
def apply_book_style(mode: Literal["print", "color"] = "print") -> None:
"""Set rcParams for a book-figure generation script.
Call once at script start (or before each render in a dual-track loop).
Resolves the prop_cycle to grayscale (with linestyle variation) for
``print`` and to the ML4T color palette for ``color``.
"""
style = BOOK_PRINT_STYLE if mode == "print" else BOOK_COLOR_STYLE
plt.rcParams.update(style)
if mode == "print":
plt.rcParams["axes.prop_cycle"] = _cycler(GRAY_CYCLER, LINESTYLE_CYCLER)
else:
plt.rcParams["axes.prop_cycle"] = _cycler(COLOR_CYCLER)
def save_dual(
make_fig,
output_basename: str,
output_dir: str | Path,
dpi: int = 300,
) -> tuple[Path, Path]:
"""Render and save both tracks of a publication figure.
``make_fig(palette, mode)`` is called twice — once with ``GRAY_FILLS`` /
``"print"`` and once with ``COLOR_FILLS`` / ``"color"``. The print PNG
lands at ``output_dir/{basename}.png`` (top-level grayscale default).
The color PNG lands at ``output_dir/color/{basename}_color.png``.
Args:
make_fig: Callable ``(palette: dict, mode: str) -> matplotlib.Figure``.
Must build the figure from scratch each call — the caller closes it.
output_basename: e.g. ``"figure_2_2_survivorship_bias"``. No extension.
output_dir: chapter ``figures/`` directory.
dpi: PNG resolution (default 300).
Returns:
(print_path, color_path) — both absolute.
"""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
color_dir = output_dir / "color"
color_dir.mkdir(parents=True, exist_ok=True)
# Print track first — that's the canonical artifact.
apply_book_style("print")
fig = make_fig(GRAY_FILLS, "print")
print_path = output_dir / f"{output_basename}.png"
fig.savefig(print_path, dpi=dpi, bbox_inches="tight", facecolor="white")
plt.close(fig)
# Color track.
apply_book_style("color")
fig = make_fig(COLOR_FILLS, "color")
color_path = color_dir / f"{output_basename}_color.png"
fig.savefig(color_path, dpi=dpi, bbox_inches="tight", facecolor="white")
plt.close(fig)
return print_path, color_path
# =============================================================================
# BOOK-SPECIFIC FUNCTIONS (LEGACY)
# =============================================================================
# DEPRECATED: Style is now applied automatically via matplotlibrc in repo root.
ML4T_STYLE = Path(__file__).parent.parent / "matplotlibrc"
def save_figure(
fig,
name: str,
chapter: str | None = None,
formats: list[str] | None = None,
dpi: int = 150,
) -> None:
"""Save figure with ML4T conventions.
Args:
fig: matplotlib figure object
name: Base filename (without extension)
chapter: Optional chapter directory (e.g., "06_alpha_factor_engineering")
formats: List of formats to save (default: ['png', 'pdf'])
dpi: Resolution for raster formats (default: 150)
"""
formats = formats or ["png", "pdf"]
if chapter:
repo_root = Path(__file__).parent.parent
output_dir = repo_root / chapter / "visualizations"
else:
output_dir = Path(".")
output_dir.mkdir(parents=True, exist_ok=True)
for fmt in formats:
output_path = output_dir / f"{name}.{fmt}"
fig.savefig(output_path, format=fmt, dpi=dpi, bbox_inches="tight")
print(f"Saved: {output_path}")
def plot_fidelity_comparison(
real_data: np.ndarray,
synthetic_data: np.ndarray,
title: str = "Real vs Synthetic Distribution",
n_samples: int = 1000,
figsize: tuple = (12, 5),
flatten_method: str = "mean",
random_state: int = 42,
) -> plt.Figure:
"""Create standardized fidelity comparison plot using PCA and t-SNE.
Designed for grayscale compatibility:
- Real data: dark circles (filled)
- Synthetic data: amber X markers (open)
Args:
real_data: Real sequences. Shape can be:
- (n_samples, seq_len, n_features): 3D time series
- (n_samples, n_features): 2D tabular
synthetic_data: Synthetic sequences, same shape as real_data
title: Plot title
n_samples: Number of samples to visualize (subsampled if larger)
figsize: Figure size (width, height)
flatten_method: How to flatten 3D data to 2D:
- "mean": Average across time dimension (default)
- "last": Use last timestep only
- "flatten": Concatenate all timesteps (high-dim)
random_state: Random seed for reproducibility
Returns:
matplotlib Figure object
"""
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
np.random.seed(random_state)
# Handle 3D (time series) vs 2D (tabular) data
if real_data.ndim == 3:
if flatten_method == "mean":
real_flat = real_data.mean(axis=1)
synth_flat = synthetic_data.mean(axis=1)
elif flatten_method == "last":
real_flat = real_data[:, -1, :]
synth_flat = synthetic_data[:, -1, :]
elif flatten_method == "flatten":
real_flat = real_data.reshape(real_data.shape[0], -1)
synth_flat = synthetic_data.reshape(synthetic_data.shape[0], -1)
else:
raise ValueError(f"Unknown flatten_method: {flatten_method}")
else:
real_flat = real_data
synth_flat = synthetic_data
# Subsample for visualization
n_viz = min(n_samples, len(real_flat), len(synth_flat))
idx_real = np.random.choice(len(real_flat), n_viz, replace=False)
idx_synth = np.random.choice(len(synth_flat), n_viz, replace=False)
real_sample = real_flat[idx_real]
synth_sample = synth_flat[idx_synth]
# PCA - fit on real, transform both
n_features = real_sample.shape[1] if real_sample.ndim > 1 else 1
n_pca = min(2, n_features, n_viz)
pca = PCA(n_components=n_pca)
pca.fit(real_sample)
real_pca = pca.transform(real_sample)
synth_pca = pca.transform(synth_sample)
# t-SNE - fit jointly for proper comparison
combined = np.vstack([real_sample, synth_sample])
perplexity = min(40, max(2, n_viz // 4))
n_tsne = min(2, n_features)
tsne = TSNE(
n_components=n_tsne, perplexity=perplexity, max_iter=1000, random_state=random_state
)
combined_tsne = tsne.fit_transform(combined)
real_tsne = combined_tsne[:n_viz]
synth_tsne = combined_tsne[n_viz:]
# Create figure with aligned axes
fig, axes = plt.subplots(1, 2, figsize=figsize)
# Style constants for grayscale compatibility
real_color = COLORS["blue"]
synth_color = COLORS["amber"]
marker_size = 25
alpha = 0.6
# PCA plot (handle 1D case when n_features < 2)
pca_y_real = real_pca[:, 1] if n_pca >= 2 else np.zeros(len(real_pca))
pca_y_synth = synth_pca[:, 1] if n_pca >= 2 else np.zeros(len(synth_pca))
axes[0].scatter(
real_pca[:, 0],
pca_y_real,
c=real_color,
marker="o",
s=marker_size,
alpha=alpha,
label="Real",
edgecolors="none",
)
axes[0].scatter(
synth_pca[:, 0],
pca_y_synth,
c=synth_color,
marker="x",
s=marker_size,
alpha=alpha,
label="Synthetic",
linewidths=1.5,
)
axes[0].set_xlabel("PC1")
axes[0].set_ylabel("PC2" if n_pca >= 2 else "")
axes[0].set_title("PCA Projection")
axes[0].legend(loc="upper right", framealpha=0.9)
# t-SNE plot (handle 1D case when n_features < 2)
tsne_y_real = real_tsne[:, 1] if n_tsne >= 2 else np.zeros(len(real_tsne))
tsne_y_synth = synth_tsne[:, 1] if n_tsne >= 2 else np.zeros(len(synth_tsne))
axes[1].scatter(
real_tsne[:, 0],
tsne_y_real,
c=real_color,
marker="o",
s=marker_size,
alpha=alpha,
label="Real",
edgecolors="none",
)
axes[1].scatter(
synth_tsne[:, 0],
tsne_y_synth,
c=synth_color,
marker="x",
s=marker_size,
alpha=alpha,
label="Synthetic",
linewidths=1.5,
)
axes[1].set_xlabel("t-SNE 1")
axes[1].set_ylabel("t-SNE 2" if n_tsne >= 2 else "")
axes[1].set_title("t-SNE Projection")
axes[1].legend(loc="upper right", framealpha=0.9)
fig.suptitle(title, fontsize=14, fontweight="semibold", y=1.02)
plt.tight_layout()
return fig
# =============================================================================
# MODULE EXPORTS
# =============================================================================
__all__ = [
# Palette
"COLORS",
"GRAYSCALE",
# Matplotlib styles
"ML4T_LIGHT_STYLE",
"ML4T_DARK_STYLE",
# Style application
"apply_ml4t_style",
# Palette helpers
"ml4t_palette",
"ml4t_diverging",
# Chart helpers
"annotate_peak",
"add_regime_shading",
"format_pct_axis",
# Book-specific
"ML4T_STYLE",
"HAS_PLOTLY",
"save_figure",
"plot_fidelity_comparison",
]