2794 lines
157 KiB
Plaintext
2794 lines
157 KiB
Plaintext
{
|
||
"cells": [
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "eff49e2f",
|
||
"metadata": {
|
||
"papermill": {
|
||
"duration": 0.005229,
|
||
"end_time": "2026-06-13T03:12:38.571834+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:38.566605+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"# Preprocessing Pipeline\n",
|
||
"\n",
|
||
"**Docker image**: `ml4t`\n",
|
||
"\n",
|
||
"**Chapter 7: Defining the Learning Task**\n",
|
||
"**Section Reference**: 7.1 - Data Preprocessing and Encodings\n",
|
||
"\n",
|
||
"## Purpose\n",
|
||
"\n",
|
||
"This notebook implements **hands-on cleaning** for datasets that need it, plus\n",
|
||
"demonstrates **split-aware preprocessing** mechanics. The key teaching artifact\n",
|
||
"is the `SplitAwarePreprocessor` class that prevents lookahead bias.\n",
|
||
"\n",
|
||
"## Learning Objectives\n",
|
||
"\n",
|
||
"1. Apply domain filters, spike detection, and winsorization\n",
|
||
"2. Understand why preprocessing must be fit on training data only\n",
|
||
"3. Implement a reusable `SplitAwarePreprocessor` class\n",
|
||
"4. Clean US Equities and ETF datasets with audit trails\n",
|
||
"5. Perform cross-dataset alignment (different frequencies)\n",
|
||
"\n",
|
||
"## Book Reference\n",
|
||
"\n",
|
||
"Section 7.1 emphasizes that **preprocessing choices affect model validity**.\n",
|
||
"Fitting scalers or encoders on full data introduces lookahead bias.\n",
|
||
"\n",
|
||
"## Prerequisites\n",
|
||
"\n",
|
||
"- `01_data_quality_diagnostics` — establishes the baseline coverage and\n",
|
||
" outlier counts that motivate each cleaning step here.\n",
|
||
"- Familiarity with leakage-aware splitting (Chapter 6 §6.3).\n",
|
||
"- Polars expressions, Jupytext percent-format notebooks.\n",
|
||
"\n",
|
||
"## Data Contract\n",
|
||
"\n",
|
||
"- **Input**: Raw datasets from data loaders\n",
|
||
"- **Output**: In-memory cleaned DataFrames (teaching demonstration, not persisted)"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 1,
|
||
"id": "abb6b1e1",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:38.585875Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:38.585379Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:41.446435Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:41.445741Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 2.869759,
|
||
"end_time": "2026-06-13T03:12:41.447100+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:38.577341+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stderr",
|
||
"output_type": "stream",
|
||
"text": [
|
||
".venv/lib/python3.14/site-packages/ml4t/engineer/features/ml/__init__.py:9: UserWarning: Feature 'cyclical_encode': lookback=0 but has period/window parameter. Consider using lookback='period' or specifying the actual lookback.\n",
|
||
" from ml4t.engineer.features.ml.cyclical_encode import * # noqa: F403\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"\"\"\"Preprocessing Pipeline - Clean datasets with split-aware processing.\"\"\"\n",
|
||
"\n",
|
||
"from __future__ import annotations\n",
|
||
"\n",
|
||
"import pickle\n",
|
||
"import tempfile\n",
|
||
"import warnings\n",
|
||
"from collections.abc import Callable\n",
|
||
"from dataclasses import dataclass, field\n",
|
||
"from datetime import datetime\n",
|
||
"from pathlib import Path\n",
|
||
"from typing import Literal\n",
|
||
"\n",
|
||
"import matplotlib.pyplot as plt\n",
|
||
"import numpy as np\n",
|
||
"import polars as pl\n",
|
||
"from IPython.display import display\n",
|
||
"from ml4t.engineer.preprocessing import StandardScaler\n",
|
||
"from sklearn.preprocessing import OneHotEncoder\n",
|
||
"\n",
|
||
"from data import (\n",
|
||
" load_crypto_perps,\n",
|
||
" load_crypto_premium,\n",
|
||
" load_etfs,\n",
|
||
" load_firm_characteristics,\n",
|
||
" load_us_equities,\n",
|
||
")\n",
|
||
"from utils.reproducibility import set_global_seeds\n",
|
||
"\n",
|
||
"warnings.filterwarnings(\"ignore\")"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 2,
|
||
"id": "3721674a",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:41.453568Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:41.453359Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:41.455881Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:41.455429Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 0.006311,
|
||
"end_time": "2026-06-13T03:12:41.456322+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:41.450011+00:00",
|
||
"status": "completed"
|
||
},
|
||
"tags": [
|
||
"parameters"
|
||
]
|
||
},
|
||
"outputs": [],
|
||
"source": [
|
||
"# Production defaults\n",
|
||
"SEED = 42\n",
|
||
"US_EQUITIES_START_DATE = \"1970-01-01\"\n",
|
||
"ETF_START_DATE = \"2015-01-01\"\n",
|
||
"CRYPTO_START_DATE = \"2021-01-01T00:00:00+00:00\"\n",
|
||
"FIRM_CHARACTERISTICS_START_DATE = \"1990-01-01\""
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 3,
|
||
"id": "7be47005",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:41.463579Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:41.463475Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:41.508616Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:41.508058Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 0.050069,
|
||
"end_time": "2026-06-13T03:12:41.509383+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:41.459314+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [],
|
||
"source": [
|
||
"set_global_seeds(SEED)"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 4,
|
||
"id": "5db60144",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:41.516614Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:41.516464Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:41.520128Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:41.519107Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 0.007936,
|
||
"end_time": "2026-06-13T03:12:41.520841+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:41.512905+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [],
|
||
"source": [
|
||
"def ensure_symbol_alias(df: pl.DataFrame) -> pl.DataFrame:\n",
|
||
" \"\"\"Expose canonical asset identifiers under the symbol name when needed.\"\"\"\n",
|
||
" if \"asset\" in df.columns and \"symbol\" not in df.columns:\n",
|
||
" return df.with_columns(pl.col(\"asset\").alias(\"symbol\"))\n",
|
||
" return df"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 5,
|
||
"id": "8b29f157",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:41.528796Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:41.528569Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:41.532880Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:41.532485Z"
|
||
},
|
||
"lines_to_next_cell": 2,
|
||
"papermill": {
|
||
"duration": 0.008698,
|
||
"end_time": "2026-06-13T03:12:41.533462+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:41.524764+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [],
|
||
"source": [
|
||
"def filter_from_start(df: pl.DataFrame, time_col: str, start_value: str) -> pl.DataFrame:\n",
|
||
" \"\"\"Apply a start-date filter without timezone/unit mismatches.\"\"\"\n",
|
||
" start_date = datetime.fromisoformat(start_value).date()\n",
|
||
" dtype = df.schema[time_col]\n",
|
||
" if dtype == pl.Date:\n",
|
||
" return df.filter(pl.col(time_col) >= pl.lit(start_date).cast(pl.Date))\n",
|
||
" return df.filter(pl.col(time_col).dt.date() >= pl.lit(start_date).cast(pl.Date))"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "37dd0ba9",
|
||
"metadata": {
|
||
"lines_to_next_cell": 2,
|
||
"papermill": {
|
||
"duration": 0.007429,
|
||
"end_time": "2026-06-13T03:12:41.545180+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:41.537751+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"## 1. Preprocessing Utilities\n",
|
||
"\n",
|
||
"Reusable functions for common data cleaning operations."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "274dcc51",
|
||
"metadata": {
|
||
"lines_to_next_cell": 2,
|
||
"papermill": {
|
||
"duration": 0.002708,
|
||
"end_time": "2026-06-13T03:12:41.552812+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:41.550104+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"### 1.1 Remove Duplicates\n",
|
||
"\n",
|
||
"Handles exact duplicates and near-duplicates with configurable strategy."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 6,
|
||
"id": "22cd54aa",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:41.562015Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:41.561876Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:41.564509Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:41.564191Z"
|
||
},
|
||
"lines_to_next_cell": 2,
|
||
"papermill": {
|
||
"duration": 0.007893,
|
||
"end_time": "2026-06-13T03:12:41.565100+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:41.557207+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [],
|
||
"source": [
|
||
"def remove_duplicates(\n",
|
||
" df: pl.DataFrame,\n",
|
||
" key_cols: list[str],\n",
|
||
" strategy: Literal[\"keep_first\", \"keep_last\", \"drop_all\"] = \"keep_last\",\n",
|
||
") -> tuple[pl.DataFrame, int]:\n",
|
||
" \"\"\"Remove duplicate rows based on key columns.\n",
|
||
"\n",
|
||
" Args:\n",
|
||
" df: Input DataFrame\n",
|
||
" key_cols: Columns that define uniqueness\n",
|
||
" strategy: How to handle duplicates\n",
|
||
" - keep_first: Keep first occurrence\n",
|
||
" - keep_last: Keep last occurrence\n",
|
||
" - drop_all: Remove all duplicates\n",
|
||
"\n",
|
||
" Returns:\n",
|
||
" Tuple of (cleaned DataFrame, number of rows removed)\n",
|
||
" \"\"\"\n",
|
||
" original_len = len(df)\n",
|
||
"\n",
|
||
" if strategy == \"keep_first\":\n",
|
||
" result = df.unique(subset=key_cols, keep=\"first\")\n",
|
||
" elif strategy == \"keep_last\":\n",
|
||
" result = df.unique(subset=key_cols, keep=\"last\")\n",
|
||
" else: # drop_all\n",
|
||
" # Count occurrences and keep only unique rows\n",
|
||
" counts = df.group_by(key_cols).len()\n",
|
||
" unique_keys = counts.filter(pl.col(\"len\") == 1).drop(\"len\")\n",
|
||
" result = df.join(unique_keys, on=key_cols, how=\"inner\")\n",
|
||
"\n",
|
||
" n_removed = original_len - len(result)\n",
|
||
" return result, n_removed"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "51701bac",
|
||
"metadata": {
|
||
"lines_to_next_cell": 2,
|
||
"papermill": {
|
||
"duration": 0.004288,
|
||
"end_time": "2026-06-13T03:12:41.573045+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:41.568757+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"### 1.2 Fill Expected Gaps\n",
|
||
"\n",
|
||
"Distinguishes between expected gaps (weekends, holidays) and unexpected gaps."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 7,
|
||
"id": "ed9574ab",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:41.582746Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:41.582560Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:41.587314Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:41.586545Z"
|
||
},
|
||
"lines_to_next_cell": 2,
|
||
"papermill": {
|
||
"duration": 0.010136,
|
||
"end_time": "2026-06-13T03:12:41.587789+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:41.577653+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [],
|
||
"source": [
|
||
"def fill_expected_gaps(\n",
|
||
" df: pl.DataFrame,\n",
|
||
" time_col: str,\n",
|
||
" symbol_col: str | None,\n",
|
||
" method: Literal[\"ffill\", \"interpolate\", \"flag_only\"] = \"flag_only\",\n",
|
||
" max_gap_days: int = 5,\n",
|
||
") -> pl.DataFrame:\n",
|
||
" \"\"\"Handle gaps in time series data.\n",
|
||
"\n",
|
||
" Args:\n",
|
||
" df: Input DataFrame\n",
|
||
" time_col: Time column name\n",
|
||
" symbol_col: Symbol column (None for single asset)\n",
|
||
" method: How to handle gaps\n",
|
||
" - ffill: Forward fill\n",
|
||
" - interpolate: Linear interpolation\n",
|
||
" - flag_only: Add flag column without filling\n",
|
||
" max_gap_days: Maximum gap to fill (longer gaps are flagged)\n",
|
||
"\n",
|
||
" Returns:\n",
|
||
" DataFrame with gaps handled\n",
|
||
" \"\"\"\n",
|
||
" # Add gap flag column\n",
|
||
" if symbol_col and symbol_col in df.columns:\n",
|
||
" result = df.sort([symbol_col, time_col]).with_columns(\n",
|
||
" prev_date=pl.col(time_col).shift(1).over(symbol_col),\n",
|
||
" )\n",
|
||
" else:\n",
|
||
" result = df.sort(time_col).with_columns(\n",
|
||
" prev_date=pl.col(time_col).shift(1),\n",
|
||
" )\n",
|
||
"\n",
|
||
" # Calculate gap in days\n",
|
||
" result = result.with_columns(gap_days=(pl.col(time_col) - pl.col(\"prev_date\")).dt.total_days())\n",
|
||
"\n",
|
||
" # Flag unexpected gaps (>1 day for daily, accounting for weekends)\n",
|
||
" result = result.with_columns(\n",
|
||
" is_gap=(pl.col(\"gap_days\") > max_gap_days) & pl.col(\"prev_date\").is_not_null()\n",
|
||
" )\n",
|
||
"\n",
|
||
" # Clean up temporary columns\n",
|
||
" result = result.drop([\"prev_date\", \"gap_days\"])\n",
|
||
"\n",
|
||
" return result"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "879189fb",
|
||
"metadata": {
|
||
"lines_to_next_cell": 2,
|
||
"papermill": {
|
||
"duration": 0.003579,
|
||
"end_time": "2026-06-13T03:12:41.595123+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:41.591544+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"### 1.3 Apply Domain Filters\n",
|
||
"\n",
|
||
"Remove rows with impossible values (negative prices, zero volume, etc.)"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 8,
|
||
"id": "f97cf46a",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:41.602239Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:41.602043Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:41.605028Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:41.604698Z"
|
||
},
|
||
"lines_to_next_cell": 2,
|
||
"papermill": {
|
||
"duration": 0.00722,
|
||
"end_time": "2026-06-13T03:12:41.605331+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:41.598111+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [],
|
||
"source": [
|
||
"def apply_domain_filters(\n",
|
||
" df: pl.DataFrame,\n",
|
||
" rules: dict[str, Callable[[pl.Expr], pl.Expr]],\n",
|
||
") -> tuple[pl.DataFrame, dict[str, int]]:\n",
|
||
" \"\"\"Apply domain validation rules and filter invalid rows.\n",
|
||
"\n",
|
||
" Args:\n",
|
||
" df: Input DataFrame\n",
|
||
" rules: Dictionary of {rule_name: filter_expression}\n",
|
||
" Expression should return True for rows to KEEP\n",
|
||
"\n",
|
||
" Returns:\n",
|
||
" Tuple of (filtered DataFrame, counts of removed rows per rule)\n",
|
||
"\n",
|
||
" Example:\n",
|
||
" >>> rules = {\n",
|
||
" ... \"positive_close\": lambda c: pl.col(\"close\") > 0,\n",
|
||
" ... \"non_negative_volume\": lambda c: pl.col(\"volume\") >= 0,\n",
|
||
" ... }\n",
|
||
" >>> clean_df, removed = apply_domain_filters(df, rules)\n",
|
||
" \"\"\"\n",
|
||
" removed_counts = {}\n",
|
||
" result = df\n",
|
||
"\n",
|
||
" for rule_name, filter_fn in rules.items():\n",
|
||
" pre_len = len(result)\n",
|
||
" result = result.filter(filter_fn(None))\n",
|
||
" removed_counts[rule_name] = pre_len - len(result)\n",
|
||
"\n",
|
||
" return result, removed_counts"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "e62590cd",
|
||
"metadata": {
|
||
"lines_to_next_cell": 2,
|
||
"papermill": {
|
||
"duration": 0.003825,
|
||
"end_time": "2026-06-13T03:12:41.612692+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:41.608867+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"### 1.4 Spike Filter\n",
|
||
"\n",
|
||
"Detect and flag single-bar price reversals (potential data errors)."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 9,
|
||
"id": "436382e8",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:41.620421Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:41.620255Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:41.625508Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:41.624772Z"
|
||
},
|
||
"lines_to_next_cell": 2,
|
||
"papermill": {
|
||
"duration": 0.010215,
|
||
"end_time": "2026-06-13T03:12:41.625868+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:41.615653+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [],
|
||
"source": [
|
||
"def spike_filter(\n",
|
||
" df: pl.DataFrame,\n",
|
||
" price_col: str = \"close\",\n",
|
||
" threshold: float = 0.5,\n",
|
||
" symbol_col: str | None = \"symbol\",\n",
|
||
" time_col: str = \"timestamp\",\n",
|
||
" action: Literal[\"flag\", \"remove\", \"replace\"] = \"flag\",\n",
|
||
") -> pl.DataFrame:\n",
|
||
" \"\"\"Detect and handle price spikes (single-bar reversals).\n",
|
||
"\n",
|
||
" A spike is defined as:\n",
|
||
" - Return > threshold (e.g., 50% up)\n",
|
||
" - Followed by return < -threshold/(1+threshold) (reverting back)\n",
|
||
"\n",
|
||
" Args:\n",
|
||
" df: Input DataFrame\n",
|
||
" price_col: Price column to check\n",
|
||
" threshold: Minimum return to be considered a spike\n",
|
||
" symbol_col: Symbol column for panel data\n",
|
||
" time_col: Time column for sorting\n",
|
||
" action: How to handle spikes\n",
|
||
" - flag: Add is_spike column\n",
|
||
" - remove: Remove spike rows\n",
|
||
" - replace: Replace spike with interpolated value\n",
|
||
"\n",
|
||
" Returns:\n",
|
||
" DataFrame with spikes handled\n",
|
||
" \"\"\"\n",
|
||
" # Calculate returns\n",
|
||
" if symbol_col and symbol_col in df.columns:\n",
|
||
" result = df.sort([symbol_col, time_col]).with_columns(\n",
|
||
" ret=pl.col(price_col).pct_change().over(symbol_col),\n",
|
||
" ret_next=pl.col(price_col).pct_change().shift(-1).over(symbol_col),\n",
|
||
" )\n",
|
||
" else:\n",
|
||
" result = df.sort(time_col).with_columns(\n",
|
||
" ret=pl.col(price_col).pct_change(),\n",
|
||
" ret_next=pl.col(price_col).pct_change().shift(-1),\n",
|
||
" )\n",
|
||
"\n",
|
||
" # Detect spikes (sharp move followed by reversion)\n",
|
||
" reversion_threshold = -threshold / (1 + threshold)\n",
|
||
" result = result.with_columns(\n",
|
||
" is_spike=((pl.col(\"ret\") > threshold) & (pl.col(\"ret_next\") < reversion_threshold))\n",
|
||
" | ((pl.col(\"ret\") < -threshold / (1 + threshold)) & (pl.col(\"ret_next\") > threshold))\n",
|
||
" )\n",
|
||
"\n",
|
||
" if action == \"remove\":\n",
|
||
" result = result.filter(~pl.col(\"is_spike\"))\n",
|
||
" elif action == \"replace\":\n",
|
||
" # Replace with geometric mean of before and after\n",
|
||
" if symbol_col and symbol_col in df.columns:\n",
|
||
" result = result.with_columns(\n",
|
||
" pl.when(pl.col(\"is_spike\"))\n",
|
||
" .then(\n",
|
||
" (\n",
|
||
" pl.col(price_col).shift(1).over(symbol_col)\n",
|
||
" * pl.col(price_col).shift(-1).over(symbol_col)\n",
|
||
" ).sqrt()\n",
|
||
" )\n",
|
||
" .otherwise(pl.col(price_col))\n",
|
||
" .alias(price_col)\n",
|
||
" )\n",
|
||
" else:\n",
|
||
" result = result.with_columns(\n",
|
||
" pl.when(pl.col(\"is_spike\"))\n",
|
||
" .then((pl.col(price_col).shift(1) * pl.col(price_col).shift(-1)).sqrt())\n",
|
||
" .otherwise(pl.col(price_col))\n",
|
||
" .alias(price_col)\n",
|
||
" )\n",
|
||
"\n",
|
||
" # Clean up temporary columns\n",
|
||
" result = result.drop([\"ret\", \"ret_next\"])\n",
|
||
"\n",
|
||
" return result"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "b55d75eb",
|
||
"metadata": {
|
||
"lines_to_next_cell": 2,
|
||
"papermill": {
|
||
"duration": 0.003707,
|
||
"end_time": "2026-06-13T03:12:41.633119+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:41.629412+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"### 1.5 Winsorize Panel\n",
|
||
"\n",
|
||
"Clip extreme values at percentile thresholds, respecting panel structure."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 10,
|
||
"id": "9a72f34d",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:41.641723Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:41.641544Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:41.646545Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:41.645905Z"
|
||
},
|
||
"lines_to_next_cell": 2,
|
||
"papermill": {
|
||
"duration": 0.010561,
|
||
"end_time": "2026-06-13T03:12:41.646972+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:41.636411+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [],
|
||
"source": [
|
||
"def winsorize_panel(\n",
|
||
" df: pl.DataFrame,\n",
|
||
" fields: list[str],\n",
|
||
" limits: tuple[float, float] = (0.01, 0.99),\n",
|
||
" by_period: bool = True,\n",
|
||
" period_col: str = \"timestamp\",\n",
|
||
") -> pl.DataFrame:\n",
|
||
" \"\"\"Winsorize (clip) extreme values at percentile bounds.\n",
|
||
"\n",
|
||
" Args:\n",
|
||
" df: Input DataFrame\n",
|
||
" fields: Columns to winsorize\n",
|
||
" limits: (lower_percentile, upper_percentile) bounds\n",
|
||
" by_period: If True, compute bounds per time period (cross-sectional)\n",
|
||
" period_col: Column to group by for cross-sectional winsorization\n",
|
||
"\n",
|
||
" Returns:\n",
|
||
" DataFrame with winsorized values\n",
|
||
" \"\"\"\n",
|
||
" result = df\n",
|
||
"\n",
|
||
" for col_name in fields:\n",
|
||
" if col_name not in df.columns:\n",
|
||
" continue\n",
|
||
"\n",
|
||
" if by_period:\n",
|
||
" # Cross-sectional winsorization (per date)\n",
|
||
" bounds = df.group_by(period_col).agg(\n",
|
||
" [\n",
|
||
" pl.col(col_name).quantile(limits[0]).alias(\"lower\"),\n",
|
||
" pl.col(col_name).quantile(limits[1]).alias(\"upper\"),\n",
|
||
" ]\n",
|
||
" )\n",
|
||
" result = result.join(bounds, on=period_col, how=\"left\")\n",
|
||
" result = result.with_columns(\n",
|
||
" pl.when(pl.col(col_name) < pl.col(\"lower\"))\n",
|
||
" .then(pl.col(\"lower\"))\n",
|
||
" .when(pl.col(col_name) > pl.col(\"upper\"))\n",
|
||
" .then(pl.col(\"upper\"))\n",
|
||
" .otherwise(pl.col(col_name))\n",
|
||
" .alias(col_name)\n",
|
||
" ).drop([\"lower\", \"upper\"])\n",
|
||
" else:\n",
|
||
" # Global winsorization\n",
|
||
" lower = df[col_name].quantile(limits[0])\n",
|
||
" upper = df[col_name].quantile(limits[1])\n",
|
||
" result = result.with_columns(pl.col(col_name).clip(lower, upper))\n",
|
||
"\n",
|
||
" return result"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "74147f1c",
|
||
"metadata": {
|
||
"lines_to_next_cell": 2,
|
||
"papermill": {
|
||
"duration": 0.003363,
|
||
"end_time": "2026-06-13T03:12:41.653738+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:41.650375+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"## 2. SplitAwarePreprocessor\n",
|
||
"\n",
|
||
"The key pedagogical artifact: a preprocessing class that **learns parameters\n",
|
||
"on training data only** and applies them to validation/test data.\n",
|
||
"\n",
|
||
"This prevents lookahead bias in:\n",
|
||
"- Scaling (mean, std computed on train only)\n",
|
||
"- Imputation (median/mode computed on train only)\n",
|
||
"- Encoding (vocabulary built on train only)"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 11,
|
||
"id": "64aa62e2",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:41.662475Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:41.662297Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:41.674449Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:41.673817Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 0.017499,
|
||
"end_time": "2026-06-13T03:12:41.674775+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:41.657276+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [],
|
||
"source": [
|
||
"@dataclass\n",
|
||
"class SplitAwarePreprocessor:\n",
|
||
" \"\"\"Preprocessor that fits on training data only to prevent lookahead bias.\n",
|
||
"\n",
|
||
" Key principle: Any statistic used for preprocessing (mean, std, median,\n",
|
||
" encoder vocabulary) must be computed on training data only.\n",
|
||
"\n",
|
||
" Example:\n",
|
||
" >>> preprocessor = SplitAwarePreprocessor(\n",
|
||
" ... scale_cols=[\"returns\", \"volume\"],\n",
|
||
" ... winsorize_cols=[\"returns\"],\n",
|
||
" ... )\n",
|
||
" >>> preprocessor.fit(train_df)\n",
|
||
" >>> train_processed = preprocessor.transform(train_df)\n",
|
||
" >>> test_processed = preprocessor.transform(test_df)\n",
|
||
" \"\"\"\n",
|
||
"\n",
|
||
" # Configuration\n",
|
||
" scale_cols: list[str] = field(default_factory=list)\n",
|
||
" winsorize_cols: list[str] = field(default_factory=list)\n",
|
||
" winsorize_limits: tuple[float, float] = (0.01, 0.99)\n",
|
||
" impute_cols: list[str] = field(default_factory=list)\n",
|
||
" impute_strategy: Literal[\"median\", \"mean\", \"zero\"] = \"median\"\n",
|
||
" rank_cols: list[str] = field(default_factory=list)\n",
|
||
"\n",
|
||
" # Learned parameters (set by fit)\n",
|
||
" _fitted: bool = field(default=False, init=False)\n",
|
||
" _scale_params: dict[str, dict[str, float]] = field(default_factory=dict, init=False)\n",
|
||
" _winsorize_params: dict[str, dict[str, float]] = field(default_factory=dict, init=False)\n",
|
||
" _impute_params: dict[str, float] = field(default_factory=dict, init=False)\n",
|
||
"\n",
|
||
" def fit(self, train_df: pl.DataFrame) -> SplitAwarePreprocessor:\n",
|
||
" \"\"\"Learn preprocessing parameters from training data.\n",
|
||
"\n",
|
||
" Args:\n",
|
||
" train_df: Training DataFrame\n",
|
||
"\n",
|
||
" Returns:\n",
|
||
" Self (for method chaining)\n",
|
||
" \"\"\"\n",
|
||
" # Learn scaling parameters\n",
|
||
" for col in self.scale_cols:\n",
|
||
" if col in train_df.columns:\n",
|
||
" col_data = train_df[col].drop_nulls()\n",
|
||
" self._scale_params[col] = {\n",
|
||
" \"mean\": float(col_data.mean()),\n",
|
||
" \"std\": float(col_data.std()),\n",
|
||
" }\n",
|
||
"\n",
|
||
" # Learn winsorization bounds\n",
|
||
" for col in self.winsorize_cols:\n",
|
||
" if col in train_df.columns:\n",
|
||
" col_data = train_df[col].drop_nulls()\n",
|
||
" self._winsorize_params[col] = {\n",
|
||
" \"lower\": float(col_data.quantile(self.winsorize_limits[0])),\n",
|
||
" \"upper\": float(col_data.quantile(self.winsorize_limits[1])),\n",
|
||
" }\n",
|
||
"\n",
|
||
" # Learn imputation values\n",
|
||
" for col in self.impute_cols:\n",
|
||
" if col in train_df.columns:\n",
|
||
" col_data = train_df[col].drop_nulls()\n",
|
||
" if self.impute_strategy == \"median\":\n",
|
||
" self._impute_params[col] = float(col_data.median())\n",
|
||
" elif self.impute_strategy == \"mean\":\n",
|
||
" self._impute_params[col] = float(col_data.mean())\n",
|
||
" else:\n",
|
||
" self._impute_params[col] = 0.0\n",
|
||
"\n",
|
||
" self._fitted = True\n",
|
||
" return self\n",
|
||
"\n",
|
||
" def transform(self, df: pl.DataFrame) -> pl.DataFrame:\n",
|
||
" \"\"\"Apply learned preprocessing to any DataFrame.\n",
|
||
"\n",
|
||
" Args:\n",
|
||
" df: DataFrame to transform\n",
|
||
"\n",
|
||
" Returns:\n",
|
||
" Transformed DataFrame\n",
|
||
" \"\"\"\n",
|
||
" if not self._fitted:\n",
|
||
" raise ValueError(\"Preprocessor not fitted. Call fit() first.\")\n",
|
||
"\n",
|
||
" result = df\n",
|
||
"\n",
|
||
" # Apply imputation\n",
|
||
" for col, fill_value in self._impute_params.items():\n",
|
||
" if col in result.columns:\n",
|
||
" result = result.with_columns(pl.col(col).fill_null(fill_value))\n",
|
||
"\n",
|
||
" # Apply winsorization\n",
|
||
" for col, bounds in self._winsorize_params.items():\n",
|
||
" if col in result.columns:\n",
|
||
" result = result.with_columns(pl.col(col).clip(bounds[\"lower\"], bounds[\"upper\"]))\n",
|
||
"\n",
|
||
" # Apply scaling\n",
|
||
" for col, params in self._scale_params.items():\n",
|
||
" if col in result.columns and params[\"std\"] > 0:\n",
|
||
" result = result.with_columns(\n",
|
||
" ((pl.col(col) - params[\"mean\"]) / params[\"std\"]).alias(col)\n",
|
||
" )\n",
|
||
"\n",
|
||
" # Apply cross-sectional ranking (doesn't require fitting)\n",
|
||
" for col in self.rank_cols:\n",
|
||
" if col in result.columns and \"timestamp\" in result.columns:\n",
|
||
" result = result.with_columns(\n",
|
||
" pl.col(col).rank().over(\"timestamp\").alias(f\"{col}_rank\")\n",
|
||
" )\n",
|
||
"\n",
|
||
" return result\n",
|
||
"\n",
|
||
" def fit_transform(self, train_df: pl.DataFrame) -> pl.DataFrame:\n",
|
||
" \"\"\"Fit and transform in one step.\"\"\"\n",
|
||
" return self.fit(train_df).transform(train_df)\n",
|
||
"\n",
|
||
" def save(self, path: Path) -> None:\n",
|
||
" \"\"\"Save fitted preprocessor to disk.\"\"\"\n",
|
||
" if not self._fitted:\n",
|
||
" raise ValueError(\"Cannot save unfitted preprocessor.\")\n",
|
||
" with open(path, \"wb\") as f:\n",
|
||
" pickle.dump(self, f)\n",
|
||
"\n",
|
||
" @classmethod\n",
|
||
" def load(cls, path: Path) -> SplitAwarePreprocessor:\n",
|
||
" \"\"\"Load fitted preprocessor from disk.\"\"\"\n",
|
||
" with open(path, \"rb\") as f:\n",
|
||
" return pickle.load(f)"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "0ba1f0fc",
|
||
"metadata": {
|
||
"papermill": {
|
||
"duration": 0.003242,
|
||
"end_time": "2026-06-13T03:12:41.681480+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:41.678238+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"## 3. US Equities Deep Clean\n",
|
||
"\n",
|
||
"The most complex dataset and the best teaching vehicle for preprocessing.\n",
|
||
"We apply four sequential cleaning steps: penny stock filter, domain\n",
|
||
"validation, extreme return removal, and spike detection."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 12,
|
||
"id": "16d530fc",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:41.688781Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:41.688597Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:42.125921Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:42.125262Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 0.441901,
|
||
"end_time": "2026-06-13T03:12:42.126387+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:41.684486+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"Loaded 15,371,431 rows, 3199 symbols\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"us_equities = None\n",
|
||
"try:\n",
|
||
" us_equities = ensure_symbol_alias(load_us_equities())\n",
|
||
" us_equities = filter_from_start(us_equities, \"timestamp\", US_EQUITIES_START_DATE)\n",
|
||
" print(f\"Loaded {len(us_equities):,} rows, {us_equities['symbol'].n_unique()} symbols\")\n",
|
||
"except Exception as e:\n",
|
||
" print(f\"Could not load US Equities: {type(e).__name__}\")"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "482d7bf3",
|
||
"metadata": {
|
||
"papermill": {
|
||
"duration": 0.002832,
|
||
"end_time": "2026-06-13T03:12:42.132384+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:42.129552+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"### Step 1: Remove penny stocks\n",
|
||
"\n",
|
||
"Stocks trading below $1 introduce microstructure noise that dominates\n",
|
||
"cross-sectional models. Filter them early."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 13,
|
||
"id": "a0fb41f9",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:42.139392Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:42.139285Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:42.308507Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:42.307898Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 0.173985,
|
||
"end_time": "2026-06-13T03:12:42.309225+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:42.135240+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"Penny stocks removed: 209,819 rows (1.4%)\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"if us_equities is not None:\n",
|
||
" original_rows = len(us_equities)\n",
|
||
" cleaned = us_equities.filter(pl.col(\"close\") >= 1.0)\n",
|
||
" penny_removed = original_rows - len(cleaned)\n",
|
||
" print(\n",
|
||
" f\"Penny stocks removed: {penny_removed:,} rows ({100 * penny_removed / original_rows:.1f}%)\"\n",
|
||
" )"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "5d396b2d",
|
||
"metadata": {
|
||
"papermill": {
|
||
"duration": 0.00356,
|
||
"end_time": "2026-06-13T03:12:42.317341+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:42.313781+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"### Step 2: Domain filters\n",
|
||
"\n",
|
||
"Remove rows violating physical constraints: negative prices, negative volume,\n",
|
||
"and OHLC inconsistency (high < low, etc.)."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 14,
|
||
"id": "9ce6b239",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:42.325365Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:42.325259Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:42.515227Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:42.514793Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 0.194417,
|
||
"end_time": "2026-06-13T03:12:42.515503+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:42.321086+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"positive_prices: removed 142 rows\n",
|
||
"ohlc_consistency: removed 257 rows\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"if us_equities is not None:\n",
|
||
" domain_rules = {\n",
|
||
" \"positive_prices\": lambda _: (\n",
|
||
" (pl.col(\"open\") > 0)\n",
|
||
" & (pl.col(\"high\") > 0)\n",
|
||
" & (pl.col(\"low\") > 0)\n",
|
||
" & (pl.col(\"close\") > 0)\n",
|
||
" ),\n",
|
||
" \"non_negative_volume\": lambda _: pl.col(\"volume\") >= 0,\n",
|
||
" \"ohlc_consistency\": lambda _: (\n",
|
||
" (pl.col(\"low\") <= pl.col(\"open\"))\n",
|
||
" & (pl.col(\"low\") <= pl.col(\"close\"))\n",
|
||
" & (pl.col(\"high\") >= pl.col(\"open\"))\n",
|
||
" & (pl.col(\"high\") >= pl.col(\"close\"))\n",
|
||
" ),\n",
|
||
" }\n",
|
||
" cleaned, removed_counts = apply_domain_filters(cleaned, domain_rules)\n",
|
||
" for rule, count in removed_counts.items():\n",
|
||
" if count > 0:\n",
|
||
" print(f\"{rule}: removed {count:,} rows\")"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "52f07941",
|
||
"metadata": {
|
||
"papermill": {
|
||
"duration": 0.003613,
|
||
"end_time": "2026-06-13T03:12:42.522288+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:42.518675+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"### Step 3: Extreme returns\n",
|
||
"\n",
|
||
"Daily returns exceeding 200% typically indicate stock splits or data errors."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 15,
|
||
"id": "34c0ceeb",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:42.530468Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:42.530244Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:44.150828Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:44.150060Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 1.626173,
|
||
"end_time": "2026-06-13T03:12:44.151819+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:42.525646+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"Extreme returns (>200%): 345 rows\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"if us_equities is not None:\n",
|
||
" cleaned = cleaned.sort([\"symbol\", \"timestamp\"]).with_columns(\n",
|
||
" returns=pl.col(\"close\").pct_change().over(\"symbol\")\n",
|
||
" )\n",
|
||
" extreme_returns = cleaned.filter(pl.col(\"returns\").abs() > 2.0)\n",
|
||
" print(f\"Extreme returns (>200%): {len(extreme_returns):,} rows\")\n",
|
||
" cleaned = cleaned.filter(pl.col(\"returns\").is_null() | (pl.col(\"returns\").abs() <= 2.0))"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "975dcc4d",
|
||
"metadata": {
|
||
"papermill": {
|
||
"duration": 0.002934,
|
||
"end_time": "2026-06-13T03:12:44.160311+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:44.157377+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"### Step 4: Spike detection\n",
|
||
"\n",
|
||
"Single-bar reversals (sharp move followed by immediate reversion) are\n",
|
||
"likely data errors rather than genuine price moves."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 16,
|
||
"id": "6e22d6e6",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:44.168168Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:44.168016Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:45.349968Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:45.349551Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 1.186933,
|
||
"end_time": "2026-06-13T03:12:45.350430+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:44.163497+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"Flagged spikes: 89\n",
|
||
"\n",
|
||
"Final: 15,160,868 rows, 3199 symbols\n",
|
||
"Retention: 98.6%\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"if us_equities is not None:\n",
|
||
" cleaned = spike_filter(cleaned, \"close\", threshold=0.5, action=\"flag\")\n",
|
||
" n_spikes = cleaned[\"is_spike\"].sum()\n",
|
||
" print(f\"Flagged spikes: {n_spikes}\")\n",
|
||
" print(f\"\\nFinal: {len(cleaned):,} rows, {cleaned['symbol'].n_unique()} symbols\")\n",
|
||
" print(f\"Retention: {100 * len(cleaned) / original_rows:.1f}%\")\n",
|
||
" us_equities_cleaned = cleaned"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "15b97b88",
|
||
"metadata": {
|
||
"papermill": {
|
||
"duration": 0.00281,
|
||
"end_time": "2026-06-13T03:12:45.356515+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:45.353705+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"### Winsorization effect\n",
|
||
"\n",
|
||
"Before saving, visualize how winsorization compresses the return distribution\n",
|
||
"tails. This motivates the robust scaling discussion in Section 7.1."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 17,
|
||
"id": "d4fba756",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:45.363137Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:45.363003Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:46.740274Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:46.739744Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 1.381468,
|
||
"end_time": "2026-06-13T03:12:46.740733+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:45.359265+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"data": {
|
||
"image/png": "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",
|
||
"text/plain": [
|
||
"<Figure size 1200x400 with 2 Axes>"
|
||
]
|
||
},
|
||
"metadata": {},
|
||
"output_type": "display_data"
|
||
}
|
||
],
|
||
"source": [
|
||
"if us_equities is not None:\n",
|
||
" raw_returns = us_equities_cleaned[\"returns\"].drop_nulls().to_numpy()\n",
|
||
" winsorized = winsorize_panel(\n",
|
||
" us_equities_cleaned.filter(pl.col(\"returns\").is_not_null()),\n",
|
||
" fields=[\"returns\"],\n",
|
||
" limits=(0.01, 0.99),\n",
|
||
" by_period=False,\n",
|
||
" )[\"returns\"].to_numpy()\n",
|
||
"\n",
|
||
" fig, axes = plt.subplots(1, 2, figsize=(12, 4), sharey=True)\n",
|
||
"\n",
|
||
" bin_edges = np.linspace(-0.15, 0.15, 201)\n",
|
||
" axes[0].hist(raw_returns, bins=bin_edges, alpha=0.8)\n",
|
||
" axes[0].set_title(\"Raw Returns\")\n",
|
||
" axes[0].set_xlabel(\"Daily Return\")\n",
|
||
" axes[0].set_ylabel(\"Count\")\n",
|
||
"\n",
|
||
" axes[1].hist(winsorized, bins=bin_edges, alpha=0.8)\n",
|
||
" axes[1].set_title(\"Winsorized Returns (1st/99th)\")\n",
|
||
" axes[1].set_xlabel(\"Daily Return\")\n",
|
||
"\n",
|
||
" fig.suptitle(\"Effect of Winsorization on US Equities Return Distribution\")\n",
|
||
" fig.tight_layout()\n",
|
||
" fig.show()"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "ec1204b9",
|
||
"metadata": {
|
||
"papermill": {
|
||
"duration": 0.002891,
|
||
"end_time": "2026-06-13T03:12:46.746819+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:46.743928+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"Winsorization clips the extreme tails without distorting the bulk of the\n",
|
||
"distribution. The 1st/99th percentile bounds remove genuine outliers while\n",
|
||
"preserving the fat-tailed shape that characterizes equity returns."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "bf939dd3",
|
||
"metadata": {
|
||
"papermill": {
|
||
"duration": 0.002839,
|
||
"end_time": "2026-06-13T03:12:46.752525+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:46.749686+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"### Cleaned US Equities Summary\n",
|
||
"\n",
|
||
"The cleaning pipeline is a teaching demonstration — downstream case study\n",
|
||
"notebooks apply their own cleaning via loaders and feature engineering."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 18,
|
||
"id": "4941013f",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:46.759295Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:46.759200Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:46.866002Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:46.865542Z"
|
||
},
|
||
"lines_to_next_cell": 2,
|
||
"papermill": {
|
||
"duration": 0.111006,
|
||
"end_time": "2026-06-13T03:12:46.866573+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:46.755567+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"Cleaned US Equities: 15,160,868 rows, 3199 symbols\n",
|
||
"Date range: 1970-01-02 to 2018-03-27\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"if us_equities is not None:\n",
|
||
" save_cols = [\n",
|
||
" c for c in us_equities_cleaned.columns if c not in [\"returns\", \"is_spike\", \"is_gap\"]\n",
|
||
" ]\n",
|
||
" cleaned = us_equities_cleaned.select(save_cols)\n",
|
||
" print(f\"Cleaned US Equities: {len(cleaned):,} rows, {cleaned['symbol'].n_unique()} symbols\")\n",
|
||
" print(f\"Date range: {cleaned['timestamp'].min()} to {cleaned['timestamp'].max()}\")"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "edd7e109",
|
||
"metadata": {
|
||
"papermill": {
|
||
"duration": 0.003306,
|
||
"end_time": "2026-06-13T03:12:46.873096+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:46.869790+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"## 4. ETF Universe Cleanup\n",
|
||
"\n",
|
||
"Yahoo Finance data has specific issues: adjustment artifacts from splits\n",
|
||
"and distributions, ticker changes, and occasional data gaps."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 19,
|
||
"id": "ab65d319",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:46.879672Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:46.879576Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:46.897704Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:46.897138Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 0.021934,
|
||
"end_time": "2026-06-13T03:12:46.898167+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:46.876233+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"Loaded 275,536 rows, 100 symbols\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"etfs = None\n",
|
||
"try:\n",
|
||
" etfs = ensure_symbol_alias(load_etfs())\n",
|
||
" if \"timestamp\" in etfs.columns:\n",
|
||
" etfs = etfs.with_columns(pl.col(\"timestamp\").dt.date().alias(\"timestamp\"))\n",
|
||
" etfs = filter_from_start(etfs, \"timestamp\", ETF_START_DATE)\n",
|
||
" print(f\"Loaded {len(etfs):,} rows, {etfs['symbol'].n_unique()} symbols\")\n",
|
||
"except Exception as e:\n",
|
||
" print(f\"Could not load ETFs: {type(e).__name__}\")"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 20,
|
||
"id": "27d5856d",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:46.906470Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:46.906385Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:46.943180Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:46.942710Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 0.040881,
|
||
"end_time": "2026-06-13T03:12:46.943553+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:46.902672+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"Adjustment artifacts: 0 potential split-like jumps\n",
|
||
"Unexpected gaps (>5 days): 0\n",
|
||
"Final: 275,536 rows (100.0% retained)\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"if etfs is not None:\n",
|
||
" original_rows = len(etfs)\n",
|
||
"\n",
|
||
" # Detect overnight jumps matching common split ratios\n",
|
||
" etfs_sorted = etfs.sort([\"symbol\", \"timestamp\"]).with_columns(\n",
|
||
" overnight_return=(pl.col(\"open\") / pl.col(\"close\").shift(1).over(\"symbol\") - 1)\n",
|
||
" )\n",
|
||
" split_ratios = [0.5, 0.333, 0.25, 2.0, 3.0, 4.0]\n",
|
||
" tolerance = 0.01\n",
|
||
" potential_issues = etfs_sorted.filter(\n",
|
||
" pl.any_horizontal(\n",
|
||
" [(pl.col(\"overnight_return\") - (ratio - 1)).abs() < tolerance for ratio in split_ratios]\n",
|
||
" )\n",
|
||
" )\n",
|
||
" print(f\"Adjustment artifacts: {len(potential_issues)} potential split-like jumps\")\n",
|
||
"\n",
|
||
" # Domain filters\n",
|
||
" domain_rules = {\n",
|
||
" \"positive_prices\": lambda _: pl.col(\"close\") > 0,\n",
|
||
" \"non_negative_volume\": lambda _: pl.col(\"volume\") >= 0,\n",
|
||
" }\n",
|
||
" etfs_cleaned, removed_counts = apply_domain_filters(etfs_sorted, domain_rules)\n",
|
||
" for rule, count in removed_counts.items():\n",
|
||
" if count > 0:\n",
|
||
" print(f\"{rule}: removed {count:,} rows\")\n",
|
||
"\n",
|
||
" # Gap check\n",
|
||
" etfs_cleaned = fill_expected_gaps(\n",
|
||
" etfs_cleaned, \"timestamp\", \"symbol\", method=\"flag_only\", max_gap_days=5\n",
|
||
" )\n",
|
||
" n_gaps = etfs_cleaned[\"is_gap\"].sum()\n",
|
||
" print(f\"Unexpected gaps (>5 days): {n_gaps}\")\n",
|
||
" print(\n",
|
||
" f\"Final: {len(etfs_cleaned):,} rows ({100 * len(etfs_cleaned) / original_rows:.1f}% retained)\"\n",
|
||
" )\n",
|
||
"\n",
|
||
" etfs_cleaned = etfs_cleaned.drop([\"overnight_return\"])"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 21,
|
||
"id": "72dcc394",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:46.950139Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:46.950049Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:46.954320Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:46.953929Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 0.007895,
|
||
"end_time": "2026-06-13T03:12:46.954563+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:46.946668+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"Cleaned ETFs: 275,536 rows, 100 symbols\n",
|
||
"Date range: 2015-01-02 to 2025-12-31\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"if etfs is not None:\n",
|
||
" save_cols = [c for c in etfs_cleaned.columns if c not in [\"is_gap\"]]\n",
|
||
" cleaned = etfs_cleaned.select(save_cols)\n",
|
||
" print(f\"Cleaned ETFs: {len(cleaned):,} rows, {cleaned['symbol'].n_unique()} symbols\")\n",
|
||
" print(f\"Date range: {cleaned['timestamp'].min()} to {cleaned['timestamp'].max()}\")"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "d95fe820",
|
||
"metadata": {
|
||
"lines_to_next_cell": 2,
|
||
"papermill": {
|
||
"duration": 0.002846,
|
||
"end_time": "2026-06-13T03:12:46.960315+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:46.957469+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"ETF cleaning is lighter than US Equities because the universe is curated\n",
|
||
"(100 liquid ETFs). The main concerns are adjustment artifacts from Yahoo\n",
|
||
"Finance and occasional data gaps around holidays or ticker changes."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "27fb31a4",
|
||
"metadata": {
|
||
"lines_to_next_cell": 2,
|
||
"papermill": {
|
||
"duration": 0.002807,
|
||
"end_time": "2026-06-13T03:12:46.965933+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:46.963126+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"## 5. Cross-Dataset Alignment Demo\n",
|
||
"\n",
|
||
"When combining datasets with different frequencies (daily equities with\n",
|
||
"monthly characteristics, or 8-hour crypto bars), alignment must preserve\n",
|
||
"point-in-time correctness."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "1770da71",
|
||
"metadata": {
|
||
"papermill": {
|
||
"duration": 0.002877,
|
||
"end_time": "2026-06-13T03:12:46.971643+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:46.968766+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"### 5.1 Crypto Spot + Perps Alignment\n",
|
||
"\n",
|
||
"Aligning 8-hour bars for basis computation: the premium index\n",
|
||
"captures the funding rate differential between spot and perpetual futures."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 22,
|
||
"id": "ee3ad805",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:46.978250Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:46.978152Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:47.094170Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:47.093603Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 0.120087,
|
||
"end_time": "2026-06-13T03:12:47.094719+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:46.974632+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"Perps: 97,717 rows | Premium: 97,239 rows\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"crypto_perps = None\n",
|
||
"crypto_premium = None\n",
|
||
"\n",
|
||
"try:\n",
|
||
" crypto_perps = ensure_symbol_alias(load_crypto_perps(frequency=\"8h\"))\n",
|
||
" crypto_premium = ensure_symbol_alias(load_crypto_premium(frequency=\"8h\"))\n",
|
||
" crypto_perps = filter_from_start(crypto_perps, \"timestamp\", CRYPTO_START_DATE)\n",
|
||
" crypto_premium = filter_from_start(crypto_premium, \"timestamp\", CRYPTO_START_DATE)\n",
|
||
" print(f\"Perps: {len(crypto_perps):,} rows | Premium: {len(crypto_premium):,} rows\")\n",
|
||
"except Exception as e:\n",
|
||
" print(f\"Could not load crypto data: {type(e).__name__}\")"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 23,
|
||
"id": "071ed00d",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:47.103438Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:47.103351Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:47.115643Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:47.115347Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 0.017292,
|
||
"end_time": "2026-06-13T03:12:47.116201+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.098909+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"Aligned: 97,179 rows, 19 symbols\n"
|
||
]
|
||
},
|
||
{
|
||
"data": {
|
||
"text/html": [
|
||
"<div><style>\n",
|
||
".dataframe > thead > tr,\n",
|
||
".dataframe > tbody > tr {\n",
|
||
" text-align: right;\n",
|
||
" white-space: pre-wrap;\n",
|
||
"}\n",
|
||
"</style>\n",
|
||
"<small>shape: (9, 2)</small><table border=\"1\" class=\"dataframe\"><thead><tr><th>statistic</th><th>basis</th></tr><tr><td>str</td><td>f64</td></tr></thead><tbody><tr><td>"count"</td><td>97179.0</td></tr><tr><td>"null_count"</td><td>0.0</td></tr><tr><td>"mean"</td><td>-0.013974</td></tr><tr><td>"std"</td><td>0.110737</td></tr><tr><td>"min"</td><td>-19.154743</td></tr><tr><td>"25%"</td><td>-0.051824</td></tr><tr><td>"50%"</td><td>-0.022179</td></tr><tr><td>"75%"</td><td>0.001284</td></tr><tr><td>"max"</td><td>1.270094</td></tr></tbody></table></div>"
|
||
],
|
||
"text/plain": [
|
||
"shape: (9, 2)\n",
|
||
"┌────────────┬────────────┐\n",
|
||
"│ statistic ┆ basis │\n",
|
||
"│ --- ┆ --- │\n",
|
||
"│ str ┆ f64 │\n",
|
||
"╞════════════╪════════════╡\n",
|
||
"│ count ┆ 97179.0 │\n",
|
||
"│ null_count ┆ 0.0 │\n",
|
||
"│ mean ┆ -0.013974 │\n",
|
||
"│ std ┆ 0.110737 │\n",
|
||
"│ min ┆ -19.154743 │\n",
|
||
"│ 25% ┆ -0.051824 │\n",
|
||
"│ 50% ┆ -0.022179 │\n",
|
||
"│ 75% ┆ 0.001284 │\n",
|
||
"│ max ┆ 1.270094 │\n",
|
||
"└────────────┴────────────┘"
|
||
]
|
||
},
|
||
"metadata": {},
|
||
"output_type": "display_data"
|
||
}
|
||
],
|
||
"source": [
|
||
"if crypto_perps is not None and crypto_premium is not None:\n",
|
||
" aligned = crypto_perps.join(\n",
|
||
" crypto_premium.select(\n",
|
||
" [\"timestamp\", \"symbol\", pl.col(\"premium_index_close\").alias(\"premium\")]\n",
|
||
" ),\n",
|
||
" on=[\"timestamp\", \"symbol\"],\n",
|
||
" how=\"inner\",\n",
|
||
" )\n",
|
||
" aligned = aligned.with_columns(basis=pl.col(\"premium\") * 100)\n",
|
||
"\n",
|
||
" print(f\"Aligned: {len(aligned):,} rows, {aligned['symbol'].n_unique()} symbols\")\n",
|
||
" display(aligned.select(\"basis\").describe())"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "9e922292",
|
||
"metadata": {
|
||
"papermill": {
|
||
"duration": 0.004656,
|
||
"end_time": "2026-06-13T03:12:47.125552+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.120896+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"The inner join ensures we only keep timestamps where both datasets have\n",
|
||
"observations. The basis (premium × 100) converts the raw premium index\n",
|
||
"to percentage points for readability."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "b63282ba",
|
||
"metadata": {
|
||
"papermill": {
|
||
"duration": 0.002852,
|
||
"end_time": "2026-06-13T03:12:47.131511+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.128659+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"### 5.2 Equity + Firm Characteristics As-Of Join\n",
|
||
"\n",
|
||
"Monthly characteristics must be joined to daily prices using point-in-time\n",
|
||
"logic: each daily observation gets the most recent monthly snapshot."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 24,
|
||
"id": "f8b847ab",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:47.137942Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:47.137798Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:47.280894Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:47.280537Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 0.147005,
|
||
"end_time": "2026-06-13T03:12:47.281458+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.134453+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"Firm chars: 804,530 rows, 49 columns\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"firm_char = None\n",
|
||
"try:\n",
|
||
" firm_char = load_firm_characteristics()\n",
|
||
" firm_char = firm_char.filter(\n",
|
||
" pl.col(\"timestamp\") >= datetime.fromisoformat(FIRM_CHARACTERISTICS_START_DATE)\n",
|
||
" )\n",
|
||
" print(f\"Firm chars: {len(firm_char):,} rows, {len(firm_char.columns)} columns\")\n",
|
||
"except Exception as e:\n",
|
||
" print(f\"Could not load firm characteristics: {type(e).__name__}\")"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 25,
|
||
"id": "26bc3136",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:47.288401Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:47.288317Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:47.298352Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:47.297381Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 0.014252,
|
||
"end_time": "2026-06-13T03:12:47.299024+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.284772+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"data": {
|
||
"text/html": [
|
||
"<div><style>\n",
|
||
".dataframe > thead > tr,\n",
|
||
".dataframe > tbody > tr {\n",
|
||
" text-align: right;\n",
|
||
" white-space: pre-wrap;\n",
|
||
"}\n",
|
||
"</style>\n",
|
||
"<small>shape: (2, 2)</small><table border=\"1\" class=\"dataframe\"><thead><tr><th>split</th><th>n_obs</th></tr><tr><td>str</td><td>u32</td></tr></thead><tbody><tr><td>"test"</td><td>497436</td></tr><tr><td>"valid"</td><td>307094</td></tr></tbody></table></div>"
|
||
],
|
||
"text/plain": [
|
||
"shape: (2, 2)\n",
|
||
"┌───────┬────────┐\n",
|
||
"│ split ┆ n_obs │\n",
|
||
"│ --- ┆ --- │\n",
|
||
"│ str ┆ u32 │\n",
|
||
"╞═══════╪════════╡\n",
|
||
"│ test ┆ 497436 │\n",
|
||
"│ valid ┆ 307094 │\n",
|
||
"└───────┴────────┘"
|
||
]
|
||
},
|
||
"metadata": {},
|
||
"output_type": "display_data"
|
||
},
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"Sample characteristics: ['A2ME', 'AC', 'AT', 'ATO', 'BEME']\n",
|
||
"Date range: 1990-01-31 to 2016-12-30\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"if firm_char is not None:\n",
|
||
" char_cols = sorted(c for c in firm_char.columns if c not in [\"timestamp\", \"split\", \"ret\"])[:5]\n",
|
||
" split_counts = firm_char.group_by(\"split\").agg(pl.len().alias(\"n_obs\")).sort(\"split\")\n",
|
||
" display(split_counts)\n",
|
||
" print(f\"Sample characteristics: {char_cols}\")\n",
|
||
" print(f\"Date range: {firm_char['timestamp'].min()} to {firm_char['timestamp'].max()}\")"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "552f3041",
|
||
"metadata": {
|
||
"lines_to_next_cell": 2,
|
||
"papermill": {
|
||
"duration": 0.008433,
|
||
"end_time": "2026-06-13T03:12:47.315304+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.306871+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"The as-of join pattern for monthly characteristics:\n",
|
||
"\n",
|
||
"```python\n",
|
||
"daily_df.with_columns(month=pl.col('timestamp').dt.truncate('1mo'))\n",
|
||
"daily_df.join(monthly_chars, left_on=['permno', 'month'], right_on=['permno', 'timestamp'])\n",
|
||
"```\n",
|
||
"\n",
|
||
"This ensures each daily observation sees only information available at\n",
|
||
"that point in time, preventing lookahead from future characteristic updates."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "071809f1",
|
||
"metadata": {
|
||
"papermill": {
|
||
"duration": 0.007,
|
||
"end_time": "2026-06-13T03:12:47.330122+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.323122+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"## 6. Categorical Encoding Demo\n",
|
||
"\n",
|
||
"Section 7.1 discusses categorical encodings (one-hot, ordinal, hashing).\n",
|
||
"The key constraint: **fit the encoder on training data only** so that\n",
|
||
"unseen categories in the test set are handled gracefully."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 26,
|
||
"id": "e0259bcc",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:47.345689Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:47.345514Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:47.349596Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:47.349092Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 0.012893,
|
||
"end_time": "2026-06-13T03:12:47.349942+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.337049+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"Train sectors: ['Consumer', 'Energy', 'Finance', 'Healthcare', 'Technology']\n",
|
||
"Test sectors: ['Consumer', 'Energy', 'Finance', 'Healthcare', 'Technology', 'Utilities']\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"# Create sample data with a categorical field (NumPy RNG is seeded globally in the preamble)\n",
|
||
"n_samples = 500\n",
|
||
"sectors = [\"Technology\", \"Finance\", \"Healthcare\", \"Energy\", \"Consumer\"]\n",
|
||
"train_sectors = np.random.choice(sectors, size=n_samples)\n",
|
||
"test_sectors = np.random.choice(sectors + [\"Utilities\"], size=100) # Unseen category\n",
|
||
"\n",
|
||
"cat_train = pl.DataFrame({\"sector\": train_sectors, \"returns\": np.random.randn(n_samples) * 0.02})\n",
|
||
"cat_test = pl.DataFrame({\"sector\": test_sectors, \"returns\": np.random.randn(100) * 0.02})\n",
|
||
"\n",
|
||
"print(f\"Train sectors: {sorted(str(s) for s in set(train_sectors))}\")\n",
|
||
"print(f\"Test sectors: {sorted(str(s) for s in set(test_sectors))}\")"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 27,
|
||
"id": "c522666f",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:47.358785Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:47.358683Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:47.366232Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:47.365743Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 0.012144,
|
||
"end_time": "2026-06-13T03:12:47.366748+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.354604+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"Encoded columns: ['sector_Consumer', 'sector_Energy', 'sector_Finance', 'sector_Healthcare', 'sector_Technology']\n",
|
||
"Train shape: (500, 5)\n",
|
||
"Test shape: (100, 5)\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"# Fit encoder on training data only\n",
|
||
"encoder = OneHotEncoder(sparse_output=False, handle_unknown=\"ignore\")\n",
|
||
"encoder.fit(cat_train.select(\"sector\").to_pandas())\n",
|
||
"\n",
|
||
"# Transform both splits\n",
|
||
"train_encoded = encoder.transform(cat_train.select(\"sector\").to_pandas())\n",
|
||
"test_encoded = encoder.transform(cat_test.select(\"sector\").to_pandas())\n",
|
||
"\n",
|
||
"print(f\"Encoded columns: {encoder.get_feature_names_out().tolist()}\")\n",
|
||
"print(f\"Train shape: {train_encoded.shape}\")\n",
|
||
"print(f\"Test shape: {test_encoded.shape}\")"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 28,
|
||
"id": "3387e084",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:47.374120Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:47.374026Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:47.376731Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:47.376321Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 0.006923,
|
||
"end_time": "2026-06-13T03:12:47.377118+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.370195+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"\n",
|
||
"Unseen 'Utilities' rows: 13\n",
|
||
"Encoding (all zeros): [0. 0. 0. 0. 0.]\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"# Show how unseen \"Utilities\" category is handled\n",
|
||
"utilities_mask = cat_test[\"sector\"] == \"Utilities\"\n",
|
||
"n_utilities = utilities_mask.sum()\n",
|
||
"utilities_encoded = test_encoded[utilities_mask.to_numpy()]\n",
|
||
"print(f\"\\nUnseen 'Utilities' rows: {n_utilities}\")\n",
|
||
"print(f\"Encoding (all zeros): {utilities_encoded[0] if n_utilities > 0 else 'N/A'}\")"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "0010d674",
|
||
"metadata": {
|
||
"lines_to_next_cell": 2,
|
||
"papermill": {
|
||
"duration": 0.003042,
|
||
"end_time": "2026-06-13T03:12:47.383401+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.380359+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"The `handle_unknown=\"ignore\"` setting maps unseen categories to all-zero\n",
|
||
"vectors. This is the correct behavior for split-aware encoding: the model\n",
|
||
"sees a neutral representation for categories it wasn't trained on, rather\n",
|
||
"than crashing or silently misencoding."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "a5829490",
|
||
"metadata": {
|
||
"lines_to_next_cell": 2,
|
||
"papermill": {
|
||
"duration": 0.002949,
|
||
"end_time": "2026-06-13T03:12:47.389332+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.386383+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"## 7. Split-Aware Preprocessing Demo\n",
|
||
"\n",
|
||
"The critical lesson: **preprocessing parameters must be learned on training\n",
|
||
"data only**. Fitting on the full dataset leaks future information into the\n",
|
||
"training representation."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "e21f8fbb",
|
||
"metadata": {
|
||
"papermill": {
|
||
"duration": 0.002924,
|
||
"end_time": "2026-06-13T03:12:47.395221+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.392297+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"### 7.1 Correct Approach: Fit on Train Only"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 29,
|
||
"id": "e988e92f",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:47.402040Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:47.401907Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:47.406636Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:47.406124Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 0.008846,
|
||
"end_time": "2026-06-13T03:12:47.407007+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.398161+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"Train: 1000 rows | Test: 200 rows\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"n_train, n_test = 1000, 200\n",
|
||
"\n",
|
||
"sample_data = pl.DataFrame(\n",
|
||
" {\n",
|
||
" \"timestamp\": pl.date_range(datetime(2020, 1, 1), datetime(2023, 12, 31), eager=True)[\n",
|
||
" : n_train + n_test\n",
|
||
" ],\n",
|
||
" \"returns\": np.random.randn(n_train + n_test) * 0.02,\n",
|
||
" \"volume\": np.random.exponential(1e6, n_train + n_test),\n",
|
||
" }\n",
|
||
")\n",
|
||
"\n",
|
||
"# Add regime-dependent extreme values (March = high volatility)\n",
|
||
"sample_data = sample_data.with_columns(\n",
|
||
" returns=pl.when(pl.col(\"timestamp\").dt.month() == 3)\n",
|
||
" .then(pl.col(\"returns\") * 3)\n",
|
||
" .otherwise(pl.col(\"returns\"))\n",
|
||
")\n",
|
||
"\n",
|
||
"train_df = sample_data.head(n_train)\n",
|
||
"test_df = sample_data.tail(n_test)\n",
|
||
"print(f\"Train: {len(train_df)} rows | Test: {len(test_df)} rows\")"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 30,
|
||
"id": "f5d64acf",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:47.420867Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:47.420697Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:47.425934Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:47.425490Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 0.013172,
|
||
"end_time": "2026-06-13T03:12:47.426544+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.413372+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"Learned from training data:\n",
|
||
" Mean: 0.000768\n",
|
||
" Std: 0.026290\n",
|
||
" Winsorize: [-0.0670, 0.0717]\n",
|
||
"\n",
|
||
"Train processed: mean=0.0026, std=0.8885\n",
|
||
"Test processed: mean=0.0720, std=1.0271\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"preprocessor = SplitAwarePreprocessor(\n",
|
||
" scale_cols=[\"returns\"],\n",
|
||
" winsorize_cols=[\"returns\"],\n",
|
||
" winsorize_limits=(0.01, 0.99),\n",
|
||
")\n",
|
||
"preprocessor.fit(train_df)\n",
|
||
"\n",
|
||
"train_processed = preprocessor.transform(train_df)\n",
|
||
"test_processed = preprocessor.transform(test_df)\n",
|
||
"\n",
|
||
"print(\"Learned from training data:\")\n",
|
||
"print(f\" Mean: {preprocessor._scale_params['returns']['mean']:.6f}\")\n",
|
||
"print(f\" Std: {preprocessor._scale_params['returns']['std']:.6f}\")\n",
|
||
"print(\n",
|
||
" f\" Winsorize: [{preprocessor._winsorize_params['returns']['lower']:.4f}, \"\n",
|
||
" f\"{preprocessor._winsorize_params['returns']['upper']:.4f}]\"\n",
|
||
")\n",
|
||
"print(\n",
|
||
" f\"\\nTrain processed: mean={train_processed['returns'].mean():.4f}, \"\n",
|
||
" f\"std={train_processed['returns'].std():.4f}\"\n",
|
||
")\n",
|
||
"print(\n",
|
||
" f\"Test processed: mean={test_processed['returns'].mean():.4f}, \"\n",
|
||
" f\"std={test_processed['returns'].std():.4f}\"\n",
|
||
")"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "d826bb16",
|
||
"metadata": {
|
||
"lines_to_next_cell": 2,
|
||
"papermill": {
|
||
"duration": 0.005368,
|
||
"end_time": "2026-06-13T03:12:47.437556+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.432188+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"The test set mean and standard deviation differ from 0 and 1 because the\n",
|
||
"scaler uses training-period parameters. This is correct behavior: the\n",
|
||
"test set represents unseen future data with potentially different statistics."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "58861b41",
|
||
"metadata": {
|
||
"papermill": {
|
||
"duration": 0.006491,
|
||
"end_time": "2026-06-13T03:12:47.450029+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.443538+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"### 7.2 WRONG Approach: Fit on Full Data (Leakage Demo)\n",
|
||
"\n",
|
||
"What happens when we cheat and fit on all data including the test set?"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 31,
|
||
"id": "b872d954",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:47.462083Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:47.461918Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:47.465242Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:47.464788Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 0.009059,
|
||
"end_time": "2026-06-13T03:12:47.465575+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.456516+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"Correct mean: 0.000768 | Leaky mean: 0.001217 | Diff: 0.000449\n",
|
||
"Correct std: 0.026290 | Leaky std: 0.027230 | Diff: 0.000940\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"full_data = pl.concat([train_df, test_df])\n",
|
||
"leaky_preprocessor = SplitAwarePreprocessor(scale_cols=[\"returns\"])\n",
|
||
"leaky_preprocessor.fit(full_data) # BUG: includes future data!\n",
|
||
"\n",
|
||
"correct_mean = preprocessor._scale_params[\"returns\"][\"mean\"]\n",
|
||
"leaky_mean = leaky_preprocessor._scale_params[\"returns\"][\"mean\"]\n",
|
||
"correct_std = preprocessor._scale_params[\"returns\"][\"std\"]\n",
|
||
"leaky_std = leaky_preprocessor._scale_params[\"returns\"][\"std\"]\n",
|
||
"\n",
|
||
"print(\n",
|
||
" f\"Correct mean: {correct_mean:.6f} | Leaky mean: {leaky_mean:.6f} | Diff: {abs(correct_mean - leaky_mean):.6f}\"\n",
|
||
")\n",
|
||
"print(\n",
|
||
" f\"Correct std: {correct_std:.6f} | Leaky std: {leaky_std:.6f} | Diff: {abs(correct_std - leaky_std):.6f}\"\n",
|
||
")\n",
|
||
"\n",
|
||
"# Transform test set with leaky parameters for comparison\n",
|
||
"leaky_test_processed = leaky_preprocessor.transform(test_df)"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "5bbe9f13",
|
||
"metadata": {
|
||
"papermill": {
|
||
"duration": 0.003014,
|
||
"end_time": "2026-06-13T03:12:47.471745+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.468731+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"The parameter differences appear small in this synthetic example. But across\n",
|
||
"dozens of features and thousands of test observations, the cumulative effect\n",
|
||
"of leaking future information can meaningfully inflate apparent performance.\n",
|
||
"More fundamentally, it violates the train/test boundary that gives evaluation\n",
|
||
"its meaning."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "02c10404",
|
||
"metadata": {
|
||
"papermill": {
|
||
"duration": 0.003042,
|
||
"end_time": "2026-06-13T03:12:47.477884+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.474842+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"### Leakage comparison figure\n",
|
||
"\n",
|
||
"Visualize how the two scaling approaches produce different test-set distributions."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 32,
|
||
"id": "cfc19778",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:47.485062Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:47.484956Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:47.654743Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:47.654013Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 0.174245,
|
||
"end_time": "2026-06-13T03:12:47.655133+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.480888+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"data": {
|
||
"image/png": "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",
|
||
"text/plain": [
|
||
"<Figure size 1200x400 with 2 Axes>"
|
||
]
|
||
},
|
||
"metadata": {},
|
||
"output_type": "display_data"
|
||
}
|
||
],
|
||
"source": [
|
||
"fig, axes = plt.subplots(1, 2, figsize=(12, 4), sharey=True)\n",
|
||
"\n",
|
||
"correct_vals = test_processed[\"returns\"].to_numpy()\n",
|
||
"leaky_vals = leaky_test_processed[\"returns\"].to_numpy()\n",
|
||
"\n",
|
||
"axes[0].hist(correct_vals, bins=np.linspace(correct_vals.min(), correct_vals.max(), 51), alpha=0.8)\n",
|
||
"axes[0].axvline(\n",
|
||
" correct_vals.mean(),\n",
|
||
" color=\"C1\",\n",
|
||
" linestyle=\"--\",\n",
|
||
" linewidth=2,\n",
|
||
" label=f\"Mean={correct_vals.mean():.3f}\",\n",
|
||
")\n",
|
||
"axes[0].set_title(\"Correct: Train-Only Fit\")\n",
|
||
"axes[0].set_xlabel(\"Scaled Return\")\n",
|
||
"axes[0].set_ylabel(\"Count\")\n",
|
||
"axes[0].legend()\n",
|
||
"\n",
|
||
"axes[1].hist(leaky_vals, bins=np.linspace(leaky_vals.min(), leaky_vals.max(), 51), alpha=0.8)\n",
|
||
"axes[1].axvline(\n",
|
||
" leaky_vals.mean(),\n",
|
||
" color=\"C1\",\n",
|
||
" linestyle=\"--\",\n",
|
||
" linewidth=2,\n",
|
||
" label=f\"Mean={leaky_vals.mean():.3f}\",\n",
|
||
")\n",
|
||
"axes[1].set_title(\"Leaky: Full-Data Fit\")\n",
|
||
"axes[1].set_xlabel(\"Scaled Return\")\n",
|
||
"axes[1].legend()\n",
|
||
"\n",
|
||
"fig.suptitle(\"Distribution of Scaled Test Returns: Correct vs Leaky Preprocessing\")\n",
|
||
"fig.tight_layout()\n",
|
||
"fig.show()"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "023c64c7",
|
||
"metadata": {
|
||
"lines_to_next_cell": 2,
|
||
"papermill": {
|
||
"duration": 0.003386,
|
||
"end_time": "2026-06-13T03:12:47.662111+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.658725+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"The location and scale shifts between the two panels demonstrate information\n",
|
||
"leakage. In production with many features, this systematic bias accumulates\n",
|
||
"and can meaningfully inflate Sharpe ratios and IC estimates."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "ca9430ab",
|
||
"metadata": {
|
||
"papermill": {
|
||
"duration": 0.003432,
|
||
"end_time": "2026-06-13T03:12:47.668764+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.665332+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"### 7.3 Walk-Forward Refit Demo\n",
|
||
"\n",
|
||
"In walk-forward evaluation (Chapter 6), the preprocessor must be **refit\n",
|
||
"at each fold boundary** using only data available up to that point.\n",
|
||
"Parameters drift over time as the data distribution evolves."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 33,
|
||
"id": "53d214a0",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:47.699517Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:47.699368Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:47.704097Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:47.703771Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 0.032438,
|
||
"end_time": "2026-06-13T03:12:47.704665+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.672227+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"data": {
|
||
"text/html": [
|
||
"<div><style>\n",
|
||
".dataframe > thead > tr,\n",
|
||
".dataframe > tbody > tr {\n",
|
||
" text-align: right;\n",
|
||
" white-space: pre-wrap;\n",
|
||
"}\n",
|
||
"</style>\n",
|
||
"<small>shape: (3, 7)</small><table border=\"1\" class=\"dataframe\"><thead><tr><th>fold</th><th>n_train</th><th>n_test</th><th>mean</th><th>std</th><th>winsor_lo</th><th>winsor_hi</th></tr><tr><td>i64</td><td>i64</td><td>i64</td><td>f64</td><td>f64</td><td>f64</td><td>f64</td></tr></thead><tbody><tr><td>1</td><td>300</td><td>300</td><td>0.003546</td><td>0.02998</td><td>-0.0807</td><td>0.0906</td></tr><tr><td>2</td><td>600</td><td>300</td><td>0.001783</td><td>0.028172</td><td>-0.0807</td><td>0.0746</td></tr><tr><td>3</td><td>900</td><td>300</td><td>0.001148</td><td>0.027003</td><td>-0.0766</td><td>0.072</td></tr></tbody></table></div>"
|
||
],
|
||
"text/plain": [
|
||
"shape: (3, 7)\n",
|
||
"┌──────┬─────────┬────────┬──────────┬──────────┬───────────┬───────────┐\n",
|
||
"│ fold ┆ n_train ┆ n_test ┆ mean ┆ std ┆ winsor_lo ┆ winsor_hi │\n",
|
||
"│ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │\n",
|
||
"│ i64 ┆ i64 ┆ i64 ┆ f64 ┆ f64 ┆ f64 ┆ f64 │\n",
|
||
"╞══════╪═════════╪════════╪══════════╪══════════╪═══════════╪═══════════╡\n",
|
||
"│ 1 ┆ 300 ┆ 300 ┆ 0.003546 ┆ 0.02998 ┆ -0.0807 ┆ 0.0906 │\n",
|
||
"│ 2 ┆ 600 ┆ 300 ┆ 0.001783 ┆ 0.028172 ┆ -0.0807 ┆ 0.0746 │\n",
|
||
"│ 3 ┆ 900 ┆ 300 ┆ 0.001148 ┆ 0.027003 ┆ -0.0766 ┆ 0.072 │\n",
|
||
"└──────┴─────────┴────────┴──────────┴──────────┴───────────┴───────────┘"
|
||
]
|
||
},
|
||
"execution_count": 33,
|
||
"metadata": {},
|
||
"output_type": "execute_result"
|
||
}
|
||
],
|
||
"source": [
|
||
"# Simple 3-fold walk-forward loop\n",
|
||
"fold_size = len(sample_data) // 4\n",
|
||
"folds = [\n",
|
||
" (sample_data[:fold_size], sample_data[fold_size : 2 * fold_size]),\n",
|
||
" (sample_data[: 2 * fold_size], sample_data[2 * fold_size : 3 * fold_size]),\n",
|
||
" (sample_data[: 3 * fold_size], sample_data[3 * fold_size :]),\n",
|
||
"]\n",
|
||
"\n",
|
||
"fold_rows = []\n",
|
||
"for i, (fold_train, fold_test) in enumerate(folds, 1):\n",
|
||
" fold_pp = SplitAwarePreprocessor(\n",
|
||
" scale_cols=[\"returns\"],\n",
|
||
" winsorize_cols=[\"returns\"],\n",
|
||
" winsorize_limits=(0.01, 0.99),\n",
|
||
" )\n",
|
||
" fold_pp.fit(fold_train)\n",
|
||
" params = fold_pp._scale_params[\"returns\"]\n",
|
||
" wparams = fold_pp._winsorize_params[\"returns\"]\n",
|
||
" fold_rows.append(\n",
|
||
" {\n",
|
||
" \"fold\": i,\n",
|
||
" \"n_train\": len(fold_train),\n",
|
||
" \"n_test\": len(fold_test),\n",
|
||
" \"mean\": round(params[\"mean\"], 6),\n",
|
||
" \"std\": round(params[\"std\"], 6),\n",
|
||
" \"winsor_lo\": round(wparams[\"lower\"], 4),\n",
|
||
" \"winsor_hi\": round(wparams[\"upper\"], 4),\n",
|
||
" }\n",
|
||
" )\n",
|
||
"\n",
|
||
"fold_summary = pl.DataFrame(fold_rows)\n",
|
||
"fold_summary"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "f1650ad7",
|
||
"metadata": {
|
||
"lines_to_next_cell": 2,
|
||
"papermill": {
|
||
"duration": 0.004538,
|
||
"end_time": "2026-06-13T03:12:47.713801+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.709263+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"Parameters vary across folds as the expanding training window incorporates\n",
|
||
"new periods. The variation here is small because the synthetic data is\n",
|
||
"stationary by construction; in real markets, regime shifts produce larger\n",
|
||
"swings in mean, variance, and tail bounds. The walk-forward protocol ensures\n",
|
||
"each test fold sees parameters estimated only from its past, so any drift\n",
|
||
"(small or large) is handled correctly."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "04a47588",
|
||
"metadata": {
|
||
"papermill": {
|
||
"duration": 0.004167,
|
||
"end_time": "2026-06-13T03:12:47.722080+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.717913+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"### 7.4 Preprocessor Serialization Demo\n",
|
||
"\n",
|
||
"Demonstrate save/load round-trip. Note: `pickle` is used here for\n",
|
||
"simplicity. For production systems, prefer `ml4t-engineer` serialization."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 34,
|
||
"id": "e312cb49",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:47.733754Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:47.733587Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:47.737054Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:47.736488Z"
|
||
},
|
||
"lines_to_next_cell": 2,
|
||
"papermill": {
|
||
"duration": 0.010662,
|
||
"end_time": "2026-06-13T03:12:47.737457+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.726795+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"Round-trip OK: fitted=True\n",
|
||
" Scale params: {'returns': {'mean': 0.0007676845036926392, 'std': 0.026290177923298144}}\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"with tempfile.NamedTemporaryFile(suffix=\".pkl\", delete=True) as tmp:\n",
|
||
" preprocessor.save(tmp.name)\n",
|
||
" loaded_preprocessor = SplitAwarePreprocessor.load(tmp.name)\n",
|
||
" print(f\"Round-trip OK: fitted={loaded_preprocessor._fitted}\")\n",
|
||
" print(f\" Scale params: {loaded_preprocessor._scale_params}\")"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "16d2daac",
|
||
"metadata": {
|
||
"papermill": {
|
||
"duration": 0.004147,
|
||
"end_time": "2026-06-13T03:12:47.747201+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.743054+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"### 7.5 Production Alternative: ml4t-engineer\n",
|
||
"\n",
|
||
"The manual `SplitAwarePreprocessor` above teaches the principle. In\n",
|
||
"practice, use the tested library version which provides the same\n",
|
||
"split-aware semantics with better performance and serialization."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 35,
|
||
"id": "ff6d6d3e",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:47.754752Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:47.754605Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:47.759561Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:47.758899Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 0.009493,
|
||
"end_time": "2026-06-13T03:12:47.760068+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.750575+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"Library scaler (train): mean=-0.0000, std=1.0000\n",
|
||
"Library scaler (test): mean=0.1026, std=1.1978\n",
|
||
"\n",
|
||
"Manual vs library difference: 0.000000\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"# Compare manual scale-only (no winsorization) against library\n",
|
||
"manual_scale_only = SplitAwarePreprocessor(scale_cols=[\"returns\"])\n",
|
||
"manual_scale_only.fit(train_df)\n",
|
||
"manual_test_scaled = manual_scale_only.transform(test_df)\n",
|
||
"\n",
|
||
"lib_scaler = StandardScaler(columns=[\"returns\"])\n",
|
||
"train_lib = lib_scaler.fit_transform(train_df)\n",
|
||
"test_lib = lib_scaler.transform(test_df)\n",
|
||
"\n",
|
||
"print(\n",
|
||
" f\"Library scaler (train): mean={train_lib['returns'].mean():.4f}, std={train_lib['returns'].std():.4f}\"\n",
|
||
")\n",
|
||
"print(\n",
|
||
" f\"Library scaler (test): mean={test_lib['returns'].mean():.4f}, std={test_lib['returns'].std():.4f}\"\n",
|
||
")\n",
|
||
"\n",
|
||
"manual_test_mean = manual_test_scaled[\"returns\"].mean()\n",
|
||
"lib_test_mean = test_lib[\"returns\"].mean()\n",
|
||
"print(f\"\\nManual vs library difference: {abs(manual_test_mean - lib_test_mean):.6f}\")"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "1f056392",
|
||
"metadata": {
|
||
"papermill": {
|
||
"duration": 0.004094,
|
||
"end_time": "2026-06-13T03:12:47.768090+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.763996+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"Comparing the manual scaler (without winsorization) against the library\n",
|
||
"implementation confirms they produce consistent results. Any small\n",
|
||
"differences reflect implementation details in standard deviation computation\n",
|
||
"(sample vs population)."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "68294745",
|
||
"metadata": {
|
||
"papermill": {
|
||
"duration": 0.003994,
|
||
"end_time": "2026-06-13T03:12:47.776208+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.772214+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"## 8. Final Verification\n",
|
||
"\n",
|
||
"Quick quality check on the cleaned data (in-memory, not persisted)."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 36,
|
||
"id": "e1436a9a",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-06-13T03:12:47.784782Z",
|
||
"iopub.status.busy": "2026-06-13T03:12:47.784504Z",
|
||
"iopub.status.idle": "2026-06-13T03:12:47.899260Z",
|
||
"shell.execute_reply": "2026-06-13T03:12:47.898851Z"
|
||
},
|
||
"papermill": {
|
||
"duration": 0.120047,
|
||
"end_time": "2026-06-13T03:12:47.899918+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.779871+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"US Equities: 15,160,868 rows, 3199 symbols, null_close=0, neg_close=0\n",
|
||
"ETFs: 275,536 rows, 100 symbols, null_close=0, neg_close=0\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"for name, df in [(\"US Equities\", us_equities_cleaned), (\"ETFs\", etfs_cleaned)]:\n",
|
||
" if df is not None:\n",
|
||
" null_close = df[\"close\"].is_null().sum()\n",
|
||
" neg_close = (df[\"close\"] < 0).sum()\n",
|
||
" print(\n",
|
||
" f\"{name}: {len(df):,} rows, {df['symbol'].n_unique()} symbols, \"\n",
|
||
" f\"null_close={null_close}, neg_close={neg_close}\"\n",
|
||
" )"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "7a0b87ce",
|
||
"metadata": {
|
||
"lines_to_next_cell": 2,
|
||
"papermill": {
|
||
"duration": 0.003456,
|
||
"end_time": "2026-06-13T03:12:47.907126+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.903670+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"Both cleaned datasets pass basic quality checks: no null or negative\n",
|
||
"prices remain. This notebook demonstrates the cleaning techniques —\n",
|
||
"downstream case study notebooks apply their own cleaning via loaders."
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "8bb7b5a0",
|
||
"metadata": {
|
||
"papermill": {
|
||
"duration": 0.003506,
|
||
"end_time": "2026-06-13T03:12:47.914229+00:00",
|
||
"exception": false,
|
||
"start_time": "2026-06-13T03:12:47.910723+00:00",
|
||
"status": "completed"
|
||
}
|
||
},
|
||
"source": [
|
||
"## Key Takeaways\n",
|
||
"\n",
|
||
"1. **Preprocessing must be split-aware** — fit parameters on training data only\n",
|
||
"2. **Domain filters catch obvious errors** — negative prices, impossible OHLC relations\n",
|
||
"3. **Spike detection identifies data artifacts** — single-bar reversals often indicate errors\n",
|
||
"4. **Winsorization handles outliers** — but bounds must come from training data\n",
|
||
"5. **Categorical encoding** must handle unseen categories at test time\n",
|
||
"6. **Walk-forward refit** captures parameter drift across market regimes\n",
|
||
"7. **Cross-dataset alignment requires care** — use as-of joins for different frequencies\n",
|
||
"\n",
|
||
"**Next**: See `05_signal_evaluation` for IC-based signal quality assessment.\n",
|
||
"**Book**: Section 7.1 discusses why preprocessing choices affect model validity."
|
||
]
|
||
}
|
||
],
|
||
"metadata": {
|
||
"kernelspec": {
|
||
"display_name": "Python 3 (ipykernel)",
|
||
"language": "python",
|
||
"name": "python3"
|
||
},
|
||
"language_info": {
|
||
"codemirror_mode": {
|
||
"name": "ipython",
|
||
"version": 3
|
||
},
|
||
"file_extension": ".py",
|
||
"mimetype": "text/x-python",
|
||
"name": "python",
|
||
"nbconvert_exporter": "python",
|
||
"pygments_lexer": "ipython3",
|
||
"version": "3.14.3"
|
||
},
|
||
"papermill": {
|
||
"default_parameters": {},
|
||
"duration": 12.055522,
|
||
"end_time": "2026-06-13T03:12:49.936727+00:00",
|
||
"environment_variables": {},
|
||
"exception": null,
|
||
"input_path": "07_defining_the_learning_task/02_preprocessing_pipeline.ipynb",
|
||
"output_path": "07_defining_the_learning_task/02_preprocessing_pipeline.ipynb",
|
||
"parameters": {},
|
||
"start_time": "2026-06-13T03:12:37.881205+00:00",
|
||
"version": "2.7.0"
|
||
},
|
||
"jupytext": {
|
||
"cell_metadata_filter": "tags,-all"
|
||
},
|
||
"ml4t_provenance": {
|
||
"source_py_blob": "d76b4eb05adcd341ddf925c38b643297367cc643",
|
||
"executed_at": "2026-06-13T03:12:50.255597+00:00",
|
||
"executor": "cpu-local",
|
||
"production": true,
|
||
"parameters": {},
|
||
"notes": "executed-state finalization batch (2026-06-13 run)"
|
||
}
|
||
},
|
||
"nbformat": 4,
|
||
"nbformat_minor": 5
|
||
}
|