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

1306 lines
41 KiB
Python
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.
# ---
# jupyter:
# jupytext:
# cell_metadata_filter: tags,-all
# text_representation:
# extension: .py
# format_name: percent
# format_version: '1.3'
# jupytext_version: 1.19.3
# kernelspec:
# display_name: Python 3 (ipykernel)
# language: python
# name: python3
# ---
# %% [markdown]
# **Docker image**: `ml4t`
# %% [markdown]
# # Label Engineering Methods
#
# **Chapter 7: Defining the Learning Task**
# **Section Reference**: 7.2 - Label Engineering
#
# ## Purpose
#
# This notebook demonstrates **all major labeling methods** for ML-based trading
# strategies, with practical examples on real ETF data. It serves as the canonical
# reference for choosing and configuring labels across all modeling chapters.
#
# ## Learning Objectives
#
# 1. Understand fixed-horizon vs path-dependent labels
# 2. Compare time-series vs **cross-sectional** percentile approaches
# 3. Implement triple-barrier with fixed and ATR-based thresholds
# 4. Visualize barrier mechanics with price path examples
# 5. Understand anchor alignment (close-to-close vs next-open)
#
# ## Prerequisites
#
# - `01_data_quality_diagnostics` — establishes the ETF coverage assumptions used here.
# - Familiarity with leakage-aware splitting (Chapter 6 §6.3) and forward-return semantics.
# - Polars DataFrame manipulation; basic statistics (t-statistics, percentiles).
#
# ## Data Contract
#
# - **Input**: Real ETF OHLCV from data loaders (SPY for single-asset, full universe for cross-sectional)
# - **Output**: Example labels for teaching (use `compute_labels()` for production)
# %%
"""Label Methods — fixed-horizon, cross-sectional, and event-driven labeling for supervised learning."""
from __future__ import annotations
import warnings
from collections.abc import Sequence
from datetime import datetime
import numpy as np
import plotly.express as px
import plotly.graph_objects as go
import polars as pl
from IPython.display import display
from ml4t.engineer.config.labeling import LabelingConfig
from ml4t.engineer.labeling import (
atr_triple_barrier_labels,
calculate_label_uniqueness,
compute_bet_size,
fixed_time_horizon_labels,
meta_labels,
rolling_percentile_binary_labels,
sequential_bootstrap,
trend_scanning_labels,
triple_barrier_labels,
)
from plotly.subplots import make_subplots
from scipy import stats as sp_stats
from data import load_etfs
from utils.reproducibility import set_global_seeds
warnings.filterwarnings("ignore")
# %% tags=["parameters"]
SEED = 42
START_DATE = "2015-01-01"
END_DATE = "2024-12-31"
# %%
set_global_seeds(SEED)
# %% [markdown]
# ## Helper Functions
#
# Robust label column discovery to avoid brittle hardcoded column names.
# %%
def first_col_matching_any(df: pl.DataFrame, needles: Sequence[str]) -> str:
"""
Return the first column whose name contains any of the substrings in needles.
Raises ValueError if no match is found.
"""
lowered = [(c, c.lower()) for c in df.columns]
for c, c_low in lowered:
for n in needles:
if n.lower() in c_low:
return c
raise ValueError(f"No column found matching: {needles}")
# %% [markdown]
# ## 1. Load Sample Data
#
# We use the ETF universe for demonstrations. SPY serves as the single-asset
# example; the full universe enables cross-sectional analysis.
# %%
# Load ETF universe
etf = load_etfs()
# Filter date range
date_filter = (pl.col("timestamp") >= datetime.strptime(START_DATE, "%Y-%m-%d")) & (
pl.col("timestamp") <= datetime.strptime(END_DATE, "%Y-%m-%d")
)
etf_filtered = (
etf.filter(date_filter)
.sort(["symbol", "timestamp"])
.with_columns(pl.col("timestamp").cast(pl.Datetime("us")))
)
# SPY for single-asset demos
spy = etf_filtered.filter(pl.col("symbol") == "SPY").sort("timestamp")
print(f"ETF universe: {etf_filtered['symbol'].n_unique()} symbols")
print(f"SPY data: {len(spy):,} days from {spy['timestamp'].min()} to {spy['timestamp'].max()}")
spy.head()
# %% [markdown]
# ## 2. Fixed Time Horizon Labels
#
# The simplest approach: compute forward returns over a fixed window.
# This is the workhorse of factor-based ML strategies.
#
# ### Methods
#
# | Method | Description | Use Case |
# |--------|-------------|----------|
# | `"returns"` | Raw percentage return | Regression targets |
# | `"binary"` | +1 if return > 0, else -1 | Classification |
# | `"log_returns"` | Log return | Regression with better properties |
# %%
# 21 trading days is a common "one month" convention in daily data
horizon = 21
labels_returns = fixed_time_horizon_labels(
spy,
horizon=horizon,
method="returns",
price_col="close",
)
# Discover the produced label column robustly
fh_label_col = first_col_matching_any(labels_returns, [f"{horizon}", "label_return", "label"])
print(f"Fixed Horizon Labels (horizon={horizon}):")
print(f" Column added: {fh_label_col}")
labels_returns.select(["timestamp", "close", fh_label_col]).head(10)
# %% [markdown]
# ### Return vs binary comparison
#
# Both methods use the same forward window but produce different target types:
# continuous returns for regression, binary direction for classification.
# %%
labels_binary = fixed_time_horizon_labels(
spy,
horizon=horizon,
method="binary",
price_col="close",
)
binary_label_col = first_col_matching_any(labels_binary, ["direction", "label"])
print("Return distribution:")
display(labels_returns[fh_label_col].describe())
print("Binary label distribution:")
display(labels_binary.group_by(binary_label_col).len().sort(binary_label_col))
# %% [markdown]
# ## 3. Anchor Alignment Demo
#
# **Critical concept**: The anchor point determines when returns are measured.
# Different anchors produce different labels even with the same horizon.
#
# - **Close-to-close**: Decision at close, measure return from close to H-day close
# - **Next-open-to-open**: Decision at close, execute at next open, measure from there
#
# This is one of the most common sources of subtle lookahead bias.
# %%
# Compute both anchor alignments
spy_anchors = spy.with_columns(
[
# Close-to-close: standard approach
(pl.col("close").shift(-horizon) / pl.col("close") - 1).alias("ret_close_to_close"),
# Next-open-to-open: decision at close(t), execute at open(t+1), exit at open(t+horizon+1)
# Holding for `horizon` trading days means exit is horizon+1 bars from decision
(pl.col("open").shift(-(horizon + 1)) / pl.col("open").shift(-1) - 1).alias(
"ret_next_open_to_open"
),
]
).drop_nulls()
# Compute the difference
spy_anchors = spy_anchors.with_columns(
[(pl.col("ret_close_to_close") - pl.col("ret_next_open_to_open")).alias("anchor_diff")]
)
print(f"Mean close-to-close return: {spy_anchors['ret_close_to_close'].mean():.4f}")
print(f"Mean next-open-to-open return: {spy_anchors['ret_next_open_to_open'].mean():.4f}")
print(f"Mean difference: {spy_anchors['anchor_diff'].mean():.4f}")
print(f"Std difference: {spy_anchors['anchor_diff'].std():.4f}")
# %%
# Visualize the difference over time
fig = make_subplots(
rows=2,
cols=1,
subplot_titles=[
"Anchor Difference Over Time",
"Distribution of Anchor Differences",
],
row_heights=[0.6, 0.4],
vertical_spacing=0.15,
)
# Raw daily differences (light) with 63-day rolling mean overlay
fig.add_trace(
go.Scatter(
x=spy_anchors["timestamp"].to_list(),
y=spy_anchors["anchor_diff"].to_numpy(),
mode="lines",
name="Daily",
line=dict(width=0.3, color="rgba(100,100,100,0.3)"),
),
row=1,
col=1,
)
# Rolling mean to show structural pattern
rolling_mean = spy_anchors["anchor_diff"].rolling_mean(63)
fig.add_trace(
go.Scatter(
x=spy_anchors["timestamp"].to_list(),
y=rolling_mean.to_numpy(),
mode="lines",
name="63-day MA",
line=dict(width=1.5, color="#2166ac"),
),
row=1,
col=1,
)
fig.add_hline(y=0, line_dash="dash", line_color="gray", line_width=0.8, row=1, col=1)
# Histogram of differences
fig.add_trace(
go.Histogram(
x=spy_anchors["anchor_diff"].to_numpy(),
nbinsx=50,
name="Distribution",
showlegend=False,
marker_color="#2166ac",
),
row=2,
col=1,
)
fig.update_xaxes(title_text="Date", row=1, col=1)
fig.update_yaxes(title_text="Return Difference", row=1, col=1)
fig.update_xaxes(title_text="Return Difference", row=2, col=1)
fig.update_yaxes(title_text="Count", row=2, col=1)
fig.update_layout(
height=550,
title_text=f"Anchor Alignment Impact — Close-to-Close minus Open-to-Open ({horizon}-day horizon)",
font=dict(size=12),
showlegend=True,
legend=dict(x=0.02, y=0.98),
)
fig.show()
# %%
# Example timestamps showing anchor shift
spy_anchors.select(
["timestamp", "close", "open", "ret_close_to_close", "ret_next_open_to_open", "anchor_diff"]
).head(10)
# %% [markdown]
# **Key Insight**: The anchor difference is noisy at the trade level (standard
# deviation ~100bps for SPY), even though it averages close to zero. This means
# individual label assignments can differ substantially between anchors, affecting
# model training. For end-of-day signals executed at next open, labels should use
# next-open anchoring to match the actual execution price.
# %% [markdown]
# ## 4. Time-Series Percentile Labels
#
# Labels are relative to recent history for a **single instrument**,
# making them adaptive to volatility regimes.
# %%
# Binary percentile: Is return in top 25%?
labels_ts_pct = rolling_percentile_binary_labels(
spy,
horizon=horizon,
percentile=75, # Top 25%
direction="long",
lookback_window=252, # 1 year rolling window
price_col="close",
)
# Find the label column robustly
ts_pct_label_col = first_col_matching_any(labels_ts_pct, ["label"])
print("Time-Series Percentile Labels (p75):")
print(f" Column added: {ts_pct_label_col}")
# Show distribution
print("Label Distribution:")
display(labels_ts_pct.group_by(ts_pct_label_col).len().sort(ts_pct_label_col))
# %%
# Visualize threshold adaptation
threshold_col = [c for c in labels_ts_pct.columns if "threshold" in c.lower()]
if threshold_col:
fig = px.line(
labels_ts_pct.to_pandas(),
x="timestamp",
y=threshold_col[0],
title=f"Rolling 75th Percentile Threshold ({horizon}-day returns)",
)
fig.update_layout(height=350, yaxis_title="Return Threshold")
fig.show()
# %% [markdown]
# ## 5. Cross-Sectional Percentile Labels
#
# **The most natural use of percentile labels**: rank assets within the universe
# at each decision time, then label top/bottom quantiles.
#
# This is the standard approach for equity and ETF rotation strategies.
# %%
# Compute forward returns and cross-sectional rank for the entire ETF universe
etf_with_fwd = etf_filtered.with_columns(
[(pl.col("close").shift(-horizon) / pl.col("close") - 1).over("symbol").alias("fwd_return")]
).drop_nulls(subset=["fwd_return"])
etf_cs = etf_with_fwd.with_columns(
[
pl.col("fwd_return").rank(method="average").over("timestamp").alias("rank"),
pl.col("fwd_return").count().over("timestamp").alias("n_symbols"),
]
).with_columns(
[
# Percentile rank: 0-100 scale (guard against single-symbol dates)
pl.when(pl.col("n_symbols") > 1)
.then((pl.col("rank") - 1) / (pl.col("n_symbols") - 1) * 100)
.otherwise(None)
.alias("pct_rank")
]
)
print(f"Cross-sectional ranking: {len(etf_cs):,} asset-date observations")
# %% [markdown]
# Cross-sectional percentile labels rank assets at each decision time $t$.
# This is inherently point-in-time: the ranking at $t$ uses only returns
# realized at $t$, so no future information leaks into label construction.
# %%
# Assign labels: top quintile = +1, bottom quintile = -1, else 0
quintile_threshold = 20 # Top/bottom 20%
etf_cs = etf_cs.with_columns(
[
pl.when(pl.col("pct_rank") >= (100 - quintile_threshold))
.then(pl.lit(1))
.when(pl.col("pct_rank") <= quintile_threshold)
.then(pl.lit(-1))
.otherwise(pl.lit(0))
.alias("cs_label")
]
)
print(f"Cross-Sectional Labels ({horizon}d horizon, {quintile_threshold}th percentile cutoffs):")
print(f" Total observations: {len(etf_cs):,}")
display(etf_cs.group_by("cs_label").len().sort("cs_label"))
# %%
# Verify stable class proportions over time
# Note: Counts vary if universe size changes; proportions are stable by construction.
label_by_date = (
etf_cs.group_by(["timestamp", "cs_label"])
.len()
.pivot(on="cs_label", index="timestamp", values="len")
.sort("timestamp")
)
# Convert counts to proportions
count_cols = [c for c in label_by_date.columns if c != "timestamp"]
if count_cols:
label_by_date = label_by_date.with_columns([pl.col(c).fill_null(0) for c in count_cols])
label_by_date = label_by_date.with_columns(
pl.sum_horizontal([pl.col(c) for c in count_cols]).alias("_total")
)
label_by_date = label_by_date.with_columns(
[
pl.when(pl.col("_total") > 0)
.then(pl.col(c) / pl.col("_total"))
.otherwise(None)
.alias(c)
for c in count_cols
]
)
# %%
# Show class proportions over time
fig = go.Figure()
for label in [-1, 0, 1]:
col_name = str(label)
if col_name in label_by_date.columns:
fig.add_trace(
go.Scatter(
x=label_by_date["timestamp"].to_list(),
y=label_by_date[col_name].to_numpy(),
mode="lines",
name=f"Label {label}",
)
)
fig.update_layout(
height=400,
title="Cross-Sectional Label Proportions Over Time (Stable by Construction)",
xaxis_title="Date",
yaxis_title="Proportion",
)
fig.show()
# %%
# Show cross-sectional threshold values over time
cs_thresholds = (
etf_with_fwd.group_by("timestamp")
.agg(
[
pl.col("fwd_return").quantile(quintile_threshold / 100).alias("bottom_threshold"),
pl.col("fwd_return").quantile(1 - quintile_threshold / 100).alias("top_threshold"),
]
)
.sort("timestamp")
)
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=cs_thresholds["timestamp"].to_list(),
y=cs_thresholds["top_threshold"].to_numpy(),
mode="lines",
name="Top 20% Threshold",
line=dict(color="green"),
)
)
fig.add_trace(
go.Scatter(
x=cs_thresholds["timestamp"].to_list(),
y=cs_thresholds["bottom_threshold"].to_numpy(),
mode="lines",
name="Bottom 20% Threshold",
line=dict(color="red"),
)
)
fig.update_layout(
height=400,
title="Cross-Sectional Threshold Values Over Time",
xaxis_title="Date",
yaxis_title=f"{horizon}-day Return Threshold",
)
fig.show()
# %% [markdown]
# **Key Insight**: Cross-sectional percentile labels maintain stable class distributions
# by construction, but the absolute return thresholds vary with market conditions.
# In high-volatility periods, larger absolute returns are needed to qualify as "top quintile".
# %% [markdown]
# ## 6. Triple-Barrier Labels
#
# Path-dependent labeling that captures realistic trade outcomes:
# - **Upper barrier**: Take profit hit → +1
# - **Lower barrier**: Stop loss hit → -1
# - **Time barrier**: Neither hit → label based on final return
#
# This method is from De Prado's *Advances in Financial Machine Learning*.
# %%
# Fixed percentage barriers: 2% take profit, 1% stop loss
config = LabelingConfig.triple_barrier(
upper_barrier=0.02, # 2% take profit
lower_barrier=0.01, # 1% stop loss
max_holding_period=20, # 20 days max
side=1, # Long positions only
)
labels_tb = triple_barrier_labels(
spy,
config=config,
price_col="close",
timestamp_col="timestamp",
calculate_uniqueness=True, # Compute sample weights
)
print("Triple-Barrier Labels (Fixed %):")
print("Label Distribution:")
display(labels_tb.group_by("label").len().sort("label"))
print("Barrier Hit Distribution:")
display(labels_tb.group_by("barrier_hit").len().sort("barrier_hit"))
# %% [markdown]
# ### 6.1 Triple-Barrier Path Visualization
#
# **Understanding triple-barrier requires seeing the price paths**.
# Below we plot several example trades showing how barriers are hit.
# %%
# Find examples of each barrier hit type
tb_with_price = labels_tb.join(
spy.select(["timestamp", "close"]), on="timestamp", how="left"
).with_row_index("row_idx")
def plot_triple_barrier_example(
df: pl.DataFrame, entry_idx: int, config: LabelingConfig, title: str
) -> go.Figure:
"""Plot a single triple-barrier trade example with barriers overlaid."""
# Get entry point
entry_row = df.row(entry_idx, named=True)
entry_price = entry_row["close"]
entry_time = entry_row["timestamp"]
# Calculate barrier levels
upper_level = entry_price * (1 + config.upper_barrier)
lower_level = entry_price * (1 - config.lower_barrier)
# Get the forward price path
forward_rows = df.filter(pl.col("timestamp") >= entry_time).head(config.max_holding_period + 1)
fig = go.Figure()
# Price path
fig.add_trace(
go.Scatter(
x=forward_rows["timestamp"].to_list(),
y=forward_rows["close"].to_numpy(),
mode="lines+markers",
name="Price",
line=dict(color="blue", width=2),
marker=dict(size=4),
)
)
# Entry point
fig.add_trace(
go.Scatter(
x=[entry_time],
y=[entry_price],
mode="markers",
name="Entry",
marker=dict(color="black", size=12, symbol="star"),
)
)
# Upper barrier (horizontal line)
fig.add_hline(
y=upper_level,
line_dash="dash",
line_color="green",
annotation_text=f"TP: {upper_level:.2f} (+{config.upper_barrier:.1%})",
)
# Lower barrier (horizontal line)
fig.add_hline(
y=lower_level,
line_dash="dash",
line_color="red",
annotation_text=f"SL: {lower_level:.2f} (-{config.lower_barrier:.1%})",
)
# Time barrier (vertical line at end)
# Note: Use add_shape instead of add_vline with annotation to avoid Plotly datetime bug
time_barrier = forward_rows["timestamp"].to_list()[-1] if len(forward_rows) > 0 else entry_time
fig.add_shape(
type="line",
x0=time_barrier,
x1=time_barrier,
y0=0,
y1=1,
yref="paper",
line=dict(color="gray", dash="dot"),
)
fig.add_annotation(
x=time_barrier,
y=1,
yref="paper",
text="Time Barrier",
showarrow=False,
yshift=10,
)
fig.update_layout(
height=350,
title=title,
xaxis_title="Date",
yaxis_title="Price",
showlegend=True,
)
return fig
# Find examples of each barrier type
if "barrier_hit" in labels_tb.columns:
# Get indices for different barrier hits
examples = []
for barrier_type in ["upper", "lower", "time"]:
matches = tb_with_price.filter(
(pl.col("barrier_hit") == barrier_type) & (pl.col("close").is_not_null())
)
if len(matches) > 10:
# Pick an example from the middle of the dataset
idx = len(matches) // 2
row_idx = matches["row_idx"][idx]
examples.append((barrier_type, row_idx))
# Plot examples
for barrier_type, idx in examples[:3]: # Limit to 3 examples
if idx < len(tb_with_price):
fig = plot_triple_barrier_example(
tb_with_price,
idx,
config,
f"Triple-Barrier Example: {barrier_type.upper()} barrier hit",
)
fig.show()
# %% [markdown]
# ### 6.2 ATR-Based Barriers
#
# Volatility-adjusted barriers adapt to market conditions:
# - Low volatility → Tighter barriers (capture smaller moves)
# - High volatility → Wider barriers (avoid whipsaws)
# %%
# Compute ATR and convert to percentage-of-price barriers
atr_period = 14
atr_tp_multiple = 1.0 # 1x ATR take profit
atr_sl_multiple = 0.5 # 0.5x ATR stop loss (tighter asymmetric)
spy_atr = (
spy.with_columns(
pl.max_horizontal(
pl.col("high") - pl.col("low"),
(pl.col("high") - pl.col("close").shift(1)).abs(),
(pl.col("low") - pl.col("close").shift(1)).abs(),
).alias("true_range")
)
.with_columns(pl.col("true_range").rolling_mean(atr_period).alias("atr_dollar"))
.with_columns(
# Express barriers as fraction of entry price so they match the return-based engine
(atr_tp_multiple * pl.col("atr_dollar") / pl.col("close")).alias("upper_barrier_pct"),
(atr_sl_multiple * pl.col("atr_dollar") / pl.col("close")).alias("lower_barrier_pct"),
)
.drop_nulls()
)
# %%
# Use triple_barrier_labels with dynamic per-row percentage barriers
atr_config = LabelingConfig.triple_barrier(
upper_barrier="upper_barrier_pct",
lower_barrier="lower_barrier_pct",
max_holding_period=20,
side=1,
)
labels_atr = triple_barrier_labels(
spy_atr,
config=atr_config,
price_col="close",
timestamp_col="timestamp",
)
print("ATR Triple-Barrier Labels:")
print(f" ATR period: {atr_period}, TP: {atr_tp_multiple}x ATR, SL: {atr_sl_multiple}x ATR")
print("Label Distribution:")
display(labels_atr.group_by("label").len().sort("label"))
if "barrier_hit" in labels_atr.columns:
print("Barrier Hit Distribution:")
display(labels_atr.group_by("barrier_hit").len().sort("barrier_hit"))
print("ATR as % of Close:")
display(spy_atr["upper_barrier_pct"].describe())
# %% [markdown]
# ### 6.3 Sample Weights from Uniqueness
#
# Overlapping labels create mechanical dependence: high-concurrency periods
# dominate training loss. Weighting by uniqueness prevents these periods
# from overwhelming the model. De Prado introduces **uniqueness-based
# sample weights** where more unique samples (less overlap) get higher weights.
# %%
if "sample_weight" in labels_tb.columns:
print("Sample Weight Statistics:")
display(labels_tb["sample_weight"].describe())
# Visualize weight distribution
fig = px.histogram(
labels_tb.filter(pl.col("sample_weight").is_not_null()).to_pandas(),
x="sample_weight",
nbins=50,
title="Triple-Barrier Sample Weight Distribution",
)
fig.update_layout(height=350)
fig.show()
# %% [markdown]
# ### 6.4 Rich Triple-Barrier Output
#
# Unlike simple forward-return labels, triple-barrier output includes the **full
# trade outcome**. This is critical for MFE/MAE analysis (NB04) and position
# sizing (Ch20).
# %%
# Display all output columns from triple_barrier_labels
output_cols = [
c for c in labels_tb.columns if c.startswith("label") or c in ("barrier_hit", "sample_weight")
]
print("Triple-Barrier Output Columns:")
for col in output_cols:
dtype = labels_tb[col].dtype
print(f" {col:<20} {str(dtype):<12}{labels_tb[col].drop_nulls().head(1).to_list()}")
# %%
# Summary table: mean return and median holding period by barrier type
barrier_summary = (
labels_tb.filter(pl.col("barrier_hit").is_not_null())
.group_by("barrier_hit")
.agg(
count=pl.len(),
mean_return=pl.col("label_return").mean(),
median_bars=pl.col("label_bars").median(),
)
.sort("barrier_hit")
)
print("Trade Outcomes by Barrier Type:")
display(barrier_summary)
# %%
# Return distribution colored by barrier hit type
fig = px.histogram(
labels_tb.filter(pl.col("label_return").is_not_null()).to_pandas(),
x="label_return",
color="barrier_hit",
nbins=50,
barmode="overlay",
opacity=0.7,
title="Label Return Distribution by Barrier Hit Type",
)
fig.update_layout(height=350, xaxis_title="Label Return", yaxis_title="Count")
fig.show()
# %% [markdown]
# ### 6.5 Sequential Bootstrap
#
# Overlapping labels create sample dependence. The **sequential bootstrap**
# (De Prado, AFML Ch4) generates bootstrap indices that respect label
# uniqueness — favoring samples with less concurrent overlap.
# %%
# Extract label lifetimes as index ranges for the uniqueness calculation
tb_valid = labels_tb.filter(pl.col("label_bars").is_not_null()).with_row_index("idx")
starts = tb_valid["idx"].to_numpy().astype(np.int64)
ends = (starts + tb_valid["label_bars"].to_numpy().astype(np.int64)).clip(max=len(tb_valid) - 1)
# Compute uniqueness from indices
uniqueness = calculate_label_uniqueness(starts, ends, n_bars=len(tb_valid))
print(f"Label Uniqueness: mean={uniqueness.mean():.3f}, std={uniqueness.std():.3f}")
print(f" Range: [{uniqueness.min():.3f}, {uniqueness.max():.3f}]")
# %%
# Sequential bootstrap vs naive random sampling
n_draws = min(len(starts), 500)
seq_indices = sequential_bootstrap(starts, ends, n_draws=n_draws, random_state=SEED)
naive_indices = np.random.default_rng(SEED).choice(len(starts), size=n_draws, replace=True)
# Compare uniqueness of selected samples
seq_uniqueness = uniqueness[seq_indices]
naive_uniqueness = uniqueness[naive_indices]
print("Bootstrap Comparison:")
print(f" Sequential mean uniqueness: {seq_uniqueness.mean():.3f}")
print(f" Naive mean uniqueness: {naive_uniqueness.mean():.3f}")
print(f" Improvement: {(seq_uniqueness.mean() / naive_uniqueness.mean() - 1):.1%}")
# %%
# Visualize the difference
fig = make_subplots(rows=1, cols=2, subplot_titles=["Naive Bootstrap", "Sequential Bootstrap"])
fig.add_trace(
go.Histogram(x=naive_uniqueness, nbinsx=30, name="Naive", marker_color="gray", opacity=0.7),
row=1,
col=1,
)
fig.add_trace(
go.Histogram(
x=seq_uniqueness, nbinsx=30, name="Sequential", marker_color="#2ca02c", opacity=0.7
),
row=1,
col=2,
)
fig.update_xaxes(title_text="Uniqueness", row=1, col=1)
fig.update_xaxes(title_text="Uniqueness", row=1, col=2)
fig.update_layout(height=300, title_text="Sequential Bootstrap Favors Higher-Uniqueness Samples")
fig.show()
# %% [markdown]
# Sequential bootstrap produces training sets where each sample contributes
# more independent information. This reduces effective sample size but
# improves model generalization on overlapping label data.
# %% [markdown]
# ### 6.6 Effective Sample Size
#
# The section text defines $N_{\text{eff}} = \sum_{t,a} w_{t,a}$ and gives
# a worked example: 100 ETFs $\times$ 20 years $\times$ 250 days $= 500{,}000$
# nominal labels; with $H=5$, $N_{\text{eff}} \approx 100{,}000$.
# Let us verify this empirically on our fixed-horizon ETF labels.
# %%
# Compute effective sample size for fixed-horizon labels on the ETF universe
N_nominal = len(etf_with_fwd)
n_symbols = etf_with_fwd["symbol"].n_unique()
# For fixed-horizon labels sampled at every bar, uniqueness ≈ 1/H
# so N_eff ≈ N / H (ignoring cross-sectional correlation)
N_eff_approx = N_nominal / horizon
# Exact uniqueness for fixed-horizon: each label is alive for H bars,
# and at each bar ~n_symbols labels are alive (one per asset).
# Concurrency c(u) ≈ n_symbols × H for cross-sectional panels,
# but uniqueness is computed per-asset: w ≈ 1/H for the time-series dimension.
print(f"Fixed-horizon ETF labels (H={horizon}):")
print(f" Symbols: {n_symbols}")
print(f" Nominal N: {N_nominal:,}")
print(f" N_eff ≈ N/H: {N_eff_approx:,.0f}")
print(
f" SE inflation: √{horizon}{np.sqrt(horizon):.1f}× (confidence intervals based on N are this much too narrow)"
)
# %% [markdown]
# ## 7. Trend Scanning Labels
#
# De Prado's adaptive approach that identifies trends using t-statistics.
# The method scans forward with varying windows and selects the one
# with the highest statistical significance.
# %%
labels_trend = trend_scanning_labels(
spy,
min_window=5, # Minimum 5 days
max_window=20, # Maximum 20 days
step=1, # Check every window size
price_col="close",
)
print("Trend Scanning Labels:")
print("Label Distribution:")
display(labels_trend.group_by("label").len().sort("label"))
if "t_value" in labels_trend.columns:
print("T-Value Statistics:")
display(labels_trend["t_value"].describe())
# %% [markdown]
# ### 7.1 Selection Bias in Trend Scanning
#
# Trend scanning picks the horizon with the strongest t-statistic for each
# observation. This maximization introduces **selection bias**: the reported
# t-statistics are systematically inflated. The Bonferroni correction raises
# the critical value to account for the number of horizons tested, requiring
# each t-statistic to clear a higher bar for significance.
# %%
# Distribution of selected horizons
if "optimal_window" in labels_trend.columns:
horizon_col = "optimal_window"
elif "best_window" in labels_trend.columns:
horizon_col = "best_window"
else:
horizon_col = None
# %%
if horizon_col is not None:
fig = make_subplots(
rows=1,
cols=2,
subplot_titles=["Selected Horizon Distribution", "Raw t-statistics (with critical values)"],
)
# (a) Histogram of selected horizons
selected_horizons = labels_trend[horizon_col].drop_nulls().cast(pl.Int32, strict=False)
fig.add_trace(
go.Histogram(
x=selected_horizons.to_numpy(),
nbinsx=16,
name="Selected horizon",
marker_color="#2166ac",
showlegend=False,
),
row=1,
col=1,
)
# (b) Raw t-statistics vs Bonferroni-adjusted critical value
if "t_value" in labels_trend.columns:
n_candidates = 20 - 5 + 1 # max_window - min_window + 1
raw_t = labels_trend["t_value"].drop_nulls()
# Bonferroni: raise the critical value by dividing alpha by n_candidates
alpha = 0.05
bonferroni_crit = sp_stats.norm.ppf(1 - alpha / (2 * n_candidates))
fig.add_trace(
go.Histogram(
x=raw_t.to_numpy(),
nbinsx=50,
name="Raw t",
marker_color="rgba(33,102,172,0.5)",
opacity=0.7,
),
row=1,
col=2,
)
# Mark both critical values
fig.add_vline(
x=1.96, line_dash="dash", line_color="blue", annotation_text="t=1.96", row=1, col=2
)
fig.add_vline(x=-1.96, line_dash="dash", line_color="blue", row=1, col=2)
fig.add_vline(
x=bonferroni_crit,
line_dash="dash",
line_color="red",
annotation_text=f"Bonf={bonferroni_crit:.2f}",
row=1,
col=2,
)
fig.add_vline(x=-bonferroni_crit, line_dash="dash", line_color="red", row=1, col=2)
# Significance counts
raw_significant = (raw_t.abs() > 1.96).sum()
corrected_significant = (raw_t.abs() > bonferroni_crit).sum()
fig.update_xaxes(title_text="Horizon (bars)", row=1, col=1)
fig.update_xaxes(title_text="t-statistic", row=1, col=2)
fig.update_yaxes(title_text="Count", row=1, col=1)
fig.update_layout(height=350, font=dict(size=12))
fig.show()
# %% [markdown]
# The Bonferroni correction is conservative but illustrates the magnitude of
# the selection effect. After correction, many trends that appeared "significant"
# under the uncorrected test lose significance — confirming the text's warning
# that uncorrected trend scanning t-statistics should not be taken at face value.
# %%
if horizon_col is not None and "t_value" in labels_trend.columns:
sign_change_frac = 1 - corrected_significant / max(raw_significant, 1)
print(f"Candidate horizons tested: {n_candidates}")
print(f"Bonferroni critical value: {bonferroni_crit:.2f} (vs 1.96 uncorrected)")
print(
f"Significant at 5% (raw): {raw_significant:,} / {len(raw_t):,} ({raw_significant / len(raw_t):.1%})"
)
print(
f"Significant at 5% (Bonferroni): {corrected_significant:,} / {len(raw_t):,} ({corrected_significant / len(raw_t):.1%})"
)
print(f"Fraction losing significance: {sign_change_frac:.1%}")
# %% [markdown]
# ## 7.5 Meta-Labeling Concept
#
# **Meta-labeling** separates the signal from the sizing decision:
#
# 1. A primary model generates directional signals (+1 long, -1 short)
# 2. Triple-barrier labels determine whether each signal was profitable
# 3. A secondary (meta) model learns *when to act* and *how much to bet*
#
# This decomposes the problem: the primary model handles *direction*,
# the meta-model handles *confidence*. The cells below illustrate the
# construction on SPY; the case studies in this book do not adopt
# meta-labeling (each case study trains a single model on one label
# horizon and sizes positions through an allocator), but the pattern
# transfers directly to any directional model already in place.
# %%
# Simple primary signal: buy when 20-day momentum is positive
spy_meta = spy.with_columns(
signal=pl.when(pl.col("close") > pl.col("close").shift(20)).then(1).otherwise(-1),
fwd_return=(pl.col("close").shift(-horizon) / pl.col("close") - 1),
).drop_nulls()
# Create meta-labels: was the signal profitable?
spy_meta = meta_labels(spy_meta, signal_col="signal", return_col="fwd_return")
print("Meta-Label Distribution:")
display(spy_meta.group_by("meta_label").len().sort("meta_label"))
# %%
# Bet sizing: convert meta-model probability to position size
# Here we use the meta_label directly as a proxy for probability
spy_meta = spy_meta.with_columns(
# Simulate a meta-model probability (in practice, this comes from a trained classifier)
pseudo_prob=pl.col("meta_label").cast(pl.Float64) * 0.3 + 0.5,
).with_columns(
bet_size=compute_bet_size("pseudo_prob", method="sigmoid", scale=5.0),
)
print("Bet Size Statistics (sigmoid method):")
display(spy_meta["bet_size"].describe())
# %% [markdown]
# **Key Insight**: Meta-labeling turns a classification problem (direction)
# into a probability calibration problem (confidence). This enables
# Kelly-criterion-style position sizing from ML predictions.
# %% [markdown]
# ## 8. Label Diagnostics
#
# The function below provides a reusable diagnostic template. Run it on any
# label column to check distribution stability and class balance—the two
# properties that determine whether a label is learnable.
# %%
def label_diagnostics(
df: pl.DataFrame,
label_col: str,
timestamp_col: str = "timestamp",
title_prefix: str = "",
) -> None:
"""
Generate diagnostic plots for any label column.
Works with both continuous (returns) and discrete (classification) labels.
"""
labels = df[label_col].drop_nulls()
n_unique = labels.n_unique()
is_discrete = n_unique <= 10 # Heuristic: discrete if few unique values
if is_discrete:
# Discrete label diagnostics
print(f"\n{'=' * 60}")
print(f"{title_prefix} Discrete Label Diagnostics")
print(f"{'=' * 60}")
print(f"Unique values: {labels.unique().sort().to_list()}")
# Exclude nulls from value counts
df_non_null = df.drop_nulls(subset=[label_col])
print("Value Counts:")
display(df_non_null.group_by(label_col).len().sort(label_col))
# Bar chart of label distribution
fig = px.bar(
df_non_null.group_by(label_col).len().sort(label_col).to_pandas(),
x=label_col,
y="len",
title=f"{title_prefix} Label Distribution",
)
fig.update_layout(height=300)
fig.show()
# Class balance over time
if timestamp_col in df.columns:
by_date = (
df.group_by([timestamp_col, label_col])
.len()
.pivot(on=label_col, index=timestamp_col, values="len")
.sort(timestamp_col)
)
# Compute class proportions (robust to missing classes on a date)
count_cols = [c for c in by_date.columns if c != timestamp_col]
if count_cols:
by_date = by_date.with_columns([pl.col(c).fill_null(0) for c in count_cols])
by_date = by_date.with_columns(
pl.sum_horizontal([pl.col(c) for c in count_cols]).alias("_total")
)
by_date = by_date.with_columns(
[
pl.when(pl.col("_total") > 0)
.then(pl.col(c) / pl.col("_total"))
.otherwise(None)
.alias(f"{c}_pct")
for c in count_cols
]
)
# Plot proportions
fig = go.Figure()
for col in by_date.columns:
if col.endswith("_pct"):
fig.add_trace(
go.Scatter(
x=by_date[timestamp_col].to_list(),
y=by_date[col].to_numpy(),
mode="lines",
name=col.replace("_pct", ""),
)
)
fig.update_layout(
height=300,
title=f"{title_prefix} Class Proportions Over Time",
yaxis_title="Proportion",
)
fig.show()
else:
# Continuous label diagnostics
print(f"\n{'=' * 60}")
print(f"{title_prefix} Continuous Label Diagnostics")
print(f"{'=' * 60}")
display(labels.describe())
# Histogram
fig = px.histogram(
x=labels.to_numpy(),
nbins=50,
title=f"{title_prefix} Label Distribution",
)
fig.update_layout(height=300)
fig.show()
# Time series if available
if timestamp_col in df.columns:
fig = px.line(
df.select([timestamp_col, label_col]).drop_nulls().to_pandas(),
x=timestamp_col,
y=label_col,
title=f"{title_prefix} Labels Over Time",
)
fig.update_layout(height=300)
fig.show()
# Example: run diagnostics on fixed horizon labels
label_diagnostics(labels_returns, fh_label_col, title_prefix="Fixed Horizon (20d)")
# %% [markdown]
# ## 9. Label Method Comparison
#
# **Important**: We must compare continuous vs discrete labels separately.
# Mixing them on the same visual axis is conceptually misleading.
# %%
# Continuous targets comparison
fig = make_subplots(
rows=1,
cols=2,
subplot_titles=["Fixed Horizon Returns", "Label Return (from Triple-Barrier)"],
horizontal_spacing=0.1,
)
# Fixed horizon returns
fig.add_trace(
go.Histogram(
x=labels_returns[fh_label_col].drop_nulls().to_numpy(),
nbinsx=50,
name="Fixed Horizon",
showlegend=False,
),
row=1,
col=1,
)
# Triple-barrier final returns (before discretization)
if "label_return" in labels_tb.columns:
fig.add_trace(
go.Histogram(
x=labels_tb["label_return"].drop_nulls().to_numpy(),
nbinsx=50,
name="TB Label Return",
showlegend=False,
),
row=1,
col=2,
)
fig.update_layout(
height=350,
title_text="Continuous Targets Comparison",
)
fig.show()
# %%
# Discrete targets comparison
fig = make_subplots(
rows=1,
cols=3,
subplot_titles=["Fixed Horizon Binary", "Triple-Barrier Label", "ATR-Barrier Label"],
horizontal_spacing=0.08,
)
# Fixed horizon binary
fig.add_trace(
go.Histogram(
x=labels_binary[binary_label_col].drop_nulls().to_numpy(),
name="Binary",
showlegend=False,
),
row=1,
col=1,
)
# Triple barrier discrete
fig.add_trace(
go.Histogram(
x=labels_tb.filter(pl.col("label").is_not_null())["label"].to_numpy(),
name="Triple Barrier",
showlegend=False,
),
row=1,
col=2,
)
# ATR barrier discrete
fig.add_trace(
go.Histogram(
x=labels_atr.filter(pl.col("label").is_not_null())["label"].to_numpy(),
name="ATR Barrier",
showlegend=False,
),
row=1,
col=3,
)
# %%
fig.update_layout(
height=350,
title_text="Discrete Targets Comparison",
)
fig.show()
# %% [markdown]
# ## 10. Method Comparison: Decision Guide
#
# | Strategy Type | Recommended Method | Rationale |
# |--------------|-------------------|-----------|
# | Factor timing (monthly) | Fixed horizon | Simple, stationary targets |
# | Stat arb (intraday) | Fixed horizon binary | Speed matters |
# | Cross-sectional ranking | **Cross-sectional percentile** | Stable class balance |
# | Active trading | Triple barrier (ATR) | Matches trade mechanics |
# | Trend following | Trend scanning | Data-driven trend ID |
#
# **Key Considerations**:
#
# 1. **Anchor alignment**: Match label computation to execution timing
# 2. **Cross-sectional vs time-series**: Most equity/ETF strategies need cross-sectional
# 3. **Path-dependence**: Use triple-barrier when stop losses are part of the strategy
# 4. **Volatility adaptation**: ATR-based barriers for changing market conditions
# %% [markdown]
# ## Summary
#
# ### Key Takeaways
#
# 1. **Fixed horizon** for simple regression/classification targets
# 2. **Rolling percentile** for time-series adaptive thresholds (single instrument)
# 3. **Cross-sectional percentile** for relative ranking within a universe
# 4. **Triple barrier (ATR)** for realistic trading simulations with stops
# 5. **Trend scanning** for data-driven trend identification
# 6. **Anchor alignment** is critical - close-to-close vs next-open matters
#
# ### Production Usage
#
# For production label computation, use the experiment configuration module
# which centralizes method, horizon, and threshold choices per case study.
# The case study label notebooks (NB09NB17) demonstrate this pipeline.
#
# ### References
#
# - Lopez de Prado, M. (2018). *Advances in Financial Machine Learning*. Wiley.
# - Chapter 3: Labeling (Triple-Barrier, Meta-Labeling)
# - Chapter 4: Sample Weights (Uniqueness)
# - See [`04_minimum_favorable_adverse_excursion`](04_minimum_favorable_adverse_excursion.ipynb) for empirical barrier calibration