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

2794 lines
157 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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>&quot;count&quot;</td><td>97179.0</td></tr><tr><td>&quot;null_count&quot;</td><td>0.0</td></tr><tr><td>&quot;mean&quot;</td><td>-0.013974</td></tr><tr><td>&quot;std&quot;</td><td>0.110737</td></tr><tr><td>&quot;min&quot;</td><td>-19.154743</td></tr><tr><td>&quot;25%&quot;</td><td>-0.051824</td></tr><tr><td>&quot;50%&quot;</td><td>-0.022179</td></tr><tr><td>&quot;75%&quot;</td><td>0.001284</td></tr><tr><td>&quot;max&quot;</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>&quot;test&quot;</td><td>497436</td></tr><tr><td>&quot;valid&quot;</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": "iVBORw0KGgoAAAANSUhEUgAABJ8AAAGECAYAAACYpIGOAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjksIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvJkbTWQAAAAlwSFlzAAAPYQAAD2EBqD+naQAAYtdJREFUeJzt3Xd4FFUXx/HfpodAegFC770K0psCCgrSRBABRUAQBUWKCFjoUqQL0mtQRIqALyCCVEUUEUSQ3pNAEhKSkL7vH4E1IQlJIJsN4ft5Hh/Z2Zm5Z2Z3Z0/O3nvHEBMTbRQAAAAAAABgBlaWDgAAAAAAAAC5F8UnAAAAAAAAmA3FJwAAAAAAAJgNxScAAAAAAACYjY2lAwCA3GLhinVatHK9JMlgMCivk6MKFfRR7RqV1altC3m4u5rWve5/Q+17vKfJnw5WgzrV0913bGyclq3ZqEb1nlKZkkUzFE+77oPUtEFtvdunqyRpzJT5OnfhipbMHpP5g7vPr78f0/mLV/VK++eSLc/KNrLSxq27tGzNJgXeCFKVSmU0d/LIVNc7evyUFq1cr9PnLioqKkaeHq6qUrGM+vboKG8vjyyLZ8v2PRo79Svt3LBQeRwdHmlfazdu17S5y3Vw28pUn2/XfZD8A24+cB8jB/dR6xaNHjqGH3/+RdHRMRnaR9J4bGysld/bU03q19Ib3V6So0PGz8XDfCZyqn/PXtTyNZt05NhJhd0Ol6tzPtWoWkGd27VUhbIlLR3eA126cl3bdx1Q53bPKV9eJ4vEULdlN73fv7s6tW1h1nYWrlinbzft0P/Wzsvy/d777pAkT3dXVSpfWm+/+YoKFfTJ0rZys/5DxsrVOZ/Gjxpo6VAAAKmg+AQAWSivUx59MW6oJCk8IlKnzlzQ+s07tfGHXfpi3FCVK11ckuTh7qoF0z9R0cIFMrTf2Lg4LVq5XgV8vDL8h/bE0YPk4pz34Q4kHb/+fky79h1KUXx6vetLio6JMUubDyso+JYmz1qiDm2aq1mjp+Wcxh/IR4+f0ttDxqlRvZoa8d6bsrez0/lLV7V910FdDwzK0uJTdpo4epBiY+NMj9/76HM1bVhLbZ5ralrmW8D7kdr4ac+vuhV2O8MFrBZN66lT2xaKjYvTkb/+0ZLVGxR6+7ZGvNc7w20+zGciJ9q97zeNmjBb1SqV08C+r8rL0103bgZr208HNGjEJG1f95WlQ3ygS1f8tWjlerVu3shixafcIOl3x9XrgVqw/Fu9M3yCVn81MVNF2SfZkAGvy8bG2tJhAADSQPEJALKQtbWVKpUvZXpc56kqav/CM+r3wViNGj9baxZOlrW1lezsbJOtl5WiomPkYG+nsqWKmWX/D5ITf6W/ci1A8QkJerFlY5UqUSTN9b7bvFNFixTUuJHvymAwSJJq16yszu2ek9FozK5ws9z97wNrayt5e7qb7f2XER7urqb2q1cupxs3Q7T1x70aPrCXrKwsMyNAfHyCEhISZGubfanRjaAQjZkyX82b1NWoD/qa3ndSYoFu3y9HHmn/aR2TJY4VD5b0u6NS+VLK7+2htwaP0YFDR/VMo6dTrH/vOm9O2dFGVipe1NfSIQAAHoCsAwDMLF9eJ73d6xW9P3KyDv1xTHVrVU112N3eg79r0cr1unj5umxtrVXYt4DefvMV1ahSXs+89KYkaezUrzR2amJPiO+WfSFJat/jPX0yrJ9+OXxM+375Q+VKF9esSR+mGHZ3z88HDmvuoq/lH3BT5coU1/CBvUxJe1rDAZMOp1u4Yp381m2VlDjcRZJaNW+oUR/0TXXY3b9nL2rmV6t0/J8zsrO1Ud1a1TSw76tyd3NJ1uaYEQN0+M+/9ePuX5Qnj4NebNlEvbq1S7cYsXbjdn2zYZsCbgTJ29NdHdo0V5f2z0tKPpzltX4jJKU9xCw8IkJurs7JCgD33L9s49Zd+mbjNl25GiAnJ0dVq1RWI97vrbxOeXTsxGkt/3qT/vn3nCIiolTY10evdmqtls3qP/A4omNitGD5Ou3YdVAhoWEqWqig+r3xsurVrmZaJyYmVjO/Wq1tP+2XwWBQq+YN5eP96D2y9hz4XUtWb9C5C1eUN28ePf9sA73Vs5NsbBLThMAbQZoxf5WO/HVSEXfuyMvDTc2b1lXfHp00Zsp87dr3m6T/3g+9urXTm691yHD7pUsUUUxMrG6F3ja9L0LDwvXl4q+15+Dvioi4ozKlimnQW6+qYrnEP9DT+kxcD7iht4eO18r5E1SyWGFTG/cPybn3Xu3Zta3mL1mrS1f9NXvSh/p+2886d+GK+r3+smYuWK2r1wJVplRRDXv3DZUoVsi0v03/2y2/dVt1zf+GHB3sVbxoIQ0Z0DPZOunZ9MNuxcbF6d0+r6b6vkv6GYyPT9CS1eu1edseBd8KVaECPurRpa1aNqtnWie9Y7p/ebXK5dJ97SXpzLlLmrd0rY4eP6X4+HgVK+J7dx1rDfl4qqTE65Ak5ffx1Prl01Mcy5btezRxxiJt/Xpush5S5y5c0at9h2vGhOGqXaOSjh4/pS8Xf63T5y9Jknzze6tHl7apFmAyKiEhQSvXbtamH3Yr8Gaw8nt7qkeXNmrd/L/rwP5fj+jr9dt05twlRcfGqHgRX/Xu3lFP16yc5n6NRqOmzV2ubT/t1xfjhmr4p9PVtlXTFO/9/kPGKl9eJ036+L0Mx1z2bi/Z6wE3JN0dRl2/lvLmzaMNW39ScEiY9m1dlqFju/fer12zspat2aTgkFA9Va2Chg3sJW9P98R27l6HU/suueYfqBnzVunw0b8lo1S9SnkN7PuqCvvmN7URH58Yx5bte+QfeFOuLvlUq3oljfqgr2mdR7nOSInvlZlfrdKJU+cUGxsnH28PdWzTXB3bNE92nPc+4/eGSc6YMFyTZy3RmfOXVaRQAb3f7zVVq1zOFFdMTKymz1upHbsPysrKSi+0bCQvD3fNmL8yzeHMAIDMo/gEANmgRtXysra21t8nz6huraopnr9yLUAjxs7Uyy+11IDeXRQTE6uTp88r7Ha4JGn2pBEaMGy8enZtq/q1E/8g9XB3VVDwLUnSrAWr1bh+LY376B1ZWaddrPEPvKmZ81epT4+Osrez04IV6zRoxCR9s2SK7O0y9gt3m+ea6PLVAP1+9G9NHJ34x5SrS75U1w25Faa3h4xTsSIF9enw/rpzJ0pzF3+tdz+cqCWzxiTreTFn0Ro1rV9L40e+q8N//q3Fq9areFFfPdu4TpqxbNy6S9PmLleX9s/r6aeq6PejJzTrq9WKjY1V985t1Oa5JnJzddaU2cv0ybD+8i3gneYQszKlimmZ3yYtXrVeLZvVT3O9Jas3aMHydWr/wrMa8GYXRUfHaP+hPxV5J0p5nfLIP/CmqlQoo3atn5Gdna3++vtfjZ36lQwGg1o0rZfqPiVpxJiZ+ufUOb35Wnv5FvTRzj2/aOjH07R49hjTsLK5i7/W9//brb49O6l4UV9t/GGXftr7a5r7zIgff/5FH0+co5daNdNbr7+sq9cC9OWSb5SQYDQVLj+bPF/RMTEaNugN5XPKo6v+N3Tx8jVJiUMtAwKDdDsiQkMGvC5J8vZ0y1QM/jeClCePg1ycE99HMTGxevfDCQoPj9SAN7vIzdVZ6zfv1LvDJ+qbxVPk4e6a5mfi3h/rGXE94IbmLFyjN159Se5uriqY30uSFBAYpNkL/dTzlbayt7fVrAV+GjV+tlbOnyCDwaAjx07q85lL1Lt7B1UuX0oRkXd07J8zCo+INO27/5CxkpTm/GKSdOTYPypXunian5+kFiz/VivXblGvbu1UvkwJ7d73mz6ZNFcGg5K9r9I6ptSWZ+S1v3Dpmvq+/5mKFCqgoe++Lpd8efXP6fMKuBmsZg1q6Z3eXTVrwWpNGD1Qnu5uafamalSvpibNXKyf9x/WCy0bm5b/+PMvcndzUc2qFRQREakPRk9Vw7o19Mar7WSUdPb8ZYWHR6a6z4yaOne5ftixV2+82k5lShXTb38c1/hpC+SSL5+pwHfN/4Ya1Kmurh1bycpg0MHDR/X+yM81d8ooVa1YJsU+ExISNGnGYu05+LtmTRqhsqWK6fnmDfXDj/vUq1t7UzHx6vVA/XnslCZ9kvHCkyT5330fe7i5mpZt331AxYskFjnj4xMyfGySdOyfM7p45boG9nlV0TGxmrtojYZ/+oUWz0o+P9/93yUxMbF6Z9gE2dhY68OBb8ra2koLV3yn/kPGaeW8Caah3ZNmLtIPP+5Tt04vqHrlcgq7HaFd+w6Z9vuo1xlJGvLxVBUrUlCfDOsnW1sbXbpyXRGRdx54HqOiYzRmyny90u45ebi7atHK7/ThmBlav3y6HBzsJUlzFvlpy449euv1l1WscEFt2b5HP+7+JVOvFwAgfRSfACAb2NvZydUlr4JDwlJ9/t+zF5XH0VHv9P6vl1LSHi/ly5aQJBUq4JPqcKmK5UppyICe6cZxK/S2Jn38nqrc/WOqbOni6tTzfW3ZvlftX3gmQ8fi7eUhD3dX2dqmP3TwXg+p6eOGyskpjySpsG9+vTnwE+3adyjZH83VKpXTu31flZQ43O2Xw3/p5/2H0yw+JSQkaOHK79S6eUPTdk/XrKyIiEgtX/O9Ord7Tt5eHipeJLFXV6kShZP1hLlft46tdezv01qwfJ0WLF8nT3dXNahTQ106PK8ihRLn5rodHqFlazapc7uWGti3m2nbJg1qmf7dvEld07+NRqOqVS6nwJvB2vTD7jSLT78dOa4Dh/7UnMkfqUaV8qZjuXzFX0v9Nmr8yHcVGnZb67fs1JuvtVfXjq1M63TpPSzNY0qP0WjUnIV+ev6ZBhryTmLhSDUry9bOVlNmL1WPV16Ui3M+nTh1Vp9++LYa1qkhSaqRpH5aqKCPnPM5KcGYkPGhfEaj4uLjFRcbpyPHTmrDlp3q+UpbWd8tnP7vp/06d+GKVn81ydS7olaNSnql1xCtXrdV7/Tumu5nIiNCw8I1c+KHKeaMCrsdrvlfjDa1nWA0avin03Xx8nUVK1JQJ06eVanihdXjlTambRrWrZlsHxkZPnjjZojKlkp/vqrQsHB9vX6bXu/aVq93fUlS4pDewJvBWrTyu2Tvq7SO6f7lGX3tF6/6Tk5Ojvpy6ijTEKzaSXoD3ftslC1ZTAXuFrpSky+vk+rUrKIff/4lWfFp555f1LRBbVlbW+nSVX+FR0Rq8Ns95JTHUZIe2PMoIy5f9df6zTv10eDept5AtWtU0s3gW1q86jtTgSbpZOUJCQmqUa2Czl+8qu//tztF8Sk+PkFjp87Xb0eOa87nH5l6u73YsrFWfP29/jj6j2pWqyApsceXm6tzqj863C8uPl6SdO16oCbPWqo8eRxUq3rFZOtMGTPY9ENBRo9NSvwhYMH0j5Xf21NSYg+1t97/TAd/O5ostvu/S77bvFMBgUH6evEUU0G+YrmS6tDzfW3Y+pN6vNJGFy5d0/f/+1nv9XtNL7/U0rTts00Sr91ZcZ25FXpb1/xvaNIn76tU8cTreK3qldI9p9HRMRr0Vjc9VS3xPHq4u6pH/4905NhJ1a1VVaFht7Vh6y717t7B1GO2zlNV9Gqf4enuGwCQOZaZWAEAnkAPmjaoZLFCioiI1GeT5+nX34/pTlRUpvZdP0mh6kHcXJ1NhSdJKuDjqbKli+vEqbOZai+jTpw6p9o1KpkKT1LiHzcFfLx09O9/k637dM3kf0gUK+KrwJvBae478GawbgaFqNl9w3GeaVxHEZF3dPb85UzF6uSUR7MmfaivvvhYPV5pI98C3vr+f7vV8+2ROnX6vCTp+D9n7t7VrXGa+wm7HaFpc5er3WsD1bBVDzVs1UMbt+7SpavX09zmtyN/y8PdRVUqlkksytz976nqFXXy38S2z56/rJiY2GRFDisrKzW6r+iRGZeuXJd/YJCeaVwnebtVKygmJlZnL1yRJJUuWVTzFn9jGlLzqPy++0ENW/VQ07a99P7IyapZtaJe6/yi6fnDR46rXOniKpDfyxSTJFWrXM50PrKCl6dbqpOV5/fxTDak6F4B8977sXTJovr37EVNn7dSR46dTDah+z2zJ43Q7EkjMhBFyuF29zt38YqioqPVrOH97/WndemKv0Ju/VfUTuuY7l+e0df+8J8n9GyjOlky988zjevo8J8nFBp2W1Ji0f3SFX892zjxuHwL+CiPo4M+njhXew78rtvhEY/c5uE//5aVwaAm9Z6677NVQf+evWTqQRR4I0ifTZ6nF7u+owZ3P7e//n5Ml6/6J9tfQkKCRk2YrT+O/qO5k0cmG2ZZ2De/qlUupy079khKLLr8sHOfnnumvmysHzwRdmhYuOl60bnXEF3zD9TYEe/I0+O/XoRPVauYrIdqRo9NSpz77V7hSZKqViwjN1dnnTh1Llkc93+XnDh1VmVKFUvWE9Tby0NVKpTRX3+fkiT9cfSEJKl184apHltWXGec8znJx8tDn89crB93/6LgW6EPPJ/32NramAr60n+f5Rt3P8um6+rdgpeUOMw6I3ehBQBkDj2fACAbRMfEKDQsXO5uzqk+X7RwQU365D2t+Pp7DR45WdY21mpc7ym91+81ubmmvk1SbnfnyUl3vVT25ebqbBq+l9VuBt9KdRJYdzdn3b47pPCevE7J75Rla2ujmJjYNPd9L2b3+47d3TXxcdjtzP/hajAYVLlCaVWuUFpS4h/H/T4Yo8WrN2jSx++Z/mj2dHdNcx9jp8zX8ZNn9HrXl1S8qK+c8jjqu807tffg72luExp6W0HBoWrYqkeK56zv9qAJCkn8Y+v+1zAj74+03ApLfA3eHzk51ecDbwRJksaMGKD5S9dqxvyVuh0eqdIliuidPl0z1PMgNc89U18vv9RSd6KitXXHXm3Zvkffff+j2r/4bGJcoeE6/s+ZVM/Ho96ZL6l775X73X/XNtu7c9Lcez/WrlFJHw3urbUbEucbc3S013PPNNCAN1/J1J3JvDzdFHD3HD9IUNCtxHjvu378914PN70P0jqm+5dn9LUPCwuXxwPe75nRsG4N2dhYa9e+3/RSq2b68edf5O3prqqVykpKLDDMmDBcC1es08jxs5SQYNTTNSvp/f49Hvp1Dw0NV3xCgp5t3yfV54OCQ+Tp4aYhn0xTZGSUenfvoEIFfeToYK8Fy9clK+xJicO4fvntqJo0qGXq9ZXUiy0ba8rspfpgQE/9ffKM/ANu6sWWaRer78nrlEczJw6XwWCQu5uLvDzcUswDdv9rmJFju3eXzoxe++//LgkKvpXiGislXnfvFYhCb4fL0cE+2Y8MSWXFdcbKykrTxw/T/KVrNW7aAkXHxKhKhdJ6r3/3B95cI4+jQ7JeiPeGhUbf/Szfu666uiQ/P/c/BgA8OopPAJAN/jj6j+Lj41WpfOk016n/dHXVf7q6wiMitf/XPzVj3kpNm7tcY0YMSHf/hgz0npCU4g+pe8vuFYjs7GwlSXFxyXty3H6IQo6UWKRJrc3gkDDThLoP694fxPfv/94v4s75Hv2272VKFlXt6pV1/tJVSTLNSXQz+Faq8/REx8Ro/6EjGvx2z2TDGI0JOx7YjnO+vPLydHvghMQed/8ADLkVZppn5d7jh+Vy9xwNH9hLZVIZ/nVvviBvT3eN+qCvEhISdOLUOS1csU5DP/5CG1ZON52TzHBzdVH5MonD5mpUKS//wJtasGKdnm/eQI4ODnLO56TyZYr/N0QnCVtb2wfu2+5uz5C42Pvfw5FyvS/W1Cb5zqjWzRupdfNGCrkVpt37f9OM+avk5Oig/r1eyfA+alQpr2V+mxQaFp7sNb2fh4erpHuv/X/H8N97/b9t0zqm+5dn9LV3ds6bZcXpPI4Oqle7mn78+Re91KqZftrzq5o1qp0stkrlS2n6+GGKio7Rb0eOa+b8Vfp44hwtnPHpQ7XpnM9J1tbWmj9ttKysUp4bN1cXXbkWoH/PXNS0sUOSDUGLjo5J9RjGjBigD0ZNlae7a4rXu1mjp/XFlyu0c8+v+uPoCVUsV1LFiqR/FzZrayvTZyJN972GGTm2e9K69t9fWLz/u8TD3VXnL15NsW1wSKjpGuuSL6/uREUrIiIy1QJUVl1nihUpqAmjByouLk5/Hj+luYvW6INRU7Rx1cyHvkvmvevqrdDk19VboQ9/XQUApI5hdwBgZrfDIzRn0RoVKuiToZ4ieZ3yqGWzempUv6ap6HGv50X0A3oCZUTIrTD9lWS4m3/gTZ06c0EVypaUlPhLuI2NtS5c+m+S18g7UTr2z+lk+7G1sX5gr6R7KpQrqV9/P5ZsUtgTp87qesCNVCfxzQxvT3d5erhp557kE27v3POrnPI4qmTxtOd3Sk1qwziMRqOuXg8w9TipVL6U7O3ttHXH3lT3ERsbp4QEo+ySTLocEXlHe3858sC2n6peUcHBoXJ0cFD5MiVS/CdJJYsXlp2dbbIeVAkJCdrzgB5V6SlSqIC8PN10PeBGqu3eX1iyskq8HXyvbu0VFR0t/4DEng82Ng/upZaefq931q3Q2/r+fz9LSjwfV64FyMfbI0VM9+Z7SeszcW+y8wtJJioOCAzSxSvXZA5urs5q1/oZVatU1vR5zagXn2siGxtrzVqwOtXn9/+a+L4pUbSQHOzttXPPoWTP/7TnVxUplP+her9l9LV/qlpF7dzzq6JjUhZipJQ9SdLTvHEd/fnXSe395Q9dvR6oZxvXTXU9B3s7NaxTQy+0bJzp85pUzWoVlJCQoIiIyFSP09bWxlRksktS2LwecFN/nfg31X3Wql5JY0e+o9XrftDS1RtTxN28SV2t27RDu/cdTvXOmlklI8d2z6kzF5INZTv6978KuRWmCmUfXPCqWK6kTp0+r2v+gaZlgTeDdeyf06pSsawpDkna+uO+VPeRVdeZe2xsbPRUtYp6pf3zuhl8S7cfYUL6/66rf5iWGY1G7Uvnmg0AyDx6PgFAFoqPT9Dxf85IkiIj7+jkmfNav3mnoqJj9MW4oaYJle+3fstOHf/njOo8VUWeHm66fNVfP+05pOefbSAp8Q+8gvm99NOeX1WyWCHZ2dmqVPEimY7P1SWfPv38S/Xp0Un29rZauOI7ubk6q3WLxLk6rKys1LBuTa1Z/4Py+3gor5OT/NZtTXEnvKKFCyo4JFRbtu9RiWKF5OqcL9XJhru0f17rN+/UoBGf67WXX9CdqCjNXfS1ShYvrKYNamc6/qSsrKz0Zrf2mjRzsVyc86p2jco68tc/Wr95p956vVOG7953z4QvFiohwaimDWrJt6CPbt+O0Jbte3T63CWNG/mupMThWK93fUnzl65VXFyc6taqqtjYOB049Kfe6NZO3p7uKl+mhBav2iCnPI4yWBm04uvNyuvk+MC7MtWuUUlPP1VZAz+cqG4vv6ASRQspIvKOTp+7qOiYWPV/o7NcnPPppVZNtXDFd7K2tlbxor7a9MPuTM8Pdv85fLf3q/p08peKiLyjurWqysbGRtf8A7XnwO8aP/JdxcXHa9CISXr+2QYq7FtAsbGx8lv3gzzcXVT0bo+OooULau/BP/TzgcOmoqBXkrlq0lOxXEnVrlFJa777nzq82FzPP9tA67fs1NtDxqlrx9byze+l0LBwnTh1Tu7uLurS/vk0PxPeXh4qX6a4vlr2rezt7WRMMGrZmk1Z0hPungXL1ynsdrhqVCkvF5d8+vfMRR3566T6vdHZtM6AYeMl6YHzPnl5uGnk4D4aPXGObtwM1gstG8vLw003gkL04+5fdOTYSW1fN18uznnVuV1LLfXbYOohs3vfbzpw6Kg++/DthzqGjLz2Dg726tWtnd54d7T6DR6rrh1aydk5r/49e0Euzvn0YsvGpqFnG7b+pOZN6sje3t5UIExN3drVZO9gp89nLFbB/F6qWK6k6bn9vx7R5m171KheTfl4e+jGzRBt2PqTnqpaMc393XP63EX9tDd5cc7VJZ9qVCmvdq2badSEOerWqbXKlSmumJhYnb94VZeuXteI93qraOGC8vZ018wFq9Sne0dF3onSwhXr5OXhnmZ7DevU0MdD39Ink+bKyckx2YTlLz7XWOu37JT93UKUuRQtXDDdY7vHzSWfBo+aot6vdTDd7a5sqWLpToTeunkjrfxms977aLJ6d+8gaysrLVq1/u71qJkpjratmmrWgtUKuRWmapXLKTwiUrv2HtKYEQOy5Dpz5twlzVqwWs80riPf/N66HR6hld9sVukSRR7YazA9Ls751Pb5plqwYp2sbaxNd7uLiLzzSD0jAQApUXwCgCwUHhGp3oM+kcFgkFMeRxUq6KOWzeqrU9sWD5w3pVTxItr3yx+aOX+Vwm5HyMPdVW2fb6re3TuY1hn67huatWC13h0+UTGxsfpu2ReZji+/t6d6vNJGcxd/Lf/AmypXurg+Hd4/WaHmg7d7aOL0RZo8e6mc8zqpR5e2OnbitM7dnRRWSpzo+I+jJzRn4RqFhIapVfOGGvVB3xTtubk6a/bnIzTrq9UaPXGObG1sVLdWVQ16q1uat2TPjLatmiomNlZfr/+fvtmwTd6e7nqnT1fTXYsyo/0Lz2rrjr1avGqDgoJvKW/ePCpe1FfTxw9LdsetHq+0kXM+J32zYZs2bP1J+fI6qVqlsnJyTJzr59Ph/TVp5mJ9NjmxaNCxTXNFRUfr201pD70zGAyaMGqQlq3ZpK/X/08BN4LknC+vSpcokuyP2rd7dVFcXLwWr1ovKyuDWjarry7tn9fMr1LvOZMRzzapIycnRy3z26jN2/bIytog3/zeqv90ddnY2sjKykolixXWNxu2KeBGsBzs7VSxXClNHz/cNAl1hxef1b9nL2jc1AW6HR6hXt3a6c3XOqTTcnI9u76k/h+M1c49v6hF03qa8/lHWrD8Wy1cvk7Bt0Ll5uqsCmVLqkHd/yYGTu0zUSC/lz4d/rYmTF+oTyfNk7eXm97u1UVr1v/w0OfofuXLlNCa9T/ox59/UWRklPL7eKjXa+3Uud1/d/pKSEh4wB7+07RhbS0s4K3lazbpiy9XKOx2hNxc8qlmtQqaNfG/O2717t5R1tbWWr95pxbd+k6FCvrok2H9Hqm4kd5rLyUWFuZPHa25i7/WhOkLJSXeDOCt1ztJSrxpwTu9u2rtxm36duN2eXm5a/3y6Wm2ea9H07afDiSbZF5KvHOiwSDNW/KNQkLD5OqST/VrV9dbr7+c7rF8/7+fTT3n7qlepZzmTh6pDwb0VGHfAtr0wy4tWLFOTnkcVayIr2kuJjs7W00YPVBTZi/TiLEz5e3prp5d2uqPv/5Jdt27X/MmdXUnKlqTZixSHkcHUy+n8mVKyMvTTTWqlFfeNOZByirpHds9lSuU1lPVK2n6vJUKCQ1TjSrlNXxgr3T3b2dnq5kTP9TM+as04YuFMhqNql6lvCaMGpis6DNkwOvK7+2p7/+3Wyu++V5urs6qXeO/6+ajXmfc3V3k7uaiZX4bdTMo8fpcs2r5TA1zTcvbb76iuLh4LVrxnaysDHrumQZ68bnG+nr9tkfeNwDgP4aYmOgH3H8JAAAAQEadv3hVXfsM08yJwx96Uv6s1H/IWLk659P4UQMtHcpj451hExQXH68vp4y0dCgAkGvQ8wkAAAB4RKFht3Xx8nV9tfxblShWSE9VS3+4ICzv9z9P6O9TZ1S2VHHFxcXpx59/1eE//zYNtwYAZA2KTwAAAMAj2vfLEY2btkBFCxfQx0P7MWfQY8LR0V57Dvyu5Wu+V0xsrAoXzK+RH/RRs4aPNi8hACA5ht0BAAAAAADAbFK/7RIAAAAAAACQBSg+AQAAAAAAwGwoPgEAAAAAAMBsKD4BAAAAAADAbCg+AQAAAAAAwGwoPgEAAAAAAMBsKD4BAAAAAADAbCg+AQAAAAAAwGwoPgEAAAAAAMBsKD4BAAAAAADAbCg+AQAAAAAAwGwoPgEAAAAAAMBsKD4BAAAAAADAbCg+AQAAAAAAwGwoPgEAAAAAAMBsKD4BAAAAAADAbCg+AQAAAAAAwGwoPgEAAAAAAMBsKD494eq27KafDxy2dBjJLFyxTt37jTDb/nPiMQMAkBNc97+hui276d+zFy0dSqr+OHpCdVt20+3wCEnSlu171Lx9HwtHlfX6DxmrL75cYekwAADIMjaWDuBRBQXf0lK/jTpw6E/dCAqRm4uzSpcsqs7tWqpW9UqWDi+FLdv3aPq8ldrx3VcZ3ua6/w217/HeA9cZObiPWrdolOl4NvvNVr68TpneLjX7fjmiVd9u0b9nLig+IUElivqqw4vNHyquR9Wu+yD5B9xMtszL002bVs1Kdsz3zu2yueNUpmTRbI8TAIDMGDNlvsLDIzXpkwfnBTlVWjlNy2b19Mmw/mZpc+GKdVq0cr0kydrKSnnz5lHxIr5qXL+W2r/wjOzsbDO8rz+OntDbQ8dr+7r5j5w/jZkyX1t37E2x/JvFUzRh1CDZ2FiblrXrPkidX3pOr7R/7pHaBADAUh7r4tN1/xvq8/5nyueURwPe7KKSxQsrLi5ev/7+l6bMXqavF01+qP3GxsbJ1jblqYmLi5ONTfafMm8vD232m216vOrbrfr18F+aOXG4aZmTUx7Tv+PjE2QwSFZW6Xds83B3zZIY127crunzVqhbpxc09J3XZWNjrb0H/9DnM5fo7IUrerdP1yxpJzN6d++gts83NT2+dz6y6pgBAMDDmTlxuEoULWR6bG9vZ9b2ShQtpJkThyvBaFRoWLiO/PWPlqzeoP/t3Kc5kz+SUx5Hs7afljpPVdHIwcl7brm6OMvamsEJAIDc5bEuPk2evVQGg7Ro1qdydHAwLS9RrJBeaNnY9Ng/8KamzVmuw3/+LYOVQXVqVtHgt3vI3c1FUuIvYnsO/K6ObZprqd8m+Qfe1IH/rVDdlt00ZEBPHTx8VIePnNCrnVrpzdc6aM+B37Vo1Xe6cPGaPD1c1ap5Q/Xo0lY21om/UN0Oj9CcRWu058Dvioi4o0IFfdTvjc7K42ivsVMTezzVbdlNktSrWzu9+VqHBx6ntbVVsoJJHkf7ZMvu9aYaPaSv5i7+Wpev+GvtkqkKCQ3TvCVr9e/ZC4qLi1fpkkU1qO+rKlu6uGlfdVt208SPB6lxvadMv0ZOGDVQazdu19+nzqpwwfwa+u7rqlyhdJrxBQQGaeZXq9T5pefU743OpuVdO7aSra2Nps1drmca1VbFcqVMvxjOnDhccxd9rfOXrqp0iaIaObi3ihYumGLfR46d1DvDJmjjyhnJzsEXX67QqdPnNW/a6DTjyuPomGqhKekx3/v1tUf/jyRJ1auU09zJI9PcJwAAOdnZC5c1e4Gfjh4/JQcHez1ds7IG9u0mV5d8kqSDvx3VUr+NOnfhiqysrFSpfCm91+81FSrok+r+4uMTNGH6Qh07cVrDB/XS20PGadHMT1W+TAnTOmu++5++Xv+D1i37IkM/fEmSi3O+FN/RqfVGvh0eoRYd+mrO5yNUo2qFhzgjiZLmTV4ebipVvLBqVa+k7v1HaOU3m9W3ZydJ0g8/7tM3G7bp0pXrcnCwV82qFTSoXze5u7rouv8NvT10vCSpRYe+kqRWzRtq1Ad9M31e77GztU01V+k/ZKxKlyiq9/q9pv5Dxso/4KZmzF+pGfNXSpIOblv50OcCAABLeGx/VgkNC9cvh/9ShxebJys83XOvK3RCQoKGfvKFwm6Ha+6UkZo5Ybiu+d/QyPGzkq1/5VqAdu37TRNGD9TyueNMyxeu/E6N6z2llfMn6IWWjfXnsZP6bPI8vfxSS61eMEnD3n1DW7bv1TK/jab23h85Wcf+Pq1PhvbT6gWT1O+NzrK2tlLlCmU06K1ucsrjqM1+s7XZb7a6dmyd2M6KdWrXfdBDn4+o6Git+GazPhz0plZ9NVFurs6KvBOlVs0baN7U0Vo44xMV9vXR+6OmKCLyzgP3NW/pWnXt2ErL545T4UL5NXriHMXFx6e5/k/7DikuLl5dO7ZK8dxLrZopj6ODtu86mGz5/KVr9U6frloya4xsrK00btqCVPddvXI5+Rbw0g8795mWxcXFafuuA8kKjA9r0cxPJSX+ArvZb7YmjBr0yPsEAMASbodH6J2hE1SmZFEtnjVGX4wbquCQUI0c91/OExUVrS7tn9fiWZ9p1qQPZWVl0PDPpishISHF/mJiYvXRuJk6fe6i5k0dpeqVy6lW9YravH1PsvW2bN+jVs0bycrKSu26D9LCFevMfqxZoViRgqr7VFXt3v+baVlcfLz69Oio5V+O16SP39P1gJsaOyXxh0NvLw9NGDVQkvT1osna7Ddb7/V7TVLmzmtmTRg1SN6e7urdvYMpfwQA4HHz2PZ8unItQEajUUULF3jgeoeP/K1z5y9r3bIv5OPtIUkaPeQtde0zTCdOnVWFsiUlSbFxcRo95C25uTon275F03rJihzjpi7Qa51fVOvmifMY+RbwVp8eHTRn4Rr16tZevx35WydOnZXfgs9VpFAB0zr35HXKI4PBkOJXLhfnfMnWy6y4uHgNGdBTpZPMW/RUtYrJ1hk+sJead+ijI3+dVIM61dPcV9eOrVT/6cTne7/WQV37DNOVqwEqViRlzyRJunzFX3md8sjTwy3Fc7a2NiqY31uXr/onW963ZyfVqFJekvRa5xc1eNQURcfEyN4uZbf7F1s20Zbte9St0wuSEueWiomJ1TONn07zGCRp7uI1+mrZWtPjt15/WS+/1DLZOm4uia93ar/AAgDwOPl20w6VKVU0WS/kj97vrbbdBurSlesqUqiAmjasnWybj97vo+df7qfzl66qZLHCpuV37kRp8Kgpio2N1ZzPP1Leu8P7X3yuiT6fuUQD+7wqOztbnTp9XmcvXNbnd+eg8i3gLRfnfOnG2ue9T2VlMJgefzl1lJyzaA7KzChauIB+/eOY6fGLSXI+3wLeer//a3rjndGKvBOlPI4Ocs6XGKObq3OyOZ8yel7vt//XI2rWtpfpcZ1aVTV+5LvJ1nFxzisra6s0e3QDAPA4eGyLT5IxQ2tduHxN3l4epsKTJBUv6qt8efPowqVrpuJTfm/PFIUnSSpfpniyx2fOX9JfJ/419XSSpPiEBMXExCoqKlqnz16Ut6e7qfCUUZ3atlCnti0ytU1StrY2KlWiSLJlwSGhmr90rf746x+F3ApTQkKCoqJjFHDjZhp7SVSq+H9J0r0kJyQ0TMVUMFmC1LJZfQ0b+MZDxVuq+H+xmtq4Fab83p4p1m3VoqHmL1ur4/+cUaXypbRlxx41a/R0qj3eknq1Y2u1at7Q9PjekAMAAHKj0+cu6fejJ5J9V99z9XqgihQqoMtX/bVg+bf6++RZ3Qq7LWNCYj4VEBiUrEgyesIceXu5a9akEXJIMh9T43pPaeqcZfr5wGE1b1JXW3bsVY2q5VUgv5ckafakjN2tdsyIASqWZLi9j5eHgoJvPcxhm/gH3lTX3sNMj7u/0kY9u7R94DZGo2TQf0Wwk6fPa+GK73Tm3CXdDo9QQpLzU7yob5r7yeh5vV+NqhU05J2epseODvYPjBcAgMfVY1t8KlQwvwwGgy5evp4l+0vry97hvuWRd6LU+7UOalz/qRTr2tnZmn3CzLTY29nJkOQXREn6bPI8hd0O13v9XlN+b0/Z2tqoz3ufKjY27SF0kpJNqn5vl8a73caXJRmS6OSUODln4UL5FR4RqRtBIfK6r/dTbGycrl4PUI2q5e9r4787uNyL+16idj93Vxc1eLqGNm//WQXze+ngb39pzuSPHngMUmJvpsK++dNdDwCA3ODOnSg1eLqG+vfqnOI5Tw9XSdIHo6eqgI+nhg/qJS8PNyUkGPVq3+GKjYtLtn7d2lW1becBHf/ndLKe1La2Nnr+mQbasn2PmtSvpe27DpiGnmWGj5dHiu9og9XdfMD4Xz4QF/fgnCUpTw+3ZHmKc7686W5z4fJVFbxbOLsTFaVBIybp6ZpV9MmwfnJ1cVbAjSANGjEpxfm5X0bP6/0cHezJVQAAT4THds4nF+e8erpmZa37fofuREWleP52eIQkqVjhggq8EaSAwCDTc+cvXtXt8MgH/oKVlrKliunilesq7Js/xX9WVlYqWbywAm8G69KV1ItiNjY2WTL+PyOOnTitTm1bql7taipRrJDsbG11K/T2I+0z6fG6uyZO2N60QS3Z2FjL79utKdZfv2Wn7kRFq0XTuo/Ubpvnm2jnz79qw9af5FvAW1Urlnmk/d1jc/euhtn1mgAAYC5lSxXTuYtXVCC/V4ocxdHBQaFht3XpynX17NJWtapXUrEivqZ86X7tX3hW/Xt11tCPp+mPv/5J9lyb55votyPHte77HxUfn6DG9WtlSfyud4fCJ+0BdfrsxQxvb2NtneyYXZwfXHy6cOmafjn8l5o0SIz/4uXrCg0LV/83Oqta5XIqVqSgQm6FJm/DJmXekJnz+rBsszF/BADAHB7b4pMkfTCgpxISEtTrnY+1a+8hXb7qrwuXruqbDdvUe1DiRNK1alRSieKF9cmkuTp1+rz+PnlWn02ep+pVyiW7U0tGvfFqO/3w4z4tWvmdzl24oguXrmrH7oOavzRxbqEaVcqrWqVy+nDMDB36/Ziu+Qfq4G9HdfC3o5KkAj6eirwTpd+OHNet0NuKioqWJK3duF0Dho3PojOTqJCvj/63c58uXLqqv0+e0SeT5pqlZ1Z+b0+93auLvt7wP81b8o0uXLqmK9cC5Lduq+YsXKMuHVqpYrlSj9TG0zUryymPo5b6bdQLLRplUeSJczbY29vpl9/+UnBIqMIjIrNs3wAAmEN4RKT+PXsx2X8BgUHq0Ka5bt+O0OgJc3Ti1FlduRagXw7/pbFT5is+PkH58jrJxTmvNv6wS5ev+uvwn39rxvxVabbTqW0L9enRSUNGT9XR46dMy4sV8VXFcqU0d/EaNW9SN9mwvAHDxmvtxu0PdVwO9naqVL6UVnz9vS5cuqo//vpH85d9+1D7ul98fIKCgm/pRlCIzpy/rLUbt5vuKPdqp8Sbv/h4ecjW1kZrN23X1euB2nvwdy1ZvSHZfvL7eMpgMGjfr0cUcitMkXeiMn1eH0YBH0/9eeykAm8GP/IPiQAAWMJjO+xOSpwIcunssVrqt0kzF6xWUPAtubrkU7lSxTX07vh5g8Ggzz95T9PmLFe/D8bKYGVQnZpVNPjtHg/VZp2nqmjKZ4O1eNV6rfhms2ysrVW0cEG1ea6JaZ3xowZq1oLVGj1xju5ERatQQR/1vzv5Z5WKZdSu9TMaNX62QsPC1atbO735WgeFht3W1euBj3pKkhnxXm9NmrFIPd4eKR8vD731+suatWB1lrZxzyvtn5NvAS+t/narvtmwTfEJCSpRtJCGvNMzS+5KZ2VlpVbNG2r5mk16/tkGWRBxIhtra73f7zUtXrVBC1Z8q6qVymru5JFZtn8AALLaH3/9ox79kw8/f/G5xhrxXm/N/2K05ixao0EjJikmNk75vT1V56kqsrIyyGAw6LMPB+iLL5erW98PVaRQAb3X/zW9PWRcGi0lfr8bjQl6f9RkfTF2qKrc7Xn84nNNdOzEab3QMvkPQlevByo07OGLIx+931vjpi1UzwGjVKRQAQ3o9YoGjpj00Pu759zFK3qhywBZW1nJySmPihcpqO6d26j9C8/Izs5WUuIPUiMH99G8JWu1dsN2lSlVTAN6d9XQj6eZ9uPt6a43X2uvLxd/rXFTF+j5Zxto1Ad9M31eM6t3946aNGOxOvUcrJjYWB3ctjLL9g0AQHYwxMREZ2zmbsDCxk1boFuhYZr86WBLhwIAwBNt8ar1+mnvIa2cN8HSoQAAgMfAYz3sDk+G8IhIHT1+Stt3HVCnNg9/R0AAAPBoIu9E6eyFy/p20w6+kwEAQIY91sPu8GQY+sk0nTh1Tu1aP6PaNStbOhwAAJ5YU+cs047dB9Wobs0sGVYPAACeDAy7AwAAAAAAgNkw7A4AAAAAAABmQ/EJAAAAAAAAZkPxCQAAAAAAAGZj9gnHo2NiNHr8HJ2/dFX2dnZyc3XWkHd6qrBvfvUfMlb+AUHK6+QoSXq+eUN1af98uvs0Go2Kj4+XtbW1DAaDuQ8BAADAosh9AADA4yxb7nbXtlVT1a1VVQaDQWs3bteE6Qs1d/JISdLAt15V43pPZWp/8fHxati6p/ZuWSobG27YByDrhJzepPjYSFnb5pFb6TaWDgcAJJH7ADkN+QIAZI7Zh93Z29mpXu1qpl/pKpUvpesBN83dLAA8lJDTmxR88huFnN5k6VAAAEAORb4AAJmT7T+dfbNhmxrVrWF6PHfR1/pq2bcqXsRX/d7oLN8C3qlu9+2mHVr3/Q5JiV3PAQAAcjNyHwAAkFtka/Fpqd9GXbkWoFkTP5QkfTykn3y8PWQ0GvXtph36YPQU+S34PNVtO7Zpro5tmkuS4uLi1LB1z+wKGwAAINuR+wAAgNwi2+52t2rtFv28/7CmjR0iBwd7SZKPt4ckyWAwqFPbFrp2/YZCw25nV0gAAAAAAAAws2wpPvmt26oduw9qxoThypfXSZIUFx+v4JBQ0zq79h6Su5uzXJzzZUdIAAAAAAAAyAZmH3YXeCNIM79aLd8C3howdJwkydbWVrM//1CDR01RTGysrAxWcnHJq88/ed/c4QAAAAAAACAbmb345O3loYPbVqb63JLZY8zdPAAAAAAAACwo2+92BwA5mb1rCdnEeMraztnSoQAAgByKfAEAMofiEwAk4VtvhKVDAAAAORz5AgBkDsUnAADwQO26D1J0VIw2rZ4pG5vE1OH3P09owLDxevmllnqv32sWi+3vk2c0cfpiRcfEyNvTXaOHviVvT/dU112yeoO2bN8jSXq2cR299frLkqSlqzfqp72/mta76h+oNs810cC+3XT4z781d9HXuhMVJYMMqvd0NfV/o7OsrLLthsEAACAb5fa8Jyo6RpNmLNa/Zy5IkgoW8NKI93rLzdVZx06c1uRZSyRJcXHxqlKpjN7v1112draPHDuZEwAASJePt4f2HvzD9Pj7bbtVvkxxC0YkJSQk6OOJczXorW76ZvEU1a1VVdPnpT7P5JFjJ7Vj10GtmDdeq7+apF9/P6b9vx6RJPXs2lbLvxyv5V+O18IZn8rG2kYtm9WXJOXL66QxIwbIb8HnWjJnjI6dOK0fftyXbccIAACyX27OezZs+UnR0dFaOX+CVn01Ue6uLlr17RZJUukSRbR41mda/uV4rZw/QSG3wrRu849ZEj89nwAAQLpat2ikzdt/VtOGtRUeEanj/5xR86Z1FRkZJUlatXaLdu75VfEJ8XJzcdawgb1UwMdTvx05rq+WfavomFjFxcbplQ7Pq81zTSRJY6bMl62tja5cC1DgjWCVKFZIYz4cIFvbjKUnJ0+fl7W1tWpWqyBJeql1s7ttxcjezi7Zuj/+/Iuee7a+HB0cJEkvtGykHbsPqv7T1ZOtt+fA7/Lxcle50okJZtlSxUzP2dvZqXSJoroecCPT5w8AADw+cnPeYzAk9n6Ki4uXwcqgO1FRKlm8sCTJwcHetI/YuDhFR8fIIMMjnct7KD4BQBJXD4xXfEyYrO2cmc8BOULI6U0KOb0p3fXsXUukeM9ePTBe0bfOpbmNW+k2civdJkNxVKlYRt99/6NuBIVo78E/1KzR07K+O/Rs208HdOnKdS2Y/omsra30w4/7NGX2Ek0dM0RlSxXXvKmjZW1tpdCwcPV8+yPVqVlZ3l4ekqTTZy9pzucjZGtrq34fjNGufYfUomk9/f7nCc2Yn/qvefVqV9Nbr7+sgMAgFfDxNC13yuMopzyOuhl0S74FvJNtExAYpKoVy5geF/Dx0o+7f0mx7++37daLd5PE+wUF39KufYc05bPBGTpnAHIv8gXAfMyV+5D3JOY9L7VupmMnTqtV5/6ysrJSxXIl1bFNc9O61/1vaOgnX+jq9QDVq11NHV58NkPnLD0UnwAgiehb5xQXFSwbh9THTgPZLT42UnFRwemuZxPjmWJZfEzYA7eNj43MVCzPPVtfW7bv0Z4Dv+vT4f217af9kqQ9Bw7rn3/P6/UBIxP3m5Bg2iY07LbGf7FAl6/4mxKxsxeumJKwxvVrmn5lq1C2pK5eD5Qk1axWQcu/HJ+p+B7V9YCbOvr3v/rswwEpnouIiNSQj6eqW6fWKl+mRLbGBSDnuXL2sNycpJAI6dWxXdJcb/dWv2yMCsgdzJX7kPckOvT7MSUYjdq8ZrasDFYaM2W+Fixfp749O0mSCuT30op54xV5J0qfTvpSu/f/puZN6j5yuxSfAADIwaxt82SoGJra7b6t7ZwfuK21bZ5MxfL8sw3V8+2RKuKbX4V985uWGyV1f+VFvdSqWYptPp+5RPVqV9WEUQNlMBjU4+2PFBMba3rezva/buJWVlaKj4+XpAz9Aujj7aHrATdNyyMi7yg8MlKeHq4ptvHx9pB/4H/rXg+4IR9vj2TrbNn+sxrVrSEX57zJlkdE3tGgjyarYd2a6tKhVaoxAQCArGGu3Ie8JzHv2bB1l1o0rWcaqteyWT0tW5Oyp1keRwc926SOtv10gOITAAC5XWa6iN8vq4eCeHm4qd/rL6to4YLJljeuV1Or121V0wa15eKcV3FxcTp74YrKliqm2+ERyu/tKYPBoCPHTurMuUsZaisjvwCWK11c8fHx+v3PE6pZrYI2bPlJDZ6unmLeA0l6pmFtTZm9TJ3atpC1lbU2b9ujXt3am55PSEjQlu179OF7vZNtF3knSu999LnqPFVFr3d9KUOxAwCAh5dTcp/cmvf4FvDWoT+OqUXTxILSgUN/qkSxQpKky1f9VcDHUzY2NoqNjdPP+w+r1N35oB4VxScAAJBhL7RsnGJZy2b1FRoWrgFDE5Om+Ph4vdCyscqWKqb+b3TW5NlLtWT1BpUuWVQVypbKslisrKz08dB+mjRzsWJiYuXp4aqPh/QzPf/+yMnq3b2DypcpoRpVK+iZxnXUre+HkqRnGtdRgzr/TTb+25G/ZTBYqVb1isna+Gb9Np04dU5RUdH6ef9vkqRmDZ9Wz65ts+w4AABAzpQb855e3dpr0oxFerXPcElS0cIFNWzgG5Kk34+e0NoN2029sp6qXlGvv/pSlsRviImJNmbJnrJRXFycGrbuqb1blsrGhvoZgKxzbuubpjmfSrRaaOlwAEASuQ+Q0/y6or1pzqdhfvZprsecTwCQyMrSAQAAAAAAACD3ovgEAAAAAAAAs6H4BAAAAAAAALOh+AQAAAAAAACzYcZKAEjCrXQbxcdGyto2j6VDAQAAOdSOY9ZytJPuxFg6EgB4PFB8AoAk3Eq3sXQIAAAgh/vxOH9GAUBmMOwOAAAAAAAAZkPxCQAAAAAAAGZDf1EASCIh9o6MMsogg6xsHS0dDgAAyIHsbY0ySDJKio41WDocAMjxKD4BQBIXdryjuKhg2Ti4q0SrhZYOBwAA5ECfdYyRm5MUEiEN87O3dDgAkOMx7A4AAAAAAABmQ/EJAAAAAAAAZkPxCQAAAAAAAGZD8QkAAAAAAABmQ/EJAAAAAAAAZkPxCQAAAAAAAGZD8QkAAAAAAABmQ/EJAAAAAAAAZkPxCQAAAAAAAGZjY+kAACAnKVj3QxkT4mSw4vIIAABSN2e7rWyspbh4S0cCAI8H/roCgCQc3EpaOgQAAJDDXQpiAAkAZAZXTQAAAAAAAJgNxScAAAAAAACYDcPuACCJ8OuHZYyPkcHaTnkLPGXpcAAAQA5UuXC87GykmDjp2GVrS4cDADkexScASCLwyDzFRQXLxsFdeQsstHQ4AAAgB+rWIE5uTlJIhDTMj+ITAKSHYXcAAAAAAAAwG4pPAAAAAAAAMBuKTwAAAAAAADAbik8AAAAAAAAwG4pPAAAAAAAAMBuKTwAAAAAAADAbik8AAAAAAAAwG4pPAAAAAAAAMBuKTwCQhMHGUVY2jjLYOFo6FAAAkENFxxp0Jybx/wCA9NlYOgAAyEmKt5hl6RAAAEAON/pbO0uHAACPFXo+AQAAAAAAwGwoPgEAAAAAAMBszD7sLjomRqPHz9H5S1dlb2cnN1dnDXmnpwr75lfwrVB99vk8Xb0eKDtbW33wTk9Vr1zO3CEBAAAAAAAgm2TLnE9tWzVV3VpVZTAYtHbjdk2YvlBzJ4/U3EVfq1L5Upo+fphOnDqr4Z9N13fLvpCNDVNRAbCMG8eWKT4mXNZ2eeVVuYelwwEAADlQh9pxymNvVGS0QesO8bcLAKTH7MPu7O3sVK92NRkMiXeCqFS+lK4H3JQk/bTnV7Vr/YwkqULZkvJ0d9Mff500d0gAkKbbl/cq7OJO3b6819KhAACAHKp2yXg1LJug2iXjLR0KADwWsr1M/82GbWpUt4ZCw24rLj5eHu6upucK+Hgq4EZQqtt9u2mH1n2/Q5JkNBqzI1QAAACLIfcBAAC5RbYWn5b6bdSVawGaNfFDRcfEZGrbjm2aq2Ob5pKkuLg4NWzd0wwRAgAA5AzkPgAAILfItrvdrVq7RT/vP6xpY4fIwcFeLs75ZG1lraDgW6Z1rgfclI+XR3aFBAAAAAAAADPLluKT37qt2rH7oGZMGK58eZ1My5s1qq31W3ZKkk6cOqsbQSGqUYW73QEAAAAAAOQWZh92F3gjSDO/Wi3fAt4aMHScJMnW1laLZn6qt3u9ok8//1KdXh8sWxsbfTK0H3e6AwAAAAAAyEXMXunx9vLQwW0rU33O3c1FMyYMN3cIAAAAAAAAsJBsm/MJAAAAAAAATx6KTwAAAAAAADAbJlgCgCSc8tdUfGy4rG3zWjoUAACQQx27ZCUneyki2tKRAMDjgeITACThU6OfpUMAAAA53Mr9tpYOAQAeKwy7AwAAAAAAgNlQfAIAAAAAAIDZMOwOAAAAAMygSasuGVpv91Y/M0cCAJZF8QkAkrj40xDFR4XI2sFNRZtNtnQ4AAAgBxrRNkYueYwKjTRo/EY7S4cDADkexScASCI+KkRxUcGWDgMAAORgLnmMcnOSJKOlQwGAxwJzPgEAAAAAAMBsKD4BAAAAAADAbCg+AQAAAAAAwGwoPgEAAAAAAMBsKD4BAAAAAADAbCg+AQAAAAAAwGwoPgEAAAAAAMBsKD4BAAAAAADAbGwsHQAA5CTzfgiTnY2NYuLCdGh2lzTX273VLxujAgAAOcm6Qzays5Fi4iwdCQA8Hig+AUASh85aWzoEAACQw5EvAEDmMOwOAAAAAAAAZkPxCQAAAAAAAGbDsDsASMLHJUFWBinBKAWEUp8HAAApkS8AQOZQfAKAJN5vFSs3JykkQhrmZ2/pcAAAQA5EvgAAmUOZHgAAAAAAAGZD8QkAAAAAAABmQ/EJAAAAAAAAZkPxCQAAAAAAAGZD8QkAAAAAAABmQ/EJAAAAAAAAZmNj6QAAAAAAwJyatOqSofV2b/UzcyQA8GSi5xMAAAAAAADMhuITAAAAAAAAzIZhdwCQxPgNdjIYJKPR0pEAAICcinwBADKH4hMAJBF6x2DpEAAAQA5HvgAAmcOwOwAAAAAAAJgNxScAAAAAAACYDcPuACCJhmXjZW9rVHSsQXtPWVs6HAAAkAORLwBA5lB8AoAkXqgRJzcnKSRCJJMAACBV5AsAkDkMuwMAAAAAAIDZUHwCAAAAAACA2TDsDgAAAAAsqEmrLhlab/dWPzNHAgDmQc8nAAAAAAAAmA3FJwAAAAAAAJgNxScAAAAAAACYDcUnAAAAAAAAmA3FJwAAAAAAAJgNd7sDgCQCQg26EyOF3bF0JAAAIKciXwCAzKH4BABJTNtqZ+kQAABADke+AACZky3Fp2lzl2vvL3/IP+Cmls0dpzIli0qS2nUfJDtbG9nbJV68u3duo2eb1MmOkAAAAAAAAJANsqX41LRhbXXr1Fp9B49J8dyYEe+YilEAAAAAAADIXbKl+FS9crnsaAYAAAAAAAA5jMXnfPps8jwZjUZVKFtS/d/oLDdXZ0uHBOAJ1qtJrPI6GBUeZdCi3baWDgcAAORA5AsAkDkWLT59OWWk8nt7Ki4uTvOXfqsxU+Zr2tghqa777aYdWvf9DkmS0WjMzjABPEHKFEiQm5MUEsF1BoBlkfsAORf5AgBkjkWLT/m9PRODsLFR53Yt1blX6oUnSerYprk6tmkuSYqLi1PD1j2zI0QAAACLIPcBAAC5hZWlGr4TFaXb4RGmxzt2H2TicQAAAAAAgFwmW3o+TZyxSAcO/ang4FANGjFJeRwdNGPCcH04ZoYSEhJkNBrlm99bo4e8lR3hAAAAAAAAIJtkS/Fp+MBeqS5fPndcdjQPAAAAAAAAC7HYsDsAAAAAAADkfhSfAAAAAAAAYDYUnwAAAAAAAGA2FJ8AAAAAAABgNtky4TgAPC72nrSWo510J8bSkQAAgJyKfAEAMofiEwAksfkIl0UAAPBg5AsAkDnpXjUHj5qsqWOGpFg+5OOpmvzpYLMEBQC5RZNWXTK03u6tfmaOBAAAy+I7EQCeXOnO+fTn8VOpLj96/N8sDwYAAAAAAAC5S5o9n77ZsE2SFBcXb/r3PVevB8jD3cW8kQEAAAAAAOCxl2bx6ef9hyVJ8XHxpn9LksHKIHdXF40c3Nf80QFANpvUJVpuTlJIhDTMz97S4QAAgByIfAEAMifN4tOcyR9JkmZ+tVrv9umabQEBAAAAAAAg90h3wvF7haew2xGKvHMn2XP5vT3NExUAAAAAAAByhXSLT0eOndSYyfMVcOOmjMb/lhsM0v4fVpgzNgAAAAAAADzm0i0+TZqxWG2ea6LWLRvJ0YHxzAAAAAAAAMi4dItPN4KC1aNLGxkMhuyIBwAAAAAAALmIVXor1K9dTUePn8qOWAAAAAAAAJDLpNvzKY+jo4Z8PE11a1WVh7tLsucG9u1mtsAAAAAAAADw+Eu3+BQXF6fG9WpKkm7fjjB7QAAAAACAlJq06pKh9XZv9TNzJACQOekWn0Z+0Dc74gAAAAAAAEAulG7xyT/wZprP5ff2zNJgAMDSFu22lY2VFJdg6UgAAEBORb4AAJmTbvGpfff3ZDBIRmPi46Q3vdv/wwpzxQUAFvHv9XTvwwAAAJ5w5AsAkDnpFp+2r5uf7PHNoBAtXrVe9Z+ubragAAAAAAAAkDukW7LP65Qn2X/Fivhq2MBeWrjiu+yIDwAAAAAAAI+xdHs+pSYqKlq3Qm9ndSwAYHFlCiSY5nCgSz0AAEgN+QIAZE66xacZ81cmexwVFaNffz+mRvVqmi0oALCUXk1i5eYkhURIw/zsLR0OAADIgcgXACBz0i0+3b4dkeyxo6OD3nj1JT33TH2zBQUAAAAAAIDcId3i08gP+mZHHAAAAAAAAMiFMjTn07ETp7V1x14F3gyWt6e7WjVvqMoVSps7NgAAAAAAADzm0p0db8fugxo0YpKMMqpapbIyGAx676PPtX3XgeyIDwAAAAAAAI+xdHs+LV29UVPGfKDqlcuZlrVoWlefz1yiFk3rmTU4AAAAAAAAPN7S7fl0IyhYVSqUSbascoUyuhkcYragAAAAAAAAkDukW3wqXrSQNmzdmWzZxh92qVgRX7MFBQAAAAAAgNwh3WF3g97qpvdHTta6TT+qQH5PXfe/qZDQME0bOyQ74gMAAAAAAMBjLM3iU1x8vIKCbql8mRJau2Sq9v/6p27cDFaLpu6qWqmMPD3csjNOAAAAAAAAPIbSLD6t+e4HXbh0TSMH91Fepzxq2ey/ycXHf7FAxQr7qmvHVtkSJABkl2F+9pYOAQAA5HDkCwCQOWnO+bR910F17/xiqs916/SC/rdzn9mCAgAAAAAAQO6QZvHJP+CmihQqkOpzRQoVkH9gkNmCAgAAAAAAQO7wwLvdhYaFZ2o5AAAAAAAAkFSaxadK5Utp+64DqT63Y/dBVSxX0mxBAYClvFA9Tp2ejtML1eMsHQoAAMihyBcAIHPSnHC8xytt9P6oyYq8E6XmTerIy8NdN4KCtWP3L1rx9feaNnZIdsYJANmiYbl4uTlJIRHS5iNpXiIBAMATjHwBADInzStl1Upl9emwtzV93gp9tWytaXkBHy99Ory/qlQsky0BAgAAAHhyNGnVJUPr7d7qZ+ZIAABZ5YFl+gZ1qqtBneq6fNVfIbfC5OqSL81JyAEAAAAAAID7ZaiPaGHf/Crsm9/csQAAAAAAACCXeeDd7gAAAAAAAIBHQfEJAAAAAAAAZkPxCQAAAAAAAGZD8QkAAAAAAABmQ/EJAAAAAAAAZpOhu909qmlzl2vvL3/IP+Cmls0dpzIli0qSLl/112eT5ys07LbyOjlq5OC+KlGsUHaEBACp+ve6lfI6GBUeZbB0KAAAIIciXwCAzMmW4lPThrXVrVNr9R08JtnySTMW66VWTdW6RSP9tPeQxk6dr8WzxqSxFwAwv0W7bS0dAgAAyOHIFwAgc7Jl2F31yuXk7eWRbFnwrVD9c/qcWj5TX5LUtEEtBdwI1uWr/tkREgAAAAAAALKBxeZ8CrwRLE93V9lYW0uSDAaDfLw8FHAjyFIhAQAAAAAAIItly7C7rPDtph1a9/0OSZLRaLRwNED2adKqS4bX3b3Vz4yRIKnMvC4A8DDIfQA8rIzmKeSOALKLxYpP3l7uuhl8S3Hx8bKxtpbRaFTAjSD53Dc8756ObZqrY5vmkqS4uDg1bN0zG6MF8KR4v1WMnB2lsDvStK12lg4HwBOM3AfIucgXACBzLDbszt3VRWVLFdO2nfslSbv2/SZvT3cV9s1vqZAAQD4uRhV0M8rHhV4GAAAgdeQLAJA52dLzaeKMRTpw6E8FB4dq0IhJyuPooG+XTtOwd9/Q2KlfadmaTXLK46iPBvfJjnAAAAAAAACQTbKl+DR8YK9UlxctXFALpn+SHSEAAAAAAADAAiw27A4AAAAAAAC5H8UnAAAAAAAAmA3FJwAAAAAAAJgNxScAAAAAAACYDcUnAAAAAAAAmA3FJwAAAAAAAJiNjaUDAID7NWnVJUPr7d7ql+Vtb/7DRva2RkXHGrJ839nJkucQAIDskNHvOnN40vIFiZwBwKOh+AQASew9ZW3pEAAAQA5HvgAAmcOwOwAAAAAAAJgNxScAAAAAAACYDcPuACAJF0ejDAbJaJRC7zze8zgAAADzIF8AgMyh+AQASYx4KUZuTlJIhDTMz97S4QAAgByIfAEAModhdwAAAAAAADAbik8AAAAAAAAwG4pPAAAAAAAAMBuKTwAAAAAAADAbik8AAAAAAAAwG4pPAAAAAAAAMBsbSwcAIOs0adUlQ+vt3upn5khSl9H4nkScGwDA44rvMABAeuj5BAAAAAAAALOh+AQAAAAAAACzYdgdACQxbautrAxSgtHSkQAAgJyKfAEAMofiEwAkERBKh1AAAPBg5AsAkDlcNQEAAAAAAGA2FJ8AAAAAAABgNgy7A4AkapeMl52NFBMnHTprbelwAABADkS+AACZQ/EJAJLoUDtObk5SSATJJAAASB35AgBkDsPuAAAAAAAAYDYUnwAAAAAAAGA2DLsDkKYmrbpkaL3dW/3MHAnMJaOvscTrDAAAAODh0PMJAAAAAAAAZkPxCQAAAAAAAGZD8QkAAAAAAABmQ/EJAAAAAAAAZkPxCQAAAAAAAGbD3e4AIInQSIMk493/AwAApES+AACZQ/EJAJIYv9HO0iEAAIAcjnwBADKHYXcAAAAAAAAwG4pPAAAAAAAAMBuKTwAAAAAAADAb5nwCnkBNWnWxdAhZIqPHsXurX4b32a1+rJzspYhoaeV+24cNLVcyx/kGzIX3K4CHkdFrB/kCAGQOxScASKJykQS5OUkhEZL2WzoaAACQE5EvAEDmMOwOAAAAAAAAZkPxCQAAAAAAAGZD8QkAAAAAAABmQ/EJAAAAAAAAZkPxCQAAAAAAAGZD8QkAAAAAAABmY2PpANp1HyQ7WxvZ29lJkrp3bqNnm9SxcFQAAAAAAADIChYvPknSmBHvqEzJopYOAwAAAAAAAFksRxSfACCnOHTWWnnsjYqMNlg6FAAAkEORLwBA5uSI4tNnk+fJaDSqQtmS6v9GZ7m5Ols6JABPqHWHcsRlEQAA5GDkCwCQORa/an45ZaTye3sqLi5O85d+qzFT5mva2CEp1vt20w6t+36HJMloNGZLbE1adcnQeru3+pk5EgB4fGT02pnVuBYjt7FE7oMnAzkucgLeh8CTxeLFp/zenpIkGxsbdW7XUp17pSw8SVLHNs3VsU1zSVJcXJwatu6ZXSECAABkO3IfAACQW1hZsvE7UVG6HR5herxj90EmHgcAAAAAAMhFLNrzKTgkTB+OmaGEhAQZjUb55vfW6CFvWTIkAE+4zzrGyCWPUaGRBo3+1s7S4QAAgByIfAEAMseixSffAt5aPnecJUMAgGTsbY1ytJOiYplfBQAApI58AQAyx6LD7gAAAAAAAJC7UXwCAAAAAACA2VB8AgAAAAAAgNlQfAIAAAAAAIDZUHwCAAAAAACA2VB8AgAAAAAAgNnYWDoAAI+/Jq26WDoEAABypcx8x+7e6mextpH7ZfT9kNXvQwC5Az2fAAAAAAAAYDb0fAKAJFbus5GdjRQTZ+lIAABATkW+AACZQ/EJAJI4dtna0iEAAIAcjnwBADKHYXcAAAAAAAAwG4pPAAAAAAAAMBuG3QFAEkU8EmRjLcXFS5eCqM8DAICUyBcAIHMoPgFAEm+3iJWbkxQSIQ3zs7d0OAAAIAciXwCAzKFMDwAAAAAAALOh+AQAAAAAAACzofgEAAAAAAAAs6H4BAAAAAAAALNhwnE88Zq06pKh9XZv9TNzJDCXjL7GkjQp46sCkjL3/uI6AgAAgCcRPZ8AAAAAAABgNhSfAAAAAAAAYDYUnwAAAAAAAGA2FJ8AAAAAAABgNkw4DgBJjP7WTgZJRksHAgAAcizyBQDIHIpPAJBEdKzB0iEAAIAcjnwBADKHYXcAAAAAAAAwG4pPAAAAAAAAMBuG3QFAEs9WipOjnXQnRvrxOJdIAACQEvkCAGQOV0oASKJ55Xi5OUkhESSTAAAgdeQLAJA5XCmfEE1adcnQeru3+lmk3cdBbjoWIDey1HUuM7L6OmLJYwGy2uPwGc4ISx4HuQpyo9xybUD24n2T8zDnEwAAAAAAAMyG4hMAAAAAAADMhuITAAAAAAAAzIbiEwAAAAAAAMyG4hMAAAAAAADMhuITAAAAAAAAzIbiEwAAAAAAAMzGxtIBAEBOcummlYLDjQqPMlg6FAAAkEORLwBA5lB8AoAk5uywtXQIAAAghyNfAIDMYdgdAAAAAAAAzIbiEwAAAAAAAMyGYXc5VJNWXTK03u6tfmaOJHUZjQ8AYHlZfc3O6HdPTv8uQ0pZ/ZqZI1+w1Psqq4+FXArIWpb6TGXmWpNbvhdzy3FIHEt2ovgEAEm83TxWeR0SJxBlPgcAAJAa8gUAyByKTwCQRBHPBLk5SSERRkuHAgAAcijyBQDIHOZ8AgAAAAAAgNlQfAIAAAAAAIDZUHwCAAAAAACA2VB8AgAAAAAAgNlQfAIAAAAAAIDZWPxud5ev+uuzyfMVGnZbeZ0cNXJwX5UoVsjSYQEAAAAAACALWLzn06QZi/VSq6b6ZvEUdXv5RY2dOt/SIQEAAAAAACCLWLTnU/CtUP1z+pymTxgmSWraoJamzlmmy1f9Vdg3f5rbGY1GSVJcXJxZ47vbTLrMEUdWt22p/QGPm/gEKS4+8f+8z3OnrL5mW/J9YqlrtqW+ezLDkm2nxtraWgaD4aG3f1xzn8fh85FRfCcgKfKFtD0O14eslJlrTU77bnpYj8NxWCr3saSc9rrcn/sYYmKiLfaxP3n6vD6eOEdfL5piWvbGO6PVv1dnPVWtYrJ1v920Q+u+3yFJSkhI0KUr/tkaKwAAwMPau2WpbGwy95sfuQ8AAHhc3Z/7WHzOp4zq2Ka5OrZpLikxAYuJiXnkXxGfdN3e+lAr502wdBhPLM6/ZXH+LYvzb1mc/+xnbW2d6W3IfR5ffMZyH17T3IfXNPfhNc1Z7s99LFp88vZy183gW4qLj5eNtbWMRqMCbgTJx8vjgdtZWVnJwcEhm6LMvQwGQ6Z/hUXW4fxbFuffsjj/lsX5f/yQ+zxe+IzlPrymuQ+vae7Da5qzWXTCcXdXF5UtVUzbdu6XJO3a95u8Pd0fON8TAAAAAAAAHh8WLwsOe/cNjZ36lZat2SSnPI76aHAfS4f0xOjwYnNLh/BE4/xbFuffsjj/lsX5B8yLz1juw2ua+/Ca5j68pjmbRSccBwAAAAAAQO5m0WF3AAAAAAAAyN0oPgEAAAAAAMBsKD4BAAAAAADAbCw+4Tgs65sN27Rx6y7JkHhrym6dWuu5ZxpYOqwnxv5fj2jB8nU6d/GK2rV+Ru/1e83SIeV6l6/667PJ8xUadlt5nRw1cnBflShWyNJhPTGmzV2uvb/8If+Am1o2d5zKlCxq6ZCeGNExMRo9fo7OX7oqezs7ubk6a8g7PbnDLGAm5Fi5A3lD7sJ3Ye62edvPGjdtgSZ+PEiN6z1l6XBwH4pPT7jiRX01/4vRyuuURwGBQerx9keqVL60ChX0sXRoT4TCvvn10fu99dPeQ4q8E2XpcJ4Ik2Ys1kutmqp1i0b6ae8hjZ06X4tnjbF0WE+Mpg1rq1un1uo7mHNuCW1bNVXdWlVlMBi0duN2TZi+UHMnj7R0WECuRI6VO5A35D58F+ZO1/1vaOMPu1WpfClLh4I0MOzuCVereiXldcojSfLx9pCHm6sCbwRZOKonR5FCBVS6ZFFZW/NRzA7Bt0L1z+lzavlMfUlS0wa1FHAjWJev+ls4sidH9crl5O3lYekwnkj2dnaqV7uaDAaDJKlS+VK6HnDTwlEBuRc51uOPvCH34bswd0pISND4LxZq8NvdZWtL/5qcir94YXLoj+MKC49Q+bIlLB0KYBaBN4Ll6e4qG2trSYnDIHy8PBTAHwN4An2zYZsa1a1h6TCAJwI51uOJvCH347swd/Bb94OqVCyjcqWLWzoUPABlwVyu96BP0vx1ZtmccfLxTuyBcOb8ZY2b+pXGjhggRweH7AwxV8vo+QeA7LTUb6OuXAvQrIkfWjoU4LFFjgU83vguzB3OXris3ft/05dTGDqZ01F8yuUWTP8k3XXOX7yqIaOn6KP3e6tqpbLmD+oJkpHzj+zj7eWum8G3FBcfLxtraxmNRgXcCJIPw8DwBFm1dot+3n9YMycOl4ODvaXDAR5b5Fi5H3lD7sV3Ye5x9NgpXQ+4oU5vfCBJCg4O1aSLixUUdEvtX3zWwtEhKYpPT7gLl67q/VGTNWxgL9WuWdnS4QBm5e7qorKlimnbzv1q3aKRdu37Td6e7tzhBE8Mv3VbtWP3Qc2c+KHy5XWydDhArkaO9fgjb8id+C7MXdq/+GyyIlP/IWPVud1z3O0uBzLExEQbLR0ELOfd4RN18vQ55ff2NC3r3+sV1XmqigWjenL8duS4xkyZr4jIO5JRcnJy1JABPdWwbk1Lh5ZrXbx8TWOnfqXQsHA55XHUR4P7qFTxwpYO64kxccYiHTj0p4KDQ+XsnFd5HB307dJplg7riRB4I0htuw2UbwFv5XFMHPpja2urRTM/tXBkQO5EjpU7kDfkLnwX5n4Un3Iuik8AAAAAAAAwG+52BwAAAAAAALOh+AQAAAAAAACzofgEAAAAAAAAs6H4BAAAAAAAALOh+AQAAAAAAACzofgEAAAAAAAAs6H4BOCxdd3/huq27Kbb4REPtf3PBw6rXfdBWRsUAACAmZD7AHhc2Vg6AAC538XL1zRrgZ+O/3NasXFx8nR30wstGum1zi9aOrQH6j9krI7/c0Y21taysbFRyeKF9W6fripfpkSGtm/XfZAGvdVNjes9ZeZIAQBATkLuQ+4DIDmKTwDMbvCoKWrepK7GjhggW1tbXbx8TecvXbV0WBnS/41X9Er75xQbG6f5y9bqw89maMPKGdnSdlxcnGxsuEwDAPC4Ifd5OOQ+QO7FJxuAWd0Kva2r1wP1UqtmcnCwlySVKFZIJYoVMq0TERGpL5es1f5f/1BYeISKFiqgCaMGycfbQ37rtuq7zTsVHBIqN1dndW73nDq1bZFqW0ajUWs3bte6739UcEioSpcsoqHvvK5iRXwlSYE3gjRu2gIdP3lGhQvmV5MGtTJ8HLa2Nmr1bEOtWrtFIbfC5Obq/MD2RoydqYDAII2eMEfWVlZq2ay+und+Ue17vKft6+YrX14nSdIXX65QeESkRn3QV9f9b6h9j/f00fu9tdRvoyLvRGnsiAEa9ul0vdO7ixatXK+o6Bi9+FxjDXizy8O+JAAAwIzIfch9AKRE8QmAWbk451WRQgU0dupXatuqqSqWK6UCPp7J1hkz9StFRUXrq+mfyMPNRafPXZK9vZ0kKb+3p2ZPGiFvL3f9cfQfvT9qssqUKqaqFcukaOu7zT/q+//9rCmfDVaB/F767vsf9cHoqfJb8LlsbW308cS5KpDfS1v85sg/MEjvj5yc4eOIio7R99t2y9Uln/Llc0q3vfEj303R9fy6/40MtbX3lz+0ZPYY2drY6MSps4q8c0fnL13VN0um6Lr/Db0+YLTq1aqqGlUrZDh+AACQPch9yH0ApMSE4wDMymAwaO7kj1S6RBEtXrleHXu+py69h+rQ78ckScEhofp5/2ENH9RLXh5usrKyUtlSxeTqkk+S1LRhbfl4e8hgMKhmtQqqU7OKjhz9J9W21m36Ub27d1Bh3/yysbbWyy+1VHRMrP4+eUYBgUH68/gpDejdRQ4O9ipWpKDatW6WbvxfLvlazdv3UbO2vbR910FNGDVQNtbW6bb3KHp1a698eZ1Mv5YajVLfHp1kb2enYkV8VblCaZ08feGR2gAAAOZB7pN55D5A7kfPJwBm5+Huqnf7vqp3+76q0LBwLfPbqOGfTdf6FTN0PeCm7Gxtld/bM9Vtt/20X6vXbdV1/5syGo2Kio5Wgfxeqa57PeCmPv38S1lZ/VdXj42LU+DNYNna2sjOzlburi6m5/L7pN5mUv1e76xX2j+nwJvBGvrxNJ05f1nVKpdLt71H4ePlkeyxUx5HUzImSY4O9oq8c+eR2gAAAOZD7pM55D5A7kfxCUC2cnHOq16vtZffdz/oesANFfDxVExsrAICg+TjnTzx8A+8qTGT52vauKGqUbW8bKytNeyTL2Q0GlPdt7eXuwa91U11a1VN8VxAYJBiYmIVfCvUlIQFBAZlOG5vT3d9OKiX+n0wVo3rPyUvD7cHtidJVgZDsseOjg6SErux35v3ICj4lqmbvWk7q+TbAQCAxxe5D7kPAIbdATCzsNsRmr90rS5cuqb4+ARFRUVrzXc/yDlfXhUtXEDubi5qVLemPp+1WDeDQpSQkKBTZy4oNOy27tyJllFGubs6y8pg0IFDf+rXP46l2VbHNs21YPk6Xbx8TVLiZJ57DvyuiMg78vH2UJWKZTR30deKio7RxcvXtGHrT5k6lrKli6t6lfJa5rcp3fYkyd3NRVevBZq2d3XJp/zeHtq6Y68SEhL0+58ndPC3o5mKAQAA5GzkPuQ+AFKi5xMAs7K1tdaNm8EaPGqyQm6Fyc7OVmVLFdO0sUPk6JD4a9ioIX01Z+Eavf7OaEXeuaNihX01YdS7Kl7UVz1eaasBw8YrISFBDerUUMM6NdJsq2Ob5rKyMujDz2Yo4GaQ8jg6qmrFMqpZLXFyyk+H99f4aQvVunN/FfbNrxdaNNbG/+3K1PH07NJWA4aO12svv5Bue91faaMv5q7QktUb1KJpXQ1553WNeL+3Js9aquVrNqle7Wp6tnEdxcXHP+TZBQAAOQ25D7kPgJQMMTHRqffhBAAAAAAAAB4Rw+4AAAAAAABgNhSfAAAAAAAAYDYUnwAAAAAAAGA2FJ8AAAAAAABgNhSfAAAAAAAAYDYUnwAAAAAAAGA2FJ8AAAAAAABgNv8HuwfqadypdK8AAAAASUVORK5CYII=",
"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
}