941 lines
35 KiB
Python
941 lines
35 KiB
Python
# ---
|
|
# jupyter:
|
|
# jupytext:
|
|
# cell_metadata_filter: tags,-all
|
|
# text_representation:
|
|
# extension: .py
|
|
# format_name: percent
|
|
# format_version: '1.3'
|
|
# jupytext_version: 1.19.1
|
|
# kernelspec:
|
|
# display_name: Python 3 (ipykernel)
|
|
# language: python
|
|
# name: python3
|
|
# ---
|
|
|
|
# %% [markdown]
|
|
# # ETF Case Study: Feasibility Analysis
|
|
#
|
|
# This notebook tests whether the ETF dataset can deliver on the strategy declared
|
|
# in `config/setup.yaml`. `setup.yaml` is the canonical, hand-curated source of
|
|
# truth: universe, costs, decision schedule, mapping class, labels, sweep grid,
|
|
# and evaluation protocol. This notebook does not write it. Instead, it produces
|
|
# the evidence that justifies its values: universe breadth over time, point-in-
|
|
# time eligibility, return distributions at multiple horizons relative to
|
|
# transaction costs, a walk-forward fold demonstration, and an edge-to-cost
|
|
# ratio. Findings persist to `config/exploration/feasibility_report.json`.
|
|
#
|
|
# ## Learning Objectives
|
|
#
|
|
# - Verify the data delivers what `setup.yaml` assumes (breadth, costs, holdout)
|
|
# - Implement point-in-time eligibility (no survivorship bias within the universe)
|
|
# - Test whether typical price moves exceed transaction costs at candidate horizons
|
|
# - Demonstrate the walk-forward structure has adequate breadth per fold
|
|
# - Persist findings as a stable artifact downstream notebooks can cite
|
|
#
|
|
# ## Book Reference
|
|
#
|
|
# Chapter 6, Sections 6.2-6.6
|
|
#
|
|
# ## Prerequisites
|
|
#
|
|
# - ETF data available via `load_etfs()`
|
|
# - `config/setup.yaml` exists (canonical strategy spec)
|
|
# - Understanding of walk-forward cross-validation (Section 6.5)
|
|
|
|
# %%
|
|
"""ETF Case Study: Feasibility Analysis."""
|
|
|
|
import json
|
|
import warnings
|
|
from datetime import UTC, datetime
|
|
|
|
import matplotlib.pyplot as plt
|
|
import numpy as np
|
|
import polars as pl
|
|
import seaborn as sns
|
|
import yaml
|
|
|
|
from data import load_etfs
|
|
from utils.paths import get_case_study_dir
|
|
|
|
warnings.filterwarnings("ignore")
|
|
sns.set_style("whitegrid")
|
|
|
|
# %% tags=["parameters"]
|
|
CASE_STUDY_ID = "etfs"
|
|
START_DATE = "2006-01-01"
|
|
ADV_THRESHOLD = 10e6
|
|
MAX_SYMBOLS = 0
|
|
|
|
# %% [markdown]
|
|
# ## Configuration
|
|
|
|
# %%
|
|
CASE_DIR = get_case_study_dir("etfs")
|
|
CASE_DIR.mkdir(parents=True, exist_ok=True)
|
|
EXPLORATION_DIR = CASE_DIR / "config" / "exploration"
|
|
EXPLORATION_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
with open(CASE_DIR / "config" / "setup.yaml") as f:
|
|
SETUP = yaml.safe_load(f)
|
|
|
|
STRATEGY_ID = SETUP["strategy_id"]
|
|
START_DATE = "2006-01-01"
|
|
END_DATE = "2025-12-31"
|
|
HOLDOUT_START = str(SETUP["evaluation"]["holdout_start"])
|
|
|
|
|
|
def as_float(value: object) -> float:
|
|
"""Convert Polars scalar outputs to plain float."""
|
|
return float(str(value))
|
|
|
|
|
|
# %% [markdown]
|
|
# ---
|
|
#
|
|
# ## Section A: Orientation (Section 6.2)
|
|
#
|
|
# ETFs provide diversified exposure across asset classes with high liquidity and
|
|
# low costs. This case study explores price-based signals at monthly cadences,
|
|
# where turnover economics are manageable.
|
|
#
|
|
# `setup.yaml` declares the trading setup. This notebook asks whether the data
|
|
# delivers on those declarations:
|
|
#
|
|
# - **Universe**: Is point-in-time breadth adequate across the sample period?
|
|
# - **Costs**: Do typical moves exceed the cost grid at candidate horizons?
|
|
# - **Evaluation**: Do walk-forward folds carry enough cross-sectional breadth?
|
|
# - **Holdout**: Is the holdout cleanly separated from training data?
|
|
|
|
# %% [markdown]
|
|
# ---
|
|
#
|
|
# ## Section B: Universe and Cost Feasibility (Sections 6.3-6.4)
|
|
|
|
# %% [markdown]
|
|
# ### B.1 Load and Explore the Data
|
|
|
|
# %%
|
|
etf_data = load_etfs()
|
|
|
|
start_dt = pl.Series([START_DATE]).str.to_date("%Y-%m-%d").item()
|
|
end_dt = pl.Series([END_DATE]).str.to_date("%Y-%m-%d").item()
|
|
|
|
prices = (
|
|
etf_data.filter(pl.col("timestamp").is_between(start_dt, end_dt))
|
|
.select(["symbol", "timestamp", "close", "volume"])
|
|
.sort(["symbol", "timestamp"])
|
|
)
|
|
|
|
n_symbols = prices["symbol"].n_unique()
|
|
print(f"Loaded {n_symbols} ETFs, {len(prices):,} rows")
|
|
print(f"Period: {prices['timestamp'].min()} to {prices['timestamp'].max()}")
|
|
|
|
# %% [markdown]
|
|
# **Note**: The `close` column from `load_etfs()` is adjusted for splits and
|
|
# dividends. We verify this by checking SPY's 2006 price level.
|
|
|
|
# %%
|
|
spy_2006 = prices.filter((pl.col("symbol") == "SPY") & (pl.col("timestamp").dt.year() == 2006))
|
|
spy_first_close = as_float(spy_2006["close"].first())
|
|
assert spy_first_close < 130, (
|
|
f"SPY 2006 close={spy_first_close:.2f} looks unadjusted (expected ~$90-100 adjusted)"
|
|
)
|
|
print(f"SPY 2006 first close: ${spy_first_close:.2f} (adjusted -- verified)")
|
|
|
|
# %% [markdown]
|
|
# ### B.2 Universe Selection: Rolling Point-in-Time Methodology
|
|
#
|
|
# A critical mistake in backtesting is using **end-of-sample information** to select
|
|
# the universe. For example, filtering to "ETFs with ADV > $50M" using today's volume
|
|
# excludes ETFs that had sufficient volume historically but have since declined.
|
|
#
|
|
# The correct approach is **rolling selection**: at each decision point, use only
|
|
# information available at that time.
|
|
#
|
|
# For this case study, we will:
|
|
# 1. First explore the historical distribution of dollar volume
|
|
# 2. Set a threshold that is realistic but lenient (this is a demo, not production)
|
|
# 3. Implement point-in-time universe membership
|
|
|
|
# %% [markdown]
|
|
# #### Exploratory Analysis: Dollar Volume Distribution Over Time
|
|
#
|
|
# Before setting thresholds, we need to understand how dollar volume has evolved.
|
|
# A threshold that seems reasonable today may have excluded most ETFs in 2007.
|
|
|
|
# %%
|
|
# Compute daily dollar volume
|
|
prices_with_dv = prices.with_columns((pl.col("close") * pl.col("volume")).alias("dollar_volume"))
|
|
|
|
# Annual statistics: median dollar volume across all ETF-days in each year
|
|
annual_dv_stats = (
|
|
prices_with_dv.with_columns(pl.col("timestamp").dt.year().alias("year"))
|
|
.group_by("year")
|
|
.agg(
|
|
pl.col("dollar_volume").median().alias("median_dv"),
|
|
pl.col("dollar_volume").quantile(0.25).alias("p25_dv"),
|
|
pl.col("dollar_volume").quantile(0.75).alias("p75_dv"),
|
|
pl.col("dollar_volume").quantile(0.90).alias("p90_dv"),
|
|
pl.col("symbol").n_unique().alias("n_etfs"),
|
|
)
|
|
.sort("year")
|
|
)
|
|
|
|
# Display as DataFrame (values in millions)
|
|
annual_dv_stats.select(
|
|
[
|
|
"year",
|
|
(pl.col("median_dv") / 1e6).round(1).alias("median_$M"),
|
|
(pl.col("p75_dv") / 1e6).round(1).alias("p75_$M"),
|
|
(pl.col("p90_dv") / 1e6).round(1).alias("p90_$M"),
|
|
"n_etfs",
|
|
]
|
|
)
|
|
|
|
# %% [markdown]
|
|
# #### Visualize the Dollar Volume Evolution
|
|
|
|
# %%
|
|
fig, ax = plt.subplots(figsize=(10, 4))
|
|
|
|
years = annual_dv_stats["year"].to_numpy()
|
|
median_dv = annual_dv_stats["median_dv"].to_numpy() / 1e6
|
|
p75_dv = annual_dv_stats["p75_dv"].to_numpy() / 1e6
|
|
p90_dv = annual_dv_stats["p90_dv"].to_numpy() / 1e6
|
|
|
|
ax.fill_between(years, median_dv, p75_dv, alpha=0.3, color="#606060", label="p50-p75")
|
|
ax.fill_between(years, p75_dv, p90_dv, alpha=0.2, color="#808080", label="p75-p90")
|
|
ax.plot(years, median_dv, "o-", color="#404040", linewidth=2, label="Median")
|
|
ax.plot(years, p90_dv, "s--", color="#606060", linewidth=1, label="90th percentile")
|
|
|
|
# Show potential thresholds
|
|
ax.axhline(10, color="black", linestyle=":", linewidth=1.5, label="$10M threshold")
|
|
ax.axhline(50, color="#404040", linestyle=":", linewidth=1.5, label="$50M threshold")
|
|
|
|
ax.set_xlabel("Year")
|
|
ax.set_ylabel("Dollar Volume ($ millions/day)")
|
|
ax.set_title("ETF Dollar Volume Distribution Over Time")
|
|
ax.legend(loc="upper left")
|
|
ax.set_ylim(0, None)
|
|
sns.despine()
|
|
|
|
fig.tight_layout()
|
|
plt.show()
|
|
|
|
# %% [markdown]
|
|
# #### Interpretation
|
|
#
|
|
# The figure shows that:
|
|
# - Dollar volume has grown substantially over time (market growth + ETF adoption)
|
|
# - A \$50M/day threshold would have excluded many ETFs in early years
|
|
# - A \$10M/day threshold is more inclusive historically
|
|
#
|
|
# **For this demo case study**, we use a lenient threshold ($10M) to maintain a
|
|
# broad universe (the sample of 100 ETFs has already been selected based on liquidity). A production system might use higher thresholds but would need
|
|
# to account for the changing market structure.
|
|
|
|
# %% [markdown]
|
|
# #### Implement Rolling Universe Selection
|
|
#
|
|
# The proper point-in-time approach: at the end of each year, compute trailing ADV
|
|
# and select ETFs for the following year.
|
|
|
|
# %%
|
|
# For each ETF, compute trailing 252-day ADV at each month-end
|
|
# Then apply threshold to create point-in-time universe membership
|
|
|
|
# Simplification for demo: compute annual ADV and apply to next year.
|
|
# Note: annual granularity is coarser than the monthly decision cadence.
|
|
# An ETF that becomes illiquid in March remains "eligible" through December.
|
|
# Production systems would recompute eligibility at each decision date using
|
|
# a trailing window (e.g., 63-day or 126-day rolling ADV).
|
|
annual_adv = (
|
|
prices_with_dv.with_columns(pl.col("timestamp").dt.year().alias("year"))
|
|
.group_by(["symbol", "year"])
|
|
.agg(
|
|
pl.col("dollar_volume").mean().alias("avg_dv"),
|
|
pl.col("timestamp").count().alias("n_days"),
|
|
)
|
|
.filter(pl.col("n_days") >= 200) # Require most of the year
|
|
.sort(["symbol", "year"])
|
|
)
|
|
|
|
# Threshold: $10M/day average (lenient for demo)
|
|
ADV_THRESHOLD = 10e6
|
|
|
|
eligible_by_year = (
|
|
annual_adv.filter(pl.col("avg_dv") >= ADV_THRESHOLD)
|
|
.with_columns((pl.col("year") + 1).alias("eligible_year"))
|
|
.select(["symbol", "eligible_year", "avg_dv"])
|
|
)
|
|
|
|
# Count eligible ETFs per year
|
|
eligibility_counts = (
|
|
eligible_by_year.group_by("eligible_year")
|
|
.agg(pl.col("symbol").n_unique().alias("n_eligible"))
|
|
.sort("eligible_year")
|
|
)
|
|
|
|
print(f"Eligible ETFs per year (ADV threshold: ${ADV_THRESHOLD / 1e6:.0f}M):")
|
|
eligibility_counts
|
|
|
|
# %% [markdown]
|
|
# #### Point-in-Time Universe: Eligibility Mask
|
|
#
|
|
# **Critical**: We do NOT filter to a fixed asset list based on full-sample eligibility.
|
|
# That would use future information (survivorship bias). Instead, we create a
|
|
# point-in-time eligibility table that downstream code uses at each decision date.
|
|
#
|
|
# The rule: an ETF is tradable in year Y if it met the ADV threshold in year Y-1.
|
|
#
|
|
# **Two-Layer Bias Assessment**:
|
|
#
|
|
# Understanding survivorship bias in this case study requires distinguishing two layers:
|
|
#
|
|
# - **Layer 1 (universe composition)**: The 100 ETFs were selected *after the fact*
|
|
# based on their relevance and liquidity at sample end (2025). ETFs that were liquid
|
|
# in 2007 but have since been delisted or merged are excluded. This bias **cannot be
|
|
# fully resolved** without historical constituent data, which is not readily available
|
|
# for ETFs. It inflates apparent signal quality by removing negative outcomes.
|
|
#
|
|
# - **Layer 2 (eligibility within the universe)**: Given the 100-ETF sample, the
|
|
# point-in-time ADV filter correctly prevents using future liquidity information.
|
|
# An ETF is only tradable in year Y if it met the threshold in year Y-1. This layer
|
|
# **is** point-in-time correct.
|
|
#
|
|
# The two layers partially work at cross purposes: the rolling filter creates rigor at
|
|
# Layer 2 while Layer 1 already embeds the bias it's trying to avoid. Readers should
|
|
# understand which bias is mitigated (within-universe eligibility) and which is not
|
|
# (universe composition).
|
|
#
|
|
# Additionally, the $10M ADV threshold is not inflation-adjusted. Ten million
|
|
# dollars in 2006 had different purchasing power than in 2025. A more rigorous
|
|
# approach would step down the threshold for earlier years (e.g., scale by CPI
|
|
# or market cap growth). We proceed with the fixed threshold for simplicity---the
|
|
# 100-ETF universe was already curated for liquidity.
|
|
|
|
# %%
|
|
# All assets in the dataset are candidates
|
|
ASSETS = prices["symbol"].unique().sort().to_list()
|
|
|
|
# Eligibility table: which assets are tradable in which years
|
|
# eligibility_by_year already has (asset, eligible_year) pairs
|
|
eligibility_table = eligible_by_year.select(["symbol", "eligible_year"]).unique()
|
|
|
|
# Summary: how many ETFs eligible per year
|
|
eligibility_summary = (
|
|
eligibility_table.group_by("eligible_year")
|
|
.agg(pl.col("symbol").count().alias("n_eligible"))
|
|
.sort("eligible_year")
|
|
)
|
|
|
|
print(f"\nCandidate universe: {len(ASSETS)} ETFs (all assets in dataset)")
|
|
print("Point-in-time eligibility determined annually based on prior-year ADV")
|
|
print("\nEligible ETFs by year:")
|
|
eligibility_summary
|
|
|
|
# %% [markdown]
|
|
# ---
|
|
#
|
|
# ### B.3 Trading Cost Analysis: Horizon Feasibility
|
|
#
|
|
# A fundamental question for any trading strategy is: **at which holding periods
|
|
# do typical price moves exceed transaction costs?**
|
|
#
|
|
# This analysis informs which horizons are worth exploring, without prescribing
|
|
# a specific signal type.
|
|
|
|
# %% [markdown]
|
|
# #### Return Distributions at Multiple Horizons
|
|
#
|
|
# We compute returns at daily, weekly, and monthly frequencies and examine their
|
|
# distributions relative to transaction costs.
|
|
|
|
# %%
|
|
# Filter to universe assets
|
|
universe_prices = prices.filter(pl.col("symbol").is_in(ASSETS)).sort(["symbol", "timestamp"])
|
|
|
|
# Daily returns
|
|
daily_returns = (
|
|
universe_prices.with_columns(
|
|
(pl.col("close") / pl.col("close").shift(1) - 1).over("symbol").alias("return")
|
|
)
|
|
.filter(pl.col("return").is_not_null())
|
|
.select(["symbol", "timestamp", "return"])
|
|
)
|
|
|
|
# Weekly returns (resample to week-end)
|
|
weekly_prices = (
|
|
universe_prices.with_columns(pl.col("timestamp").dt.strftime("%G-W%V").alias("week"))
|
|
.group_by(["symbol", "week"])
|
|
.agg(pl.col("close").last().alias("close"), pl.col("timestamp").max().alias("timestamp"))
|
|
.sort(["symbol", "timestamp"])
|
|
)
|
|
weekly_returns = weekly_prices.with_columns(
|
|
(pl.col("close") / pl.col("close").shift(1) - 1).over("symbol").alias("return")
|
|
).filter(pl.col("return").is_not_null())
|
|
|
|
# Monthly returns
|
|
monthly_prices = (
|
|
universe_prices.with_columns(pl.col("timestamp").dt.strftime("%Y-%m").alias("month"))
|
|
.group_by(["symbol", "month"])
|
|
.agg(pl.col("close").last().alias("close"), pl.col("timestamp").max().alias("timestamp"))
|
|
.sort(["symbol", "timestamp"])
|
|
)
|
|
monthly_returns = monthly_prices.with_columns(
|
|
(pl.col("close") / pl.col("close").shift(1) - 1).over("symbol").alias("return")
|
|
).filter(pl.col("return").is_not_null())
|
|
|
|
print(f"Daily returns: {len(daily_returns):,} observations")
|
|
print(f"Weekly returns: {len(weekly_returns):,} observations")
|
|
print(f"Monthly returns: {len(monthly_returns):,} observations")
|
|
|
|
# %% [markdown]
|
|
# #### Summary Statistics by Horizon
|
|
#
|
|
# The key question: what fraction of absolute price moves exceed the round-trip cost?
|
|
|
|
# %%
|
|
# Extract absolute returns
|
|
daily_abs = daily_returns["return"].abs().to_numpy()
|
|
weekly_abs = weekly_returns["return"].abs().to_numpy()
|
|
monthly_abs = monthly_returns["return"].abs().to_numpy()
|
|
|
|
# Cost assumptions: per-leg cost 10 bps → round-trip 20 bps
|
|
PER_LEG_COST_BPS = 10
|
|
ROUND_TRIP_COST_BPS = 2 * PER_LEG_COST_BPS
|
|
ROUND_TRIP_COST = ROUND_TRIP_COST_BPS / 10_000 # 0.0020
|
|
|
|
|
|
# Build summary DataFrame
|
|
def compute_return_stats(data: np.ndarray, horizon: str) -> dict:
|
|
"""Compute return distribution statistics for a given horizon."""
|
|
return {
|
|
"horizon": horizon,
|
|
"median_pct": np.median(data) * 100,
|
|
"mean_pct": np.mean(data) * 100,
|
|
"std_pct": np.std(data) * 100,
|
|
"p75_pct": np.percentile(data, 75) * 100,
|
|
"p95_pct": np.percentile(data, 95) * 100,
|
|
"pct_above_20bps": (data > ROUND_TRIP_COST).mean() * 100,
|
|
}
|
|
|
|
|
|
return_stats = pl.DataFrame(
|
|
[
|
|
compute_return_stats(daily_abs, "Daily"),
|
|
compute_return_stats(weekly_abs, "Weekly"),
|
|
compute_return_stats(monthly_abs, "Monthly"),
|
|
]
|
|
)
|
|
|
|
# %% [markdown]
|
|
# **Return Distribution Summary** (absolute returns):
|
|
|
|
# %%
|
|
return_stats.select(
|
|
[
|
|
"horizon",
|
|
pl.col("median_pct").round(2).alias("median %"),
|
|
pl.col("mean_pct").round(2).alias("mean %"),
|
|
pl.col("std_pct").round(2).alias("std %"),
|
|
pl.col("p75_pct").round(2).alias("p75 %"),
|
|
pl.col("p95_pct").round(2).alias("p95 %"),
|
|
]
|
|
)
|
|
|
|
# %% [markdown]
|
|
# **Fraction of moves exceeding cost threshold** (round-trip = 20 bps):
|
|
|
|
# %%
|
|
# Cost threshold analysis
|
|
COST_THRESHOLDS_BPS = [10, 20, 30, 50]
|
|
|
|
cost_exceedance = []
|
|
for horizon, data in [("Daily", daily_abs), ("Weekly", weekly_abs), ("Monthly", monthly_abs)]:
|
|
row = {"horizon": horizon}
|
|
for cost_bps in COST_THRESHOLDS_BPS:
|
|
row[f"{cost_bps}_bps"] = (data > cost_bps / 10_000).mean() * 100
|
|
cost_exceedance.append(row)
|
|
|
|
cost_df = pl.DataFrame(cost_exceedance)
|
|
cost_df.select(
|
|
[
|
|
"horizon",
|
|
pl.col("10_bps").round(1).alias("10 bps %"),
|
|
pl.col("20_bps").round(1).alias("20 bps %"),
|
|
pl.col("30_bps").round(1).alias("30 bps %"),
|
|
pl.col("50_bps").round(1).alias("50 bps %"),
|
|
]
|
|
)
|
|
|
|
# %% [markdown]
|
|
# #### Visualize Return Distributions
|
|
#
|
|
# The visualization shows return distributions at each horizon with cost reference lines.
|
|
# Note how the distributions widen as the horizon increases.
|
|
|
|
# %%
|
|
fig, axes = plt.subplots(1, 3, figsize=(14, 4))
|
|
|
|
# Use grayscale for book compatibility
|
|
horizons = [
|
|
("Daily", daily_abs, "#404040"),
|
|
("Weekly", weekly_abs, "#707070"),
|
|
("Monthly", monthly_abs, "#a0a0a0"),
|
|
]
|
|
|
|
for ax, (label, data, color) in zip(axes, horizons, strict=False):
|
|
# Use different x-limits to show the actual distribution shape
|
|
if label == "Daily":
|
|
xlim = 0.06
|
|
elif label == "Weekly":
|
|
xlim = 0.10
|
|
else:
|
|
xlim = 0.15
|
|
|
|
# Histogram
|
|
bins = np.linspace(0, xlim, 40)
|
|
ax.hist(data[data < xlim], bins=bins, density=True, alpha=0.7, color=color, edgecolor="white")
|
|
|
|
# Cost reference line
|
|
ax.axvline(ROUND_TRIP_COST, color="black", linestyle="--", linewidth=2, label="Cost: 20 bps")
|
|
|
|
# Statistics annotation
|
|
median_val = np.median(data)
|
|
frac_above = (data > ROUND_TRIP_COST).mean()
|
|
ax.axvline(median_val, color="#404040", linestyle="-", linewidth=1.5, alpha=0.7)
|
|
|
|
stats_text = f"Median: {median_val * 100:.2f}%\n{frac_above:.0%} > cost"
|
|
ax.text(
|
|
0.95,
|
|
0.95,
|
|
stats_text,
|
|
transform=ax.transAxes,
|
|
ha="right",
|
|
va="top",
|
|
fontsize=10,
|
|
bbox=dict(boxstyle="round,pad=0.3", facecolor="white", alpha=0.8),
|
|
)
|
|
|
|
ax.set_xlabel("Absolute Return")
|
|
ax.set_title(f"{label} Horizon")
|
|
ax.set_xlim(0, xlim)
|
|
ax.legend(loc="upper right", fontsize=9)
|
|
|
|
axes[0].set_ylabel("Density")
|
|
fig.suptitle("ETF Return Distributions by Horizon", fontsize=12, fontweight="bold")
|
|
sns.despine()
|
|
fig.tight_layout()
|
|
plt.show()
|
|
|
|
# %% [markdown]
|
|
# #### Interpretation
|
|
#
|
|
# The return distributions clearly differ by horizon. Looking at the tables above:
|
|
#
|
|
# - **Daily**: Median moves are small relative to costs. A majority of moves exceed
|
|
# the 20 bps threshold, but high-turnover strategies operate with thin margins.
|
|
# - **Weekly**: Moves are larger, providing reasonable headroom over costs.
|
|
# - **Monthly**: Moves comfortably exceed costs in the vast majority of cases.
|
|
#
|
|
# **Connecting costs to the cost model**: The cost exceedance table above spans
|
|
# 10 to 50 bps round-trip. Our stated cost model (5--15 bps per leg, i.e., 10--30
|
|
# bps round-trip) maps directly to the 10, 20, and 30 bps columns. For large liquid
|
|
# ETFs (SPY, QQQ) at the low end (10 bps RT), even daily moves exceed costs ~75%
|
|
# of the time. For smaller thematic ETFs at the high end (30 bps RT), only monthly
|
|
# moves are comfortably above cost in most observations. The choice of horizon
|
|
# therefore depends on the *cost tier* of the ETFs you're trading, not just the
|
|
# horizon in isolation.
|
|
#
|
|
# **This analysis guides but does not dictate**: We establish that all three horizons
|
|
# are feasible. Chapter 7 will test features at each horizon.
|
|
|
|
# %% [markdown]
|
|
# #### Cost-Regime Choice: per-Share + Tiered Spread
|
|
#
|
|
# `setup.yaml::costs.model = per_share_plus_spread` — costs are declared as
|
|
# a per-share commission plus a per-asset half-spread in dollars per share,
|
|
# not as a flat bps rate.
|
|
#
|
|
# **Commission.** The `per_share = 0.0035` value is IBKR Pro Tiered's top
|
|
# tier (the rate readers see at the broker when they execute monthly ETF
|
|
# rotations at retail scale). This is the library default in
|
|
# `ml4t-backtest` set because we downloaded IBKR Pro pricing for exactly
|
|
# this purpose — the cost dispatcher has nothing to invent.
|
|
#
|
|
# **Spread.** The per-asset half-spread map in
|
|
# `setup.yaml::costs.asset_spreads` ties each ticker to an industry-
|
|
# knowledge tier: mega-ETFs (SPY/QQQ/IWM/EFA/EEM/DIA/VTI/VOO) at 0.5¢,
|
|
# sector XL* funds at 1¢, default 2¢ for thematic/regional/factor ETFs.
|
|
# This is a *reasoned simplification* — ETF nominal prices do not drift
|
|
# much over the validation window, so a static map is defensible — but it
|
|
# is **not** an empirical measurement. We do not have AlgoSeek-grade NBBO
|
|
# quotes spanning the broad ETF universe; the analogous case study with
|
|
# measured per-asset spreads is `nasdaq100_microstructure`, which has
|
|
# AlgoSeek minute-bar NBBO close quotes for its 100-ticker NASDAQ-100
|
|
# universe. The nasdaq100 measurements are NOT a proxy for liquid-ETF
|
|
# spreads: they cover different securities under different microstructure.
|
|
# The tiered ETF map should be read as "what an institutional trader
|
|
# knowledgeable about US ETFs would assert as the going rate" rather than
|
|
# as a quote-grounded estimate. The cost sensitivity sweep in `16_costs.py`
|
|
# probes how robust this assumption is across both bps and per-share
|
|
# grids.
|
|
|
|
# %% [markdown]
|
|
# ### B.4 Feasibility Ratio
|
|
#
|
|
# A simple edge-to-cost ratio confirms that typical signal magnitude
|
|
# comfortably exceeds transaction costs before proceeding further.
|
|
|
|
# %%
|
|
median_monthly_abs_return = float(np.median(monthly_abs))
|
|
feasibility_ratio = median_monthly_abs_return / ROUND_TRIP_COST
|
|
print(
|
|
f"Median monthly |return|: {median_monthly_abs_return:.4f} ({median_monthly_abs_return * 10000:.1f} bps)"
|
|
)
|
|
print(f"Round-trip cost: {ROUND_TRIP_COST:.4f} ({ROUND_TRIP_COST * 10000:.0f} bps)")
|
|
print(f"Edge-to-cost ratio: {feasibility_ratio:.1f}x")
|
|
print(f"Assessment: {'PROCEED' if feasibility_ratio > 1.0 else 'KILL -- edge too thin'}")
|
|
|
|
# %% [markdown]
|
|
# ---
|
|
#
|
|
# ## Section C: Design Decisions
|
|
#
|
|
# Design decisions are the strategy choices encoded in `setup.yaml` that the
|
|
# feasibility evidence above supports. They are justified here, not in the YAML.
|
|
|
|
# %% [markdown]
|
|
# ### C.1 Decision Cadence
|
|
#
|
|
# Monthly month-end is the conventional cadence for cross-asset momentum studies
|
|
# (Moskowitz, Ooi, and Pedersen 2012; Asness, Moskowitz, and Pedersen 2013), which
|
|
# makes our results directly comparable to published benchmarks. However, the choice
|
|
# deserves scrutiny rather than deference.
|
|
#
|
|
# A **weekly (5-day) cadence** would multiply effective sample size roughly 4x
|
|
# (~1,040 weekly vs ~240 monthly decision dates), improving IC estimate stability
|
|
# per fold. Purging becomes cheaper: a 1-week purge burns 5 trading days vs 21 for
|
|
# monthly. The cost analysis above confirms that weekly absolute returns comfortably
|
|
# exceed the 20 bps round-trip threshold for most of the universe.
|
|
#
|
|
# The complications are real: turnover increases mechanically (even if signals are
|
|
# slow-moving, weekly rebalancing amplifies noise-driven rank changes), and coupling
|
|
# signal horizon with decision cadence requires care---you can evaluate a signal weekly
|
|
# but still use 4-week forward labels, at the cost of overlapping labels that demand
|
|
# proper purge/embargo handling.
|
|
#
|
|
# We treat cadence as a **first-class parameter**. `setup.yaml` declares the
|
|
# default monthly cadence (`decision.cadence: monthly_month_end`) and exposes
|
|
# `fwd_ret_5d` as a weekly-horizon label variant for comparison.
|
|
|
|
# %% [markdown]
|
|
# ### C.2 Kill Conditions
|
|
#
|
|
# Kill conditions are falsifiable checkpoints --- if any triggers, the strategy
|
|
# is abandoned or substantially reworked. Defining them upfront prevents
|
|
# post-hoc rationalization. The thresholds below are anchored to the feasibility
|
|
# evidence above (cost-exceedance and edge-to-cost analysis):
|
|
#
|
|
# - **KC1 (IC floor)**: IC < 0.01 with t-stat < 2.0 across all lookback horizons
|
|
# (3M-12M). Gate: Chapter 8 feature evaluation.
|
|
# - **KC2 (edge-cost)**: Edge-to-cost ratio < 1.0x after realistic transaction
|
|
# costs. Gate: Chapter 7 label evaluation / Chapter 17 backtest. B.4 above
|
|
# tests this gate on raw return magnitudes before the model is even trained.
|
|
# - **KC3 (EW underperformance)**: Equal-weight benchmark posts a higher Sharpe
|
|
# and lower max drawdown than the strategy across all test folds. Gate:
|
|
# Chapter 17 backtest.
|
|
|
|
# %% [markdown]
|
|
# ### C.3 Mapping Class
|
|
#
|
|
# `setup.yaml` declares the simplest credible mapping: **long-only, equal-weight,
|
|
# top-N** (`mapping.class: long_only_rank_and_rebalance`, `sizing: equal_weight`).
|
|
# Long-only is appropriate because (a) most ETFs are difficult or expensive to
|
|
# short, (b) the target audience for this case study is long-only portfolio
|
|
# construction, and (c) it isolates the ranking signal from short-side
|
|
# complexity. Equal-weight is the minimal-assumption sizing rule---it avoids
|
|
# introducing a secondary optimization (risk-parity, inverse-vol) that would
|
|
# confound evaluation of the ranking signal itself. Chapter 18 explores
|
|
# alternative weighting schemes via the `backtest.sweep.allocators` grid in
|
|
# `setup.yaml`.
|
|
|
|
# %% [markdown]
|
|
# ---
|
|
#
|
|
# ## Section D: Walk-Forward Structure (Section 6.5)
|
|
#
|
|
# We verify that the data supports the walk-forward design declared in
|
|
# `setup.yaml::evaluation` (`n_splits`, `train_size`, `val_size`, `holdout_start`).
|
|
|
|
# %% [markdown]
|
|
# ### D.1 Effective Sample Size and Data Coverage
|
|
|
|
# %%
|
|
n_decision_dates = monthly_prices["month"].n_unique()
|
|
first_month = monthly_prices["month"].min()
|
|
last_month = monthly_prices["month"].max()
|
|
n_years = n_decision_dates / 12
|
|
|
|
print("Data Coverage:")
|
|
print(f" Period: {first_month} to {last_month}")
|
|
print(f" Decision points (months): {n_decision_dates}")
|
|
print(f" Approx years: {n_years:.1f}")
|
|
|
|
# %% [markdown]
|
|
# ### D.2 Walk-Forward Fold Demonstration
|
|
#
|
|
# `case_studies/utils/cv_window.py` owns the operational splits; this cell
|
|
# reproduces the fold boundaries from canonical `setup.yaml` parameters to verify
|
|
# the data supports the declared design. Each fold has:
|
|
#
|
|
# - **Train period**: `setup.yaml::evaluation.train_size`
|
|
# - **Test period**: `setup.yaml::evaluation.val_size`
|
|
# - **Purge gap**: 1 month between train end and test start (matches the 21D
|
|
# buffer for the 1-month primary label)
|
|
|
|
# %%
|
|
n_splits_declared = int(SETUP["evaluation"]["n_splits"])
|
|
purge_months = 1 # matches setup.yaml::labels.buffer (21D) at monthly cadence
|
|
train_months = 10 * 12 # setup.yaml::evaluation.train_size = 10Y
|
|
test_months = 1 * 12 # setup.yaml::evaluation.val_size = 1Y
|
|
step_months = 1 * 12 # consecutive, non-overlapping
|
|
|
|
# Get sorted list of decision dates (month-ends)
|
|
decision_dates = (
|
|
monthly_prices.select("timestamp").unique().sort("timestamp")["timestamp"].to_list()
|
|
)
|
|
|
|
# Holdout boundary
|
|
holdout_start_dt = pl.Series([HOLDOUT_START]).str.to_date("%Y-%m-%d").item()
|
|
cv_dates = [d for d in decision_dates if d < holdout_start_dt]
|
|
|
|
# Generate splits
|
|
splits = []
|
|
test_start_idx = train_months
|
|
|
|
while test_start_idx + test_months <= len(cv_dates):
|
|
train_start_idx = test_start_idx - train_months
|
|
train_end_idx = test_start_idx - purge_months # purge gap
|
|
test_end_idx = test_start_idx + test_months
|
|
|
|
split = {
|
|
"fold": len(splits) + 1,
|
|
"train_start": cv_dates[train_start_idx].strftime("%Y-%m-%d"),
|
|
"train_end": cv_dates[train_end_idx - 1].strftime("%Y-%m-%d"),
|
|
"test_start": cv_dates[test_start_idx].strftime("%Y-%m-%d"),
|
|
"test_end": cv_dates[test_end_idx - 1].strftime("%Y-%m-%d"),
|
|
"purge_months": purge_months,
|
|
}
|
|
splits.append(split)
|
|
test_start_idx += step_months
|
|
|
|
print(f"Generated {len(splits)} walk-forward folds")
|
|
|
|
# Sanity check: verify all folds fit within pre-holdout data
|
|
assert len(splits) == n_splits_declared, (
|
|
f"Expected {n_splits_declared} folds (setup.yaml), got {len(splits)}"
|
|
)
|
|
last_test_end = splits[-1]["test_end"]
|
|
print(f"Last fold test end: {last_test_end} | Holdout start: {HOLDOUT_START}")
|
|
assert last_test_end < HOLDOUT_START, (
|
|
f"Last fold ({last_test_end}) overlaps holdout ({HOLDOUT_START})"
|
|
)
|
|
|
|
# %% [markdown]
|
|
# **Walk-forward fold summary:**
|
|
|
|
# %%
|
|
splits_df = pl.DataFrame(splits)
|
|
splits_df
|
|
|
|
# %% [markdown]
|
|
# #### Universe Breadth per Fold
|
|
#
|
|
# We verify that each fold has adequate cross-sectional breadth by counting
|
|
# eligible ETFs in each test period. Thin early folds would weaken cross-sectional
|
|
# signals (quintile sorting requires reasonable N).
|
|
|
|
# %%
|
|
fold_breadth = []
|
|
for split in splits:
|
|
test_year = int(split["test_start"][:4])
|
|
n_eligible = eligibility_table.filter(pl.col("eligible_year") == test_year).height
|
|
fold_breadth.append(
|
|
{
|
|
"fold": split["fold"],
|
|
"test_year": test_year,
|
|
"n_eligible": n_eligible,
|
|
}
|
|
)
|
|
|
|
fold_breadth_df = pl.DataFrame(fold_breadth)
|
|
print("Eligible ETFs per fold test period:")
|
|
fold_breadth_df
|
|
|
|
# %% [markdown]
|
|
# All folds have 70+ eligible ETFs, which is sufficient for cross-sectional ranking
|
|
# and quintile construction. The early folds have fewer ETFs because some symbols
|
|
# (e.g., ARKK, XLC) had not yet launched---this is the correct point-in-time behavior,
|
|
# not a data problem.
|
|
|
|
# %% [markdown]
|
|
# ---
|
|
#
|
|
# ## Section E: Eligibility Artifact
|
|
#
|
|
# Point-in-time (asset, year) eligibility is the one decision-relevant artifact
|
|
# this notebook generates (everything else lives in `setup.yaml`). It is keyed by
|
|
# year because the ADV filter runs annually; downstream labels and features
|
|
# consume it to enforce point-in-time membership.
|
|
|
|
# %%
|
|
eligibility_path = CASE_DIR / "eligibility.csv"
|
|
eligibility_table.select(["symbol", "eligible_year"]).sort(["symbol", "eligible_year"]).write_csv(
|
|
eligibility_path
|
|
)
|
|
print(f"Written: {eligibility_path} ({len(eligibility_table):,} asset-year pairs)")
|
|
|
|
# %% [markdown]
|
|
# ---
|
|
#
|
|
# ## Section F: Findings vs `setup.yaml`
|
|
#
|
|
# The canonical strategy declarations live in `config/setup.yaml`. This section
|
|
# enumerates each declared knob alongside the feasibility evidence above that
|
|
# motivates it. Setup.yaml is not regenerated here --- it is the hand-curated
|
|
# source of truth, and this notebook reads it.
|
|
|
|
# %%
|
|
# Numbers used in the side-by-side report
|
|
median_d_abs_pct = float(np.median(daily_abs) * 100)
|
|
median_w_abs_pct = float(np.median(weekly_abs) * 100)
|
|
median_m_abs_pct = float(np.median(monthly_abs) * 100)
|
|
frac_d_above_20 = float((daily_abs > 0.0020).mean())
|
|
frac_w_above_20 = float((weekly_abs > 0.0020).mean())
|
|
frac_m_above_20 = float((monthly_abs > 0.0020).mean())
|
|
n_eligible_min = int(min(fb["n_eligible"] for fb in fold_breadth))
|
|
n_eligible_max = int(max(fb["n_eligible"] for fb in fold_breadth))
|
|
n_folds_generated = int(len(splits))
|
|
|
|
print("=" * 78)
|
|
print("Setup.yaml knobs vs feasibility evidence")
|
|
print("=" * 78)
|
|
|
|
print()
|
|
print(f"universe.n_assets = {SETUP['universe']['n_assets']}")
|
|
print(f" -> point-in-time eligible per fold: min={n_eligible_min}, max={n_eligible_max}")
|
|
print(" -> sufficient for cross-sectional ranking (>=70 per fold)")
|
|
|
|
print()
|
|
print(f"decision.cadence = {SETUP['decision']['cadence']}")
|
|
print(
|
|
f" -> median |monthly return| = {median_m_abs_pct:.2f}%; "
|
|
f"{frac_m_above_20 * 100:.0f}% exceed 20bps RT"
|
|
)
|
|
|
|
print()
|
|
print(f"costs.model = {SETUP['costs']['model']}")
|
|
print(f" -> at 20bps RT: edge-to-cost = {feasibility_ratio:.0f}x at monthly horizon")
|
|
print(f" -> daily moves > 20bps: {frac_d_above_20 * 100:.0f}%")
|
|
print(f" -> weekly moves > 20bps: {frac_w_above_20 * 100:.0f}%")
|
|
|
|
print()
|
|
print(f"labels.primary = {SETUP['labels']['primary']}")
|
|
print(
|
|
f" -> median |21d return| = {median_m_abs_pct:.2f}% = "
|
|
f"{(median_m_abs_pct / 100) / 0.002:.0f}x a 20bps cost"
|
|
)
|
|
|
|
print()
|
|
print(f"labels.variants = {SETUP['labels']['variants']}")
|
|
print(
|
|
f" -> median |weekly return| = {median_w_abs_pct:.2f}% ({frac_w_above_20 * 100:.0f}% > 20bps)"
|
|
)
|
|
|
|
print()
|
|
print(f"evaluation.n_splits = {SETUP['evaluation']['n_splits']}")
|
|
print(f" -> generated {n_folds_generated} folds; declared count matches")
|
|
print(
|
|
f" -> holdout {SETUP['evaluation']['holdout_start']} "
|
|
f"to {SETUP['evaluation']['holdout_end']}; "
|
|
f"last test ends {splits[-1]['test_end']}"
|
|
)
|
|
|
|
# %% [markdown]
|
|
# ### Persist Feasibility Findings
|
|
|
|
# %%
|
|
feasibility_report = {
|
|
"case_study_id": "etfs",
|
|
"computed_at_utc": datetime.now(UTC).isoformat(),
|
|
"data_period": {"start": START_DATE, "end": END_DATE},
|
|
"universe": {
|
|
"n_assets_declared": int(SETUP["universe"]["n_assets"]),
|
|
"n_eligible_per_fold_min": n_eligible_min,
|
|
"n_eligible_per_fold_max": n_eligible_max,
|
|
},
|
|
"return_distribution_abs_pct": {
|
|
"daily_median": median_d_abs_pct,
|
|
"weekly_median": median_w_abs_pct,
|
|
"monthly_median": median_m_abs_pct,
|
|
},
|
|
"cost_exceedance_at_20bps_pct": {
|
|
"daily": frac_d_above_20 * 100,
|
|
"weekly": frac_w_above_20 * 100,
|
|
"monthly": frac_m_above_20 * 100,
|
|
},
|
|
"feasibility_ratio_monthly_at_20bps": float(feasibility_ratio),
|
|
"walk_forward": {
|
|
"n_folds_generated": n_folds_generated,
|
|
"n_splits_declared": int(SETUP["evaluation"]["n_splits"]),
|
|
"holdout_start": HOLDOUT_START,
|
|
"last_test_end": splits[-1]["test_end"],
|
|
},
|
|
}
|
|
|
|
report_path = EXPLORATION_DIR / "feasibility_report.json"
|
|
with open(report_path, "w") as f:
|
|
json.dump(feasibility_report, f, indent=2)
|
|
print(f"Written: {report_path}")
|
|
|
|
# %% [markdown]
|
|
# ---
|
|
#
|
|
# ## Key Takeaways
|
|
#
|
|
# 1. **Universe**: Two-layer bias assessment---universe composition has survivorship
|
|
# bias (Layer 1, not fully resolvable, the 100 ETFs were chosen backward-looking);
|
|
# within-universe eligibility is point-in-time correct (Layer 2, via $10M ADV
|
|
# threshold applied annually with one-year lag). 70+ eligible ETFs per fold.
|
|
# 2. **Cost feasibility**: All three horizons clear the 20bps round-trip threshold,
|
|
# but the margin depends on the cost tier---large liquid ETFs (10 bps RT) support
|
|
# daily strategies, while smaller thematic ETFs (30 bps RT) require monthly
|
|
# holding periods. `setup.yaml` accommodates both via tiered `costs.asset_spreads`.
|
|
# 3. **Cadence**: Monthly month-end as default for literature comparability;
|
|
# weekly tested as a variant via `labels.variants: [fwd_ret_5d]`.
|
|
# 4. **Mapping**: Long-only equal-weight top-N as simplest credible baseline;
|
|
# alternative allocators sweep in `setup.yaml::backtest.sweep.allocators`
|
|
# (explored in Chapter 17--18).
|
|
# 5. **Evaluation**: 8 walk-forward folds with verified holdout separation
|
|
# (`evaluation.holdout_start` enforced).
|
|
# 6. **Kill conditions**: KC2 (edge-to-cost > 1.0x) already cleared---feasibility
|
|
# ratio at monthly horizon is comfortably above 1.0 on raw return magnitudes.
|
|
# KC1 (IC floor) and KC3 (EW underperformance) are tested in later chapters.
|
|
#
|
|
# **Known limitations**:
|
|
# - Layer 1 survivorship bias is documented but not resolved.
|
|
# - Annual eligibility granularity is coarser than monthly decision cadence.
|
|
# - The $10M ADV threshold is not inflation-adjusted.
|
|
#
|
|
# **Artifacts written**:
|
|
# - `eligibility.csv`: point-in-time (asset, year) membership.
|
|
# - `config/exploration/feasibility_report.json`: summary numbers downstream
|
|
# notebooks and the chapter README can cite without re-running this notebook.
|
|
#
|
|
# **Next**: Chapter 7 creates labels at the monthly and weekly horizons declared
|
|
# in `setup.yaml::labels`.
|