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

769 lines
24 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]
# # Constructing Continuous Options Series
#
# **Docker image**: `ml4t`
#
# ## Purpose
#
# Demonstrate why a "constant-maturity" option series chains together different
# contracts, produces phantom price jumps at every roll, and contaminates label
# construction. Implement two clean alternatives: same-contract holding returns
# (correct labels) and a roll-zeroed continuous reconstruction (backtestable
# mark-to-market).
#
# ## Learning Objectives
#
# - Detect contract rolls in a 30-day ATM straddle time series and quantify the
# resulting bias against non-roll-day returns.
# - Compute same-contract holding returns by looking up the entry contract's
# exit price in the raw option chain.
# - Build a roll-zeroed continuous price series suitable for backtesting and
# compare it to naive chained returns.
# - Reason about which construction belongs in label generation versus
# transaction-cost accounting.
#
# ## Book Reference
#
# Chapter 2 §2.2 (asset-class market data landscape — derivatives). The futures
# analogue is `06_futures_continuous`; the case-study scale-up is
# `case_studies/sp500_options/02_labels`.
#
# ## Prerequisites
#
# - `07_sp500_options_eda` for option-chain structure.
# - `08_options_greeks_computation` for theta and time decay.
# - The AlgoSeek S&P 500 options EDA parquet at `$ML4T_DATA_PATH/sp500_options/`.
# %%
"""Constructing Continuous Options Series — constant-maturity roll adjustment."""
import plotly.graph_objects as go
import polars as pl
from plotly.subplots import make_subplots
from data import load_sp500_options_eda
# %% tags=["parameters"]
DEMO_SYMBOL = "AAPL"
DEMO_YEAR = 2019
# %% [markdown]
# ## 1. The Constant-Maturity Construction
#
# A 30-day ATM straddle is a common volatility instrument: you buy (or sell) both
# an ATM call and an ATM put with ~30 days to expiration. To build a daily time
# series, we select the "best" straddle each day — the one closest to 30 DTE and
# 50-delta.
#
# The problem: this selection is **independent** each day. The contract identity
# (strike, expiration) changes whenever a new weekly/monthly option enters the
# 2535 DTE window or the underlying moves enough to shift which strike is ATM.
# %% [markdown]
# ### 1.1 Load Raw Options for One Symbol
# %%
raw = load_sp500_options_eda(
symbols=[DEMO_SYMBOL],
start_date=f"{DEMO_YEAR}-01-01",
end_date=f"{DEMO_YEAR}-12-31",
).rename({"timestamp": "date"})
print(f"Raw {DEMO_SYMBOL} options ({DEMO_YEAR}): {len(raw):,} rows")
print(f" Dates: {raw['date'].n_unique()}")
print(
f" Unique (strike, expiration): {raw.select(pl.struct('strike', 'expiration')).n_unique():,}"
)
# %% [markdown]
# ### 1.2 Constant-Maturity Straddle Selection
#
# For each trading day, select the ATM straddle closest to 30 DTE.
# This replicates the logic in `materialize_options.py`.
# %%
# Selection parameters for constant-maturity straddle
DTE_WINDOW = (25, 35)
TARGET_DELTA = 0.50
DELTA_TOL = 0.15
MIN_BID = 0.01
MAX_REL_SPREAD = 0.30
# %% [markdown]
# ### Straddle Selection Logic
#
# For each trading day, select the ATM straddle closest to 30 DTE.
# %%
def select_constant_maturity_straddle(raw_options: pl.DataFrame) -> pl.DataFrame:
"""Select the 'best' 30D ATM straddle per day from raw option chains."""
opts = raw_options.with_columns(
pl.col("delta").abs().alias("abs_delta"),
((pl.col("ask") - pl.col("bid")) / pl.col("mid_price").clip(lower_bound=0.01)).alias(
"rel_spread"
),
)
filtered = opts.filter(
pl.col("days_to_maturity").is_between(DTE_WINDOW[0], DTE_WINDOW[1])
& (pl.col("bid") >= MIN_BID)
& (pl.col("rel_spread") <= MAX_REL_SPREAD)
& (pl.col("iv_convergence") == "Converged")
& pl.col("abs_delta").is_between(TARGET_DELTA - DELTA_TOL, TARGET_DELTA + DELTA_TOL)
)
def _best_leg(df: pl.DataFrame, cp: str) -> pl.DataFrame:
leg = df.filter(pl.col("call_put") == cp)
leg = leg.with_columns((pl.col("abs_delta") - TARGET_DELTA).abs().alias("_dd"))
return (
leg.with_columns(pl.col("_dd").rank("ordinal").over("date").alias("_rank"))
.filter(pl.col("_rank") == 1)
.drop(["_rank", "_dd"])
)
call_cols = [
"date",
"strike",
"expiration",
"days_to_maturity",
"underlying_price",
pl.col("mid_price").alias("call_mid"),
pl.col("bid").alias("call_bid"),
pl.col("ask").alias("call_ask"),
pl.col("delta").alias("call_delta"),
pl.col("theta").alias("call_theta"),
]
put_cols = [
"date",
pl.col("mid_price").alias("put_mid"),
pl.col("bid").alias("put_bid"),
pl.col("ask").alias("put_ask"),
pl.col("delta").alias("put_delta"),
pl.col("theta").alias("put_theta"),
]
calls = _best_leg(filtered, "C").select(call_cols)
puts = _best_leg(filtered, "P").select(put_cols)
return (
calls.join(puts, on="date", how="inner")
.with_columns(
(pl.col("call_mid") + pl.col("put_mid")).alias("instr_mid"),
(pl.col("call_theta") + pl.col("put_theta")).alias("instr_theta"),
(pl.col("call_delta") + pl.col("put_delta")).alias("instr_delta"),
)
.sort("date")
)
# %%
cm_straddles = select_constant_maturity_straddle(raw)
print(f"Constant-maturity straddle series: {len(cm_straddles)} days")
print(f" Date range: {cm_straddles['date'].min()} to {cm_straddles['date'].max()}")
cm_straddles.select(["date", "strike", "expiration", "days_to_maturity", "instr_mid"]).head(10)
# %% [markdown]
# ## 2. The Roll Problem
#
# How often does the underlying contract change?
# %%
cm_straddles = cm_straddles.with_columns(
pl.col("expiration").shift(1).alias("prev_exp"),
pl.col("strike").shift(1).alias("prev_strike"),
pl.col("instr_mid").shift(1).alias("prev_mid"),
)
cm_straddles = cm_straddles.with_columns(
((pl.col("expiration") != pl.col("prev_exp")) | (pl.col("strike") != pl.col("prev_strike")))
.fill_null(False)
.alias("is_roll"),
((pl.col("instr_mid") - pl.col("prev_mid")) / pl.col("prev_mid")).alias("daily_return"),
)
n_rolls = cm_straddles.filter(pl.col("is_roll")).height
n_total = len(cm_straddles) - 1 # exclude first row (no return)
print(f"Contract changes: {n_rolls} out of {n_total} days ({n_rolls / n_total:.0%})")
# %% [markdown]
# ### 2.1 Roll-Day vs Non-Roll-Day Returns
#
# If the constant-maturity series were a true continuous instrument, roll days
# and non-roll days should have similar return characteristics. They don't.
# %%
roll_rets = cm_straddles.filter(pl.col("is_roll"))["daily_return"].drop_nulls()
nonroll_rets = cm_straddles.filter(~pl.col("is_roll"))["daily_return"].drop_nulls()
roll_summary = pl.DataFrame(
{
"metric": ["count", "mean_return", "median_return", "pct_positive"],
"roll_days": [
float(roll_rets.len()),
roll_rets.mean(),
roll_rets.median(),
(roll_rets > 0).mean(),
],
"non_roll_days": [
float(nonroll_rets.len()),
nonroll_rets.mean(),
nonroll_rets.median(),
(nonroll_rets > 0).mean(),
],
}
)
roll_summary = roll_summary.with_columns(
(pl.col("roll_days") - pl.col("non_roll_days")).alias("difference"),
)
roll_summary
# %% [markdown]
# The pattern is clear: roll days show systematically positive returns (~+1.3%)
# while non-roll days show negative returns (~-0.9%). This is not a market signal —
# it's the mechanical effect of switching from a time-decayed contract (low
# premium, near expiry) to a fresh contract (high premium, further expiry).
#
# For a short straddle strategy, this phantom "return" would be interpreted as a
# loss, but it's actually the cost of maintaining the constant-maturity position —
# analogous to contango roll cost in futures, but much larger in magnitude.
# %% [markdown]
# ### 2.2 Visualizing the Sawtooth Pattern
# %%
# Prepare data for sawtooth visualization
dates = cm_straddles["date"].to_list()
prices = cm_straddles["instr_mid"].to_list()
dtes = cm_straddles["days_to_maturity"].to_list()
rolls = cm_straddles["is_roll"].to_list()
roll_dates = [d for d, r in zip(dates, rolls, strict=False) if r]
roll_prices = [p for p, r in zip(prices, rolls, strict=False) if r]
nonroll_dates = [d for d, r in zip(dates, rolls, strict=False) if not r]
nonroll_prices = [p for p, r in zip(prices, rolls, strict=False) if not r]
# %%
# Build both panels in a single cell so the inline backend does not flush
# the figure mid-construction.
fig = make_subplots(
rows=2,
cols=1,
shared_xaxes=True,
subplot_titles=[
f"{DEMO_SYMBOL} Constant-Maturity Straddle Price",
"Days to Expiration (DTE)",
],
vertical_spacing=0.08,
)
fig.add_trace(
go.Scatter(
x=nonroll_dates,
y=nonroll_prices,
mode="markers",
name="Same contract",
marker=dict(size=4, color="#3498db"),
opacity=0.7,
),
row=1,
col=1,
)
fig.add_trace(
go.Scatter(
x=roll_dates,
y=roll_prices,
mode="markers",
name="Contract switch",
marker=dict(size=6, color="#e74c3c", symbol="diamond"),
opacity=0.9,
),
row=1,
col=1,
)
fig.add_trace(
go.Scatter(
x=dates,
y=prices,
mode="lines",
name="Price",
line=dict(width=1, color="#95a5a6"),
showlegend=False,
),
row=1,
col=1,
)
fig.add_trace(
go.Scatter(
x=dates,
y=dtes,
mode="lines+markers",
name="DTE",
marker=dict(size=3, color="#2ecc71"),
line=dict(width=1),
),
row=2,
col=1,
)
fig.update_yaxes(title_text="Straddle Mid ($)", row=1, col=1)
fig.update_yaxes(title_text="DTE", row=2, col=1)
fig.update_xaxes(title_text="Date", row=2, col=1)
fig.update_layout(template="plotly_white", height=600, legend=dict(x=0.01, y=0.99))
fig.show()
# %% [markdown]
# The sawtooth pattern is visible: price decays for a few days (theta eats the
# premium), then jumps up when a new contract with more time value is selected.
# The DTE panel confirms: DTE drops from ~35 to ~25, then jumps back when the
# contract rolls. **Every price jump at a roll is a phantom return.**
# %% [markdown]
# ## 3. Same-Contract Holding Returns
#
# The correct return for a straddle trade is: enter a specific contract at mid on
# day $t$, exit the **same contract** at mid on day $t + h$. This requires
# looking up that specific contract in the raw option chain $h$ days later.
#
# $$r_{same} = \frac{P_{entry}^{mid}(K, T) - P_{exit}^{mid}(K, T)}{P_{entry}^{mid}(K, T)}$$
#
# where $(K, T)$ identifies the specific strike and expiration.
# %% [markdown]
# ### 3.1 Build Exit Price Lookup from Raw Chain
# %%
HOLDING_PERIOD = 10 # days
def build_exit_lookup(raw_options: pl.DataFrame) -> pl.DataFrame:
"""Build a lookup table: (date, strike, expiration, call_put) → mid_price.
Used to find the exit price of a specific contract h days after entry.
"""
return raw_options.filter(
pl.col("bid") >= MIN_BID,
pl.col("iv_convergence") == "Converged",
).select(
[
"date",
"strike",
"expiration",
"call_put",
"mid_price",
"bid",
"ask",
"delta",
"theta",
"days_to_maturity",
]
)
# %%
# Build lookup from the raw chain
lookup = build_exit_lookup(raw)
print(f"Exit price lookup: {len(lookup):,} rows")
print(
f" Unique contracts: {lookup.select(pl.struct('strike', 'expiration', 'call_put')).n_unique():,}"
)
# %% [markdown]
# ### 3.2 Compute Same-Contract Returns
#
# For each day's constant-maturity straddle, find the same contract's price
# $h$ trading days later in the raw chain.
# %%
# Get the trading calendar (ordered dates)
trading_dates = cm_straddles["date"].unique().sort().to_list()
date_to_idx = {d: i for i, d in enumerate(trading_dates)}
def get_exit_date(entry_date, h: int, cal: list):
"""Get the trading date h business days after entry_date."""
idx = date_to_idx.get(entry_date)
if idx is None or idx + h >= len(cal):
return None
return cal[idx + h]
# Build entry-exit pairs
entries = cm_straddles.select(
[
"date",
"strike",
"expiration",
"instr_mid",
"call_mid",
"put_mid",
"instr_delta",
"is_roll",
]
).rename({"date": "entry_date", "instr_mid": "entry_mid"})
# Add exit dates
exit_dates = [
get_exit_date(d, HOLDING_PERIOD, trading_dates) for d in entries["entry_date"].to_list()
]
entries = entries.with_columns(pl.Series("exit_date", exit_dates).cast(pl.Date))
# Drop entries where exit date is beyond our data
entries = entries.filter(pl.col("exit_date").is_not_null())
print(f"Entry-exit pairs to look up: {len(entries):,}")
# %%
# Look up call exit price
call_exit = lookup.filter(pl.col("call_put") == "C").select(
[
pl.col("date").alias("exit_date"),
"strike",
"expiration",
pl.col("mid_price").alias("call_exit_mid"),
]
)
# Look up put exit price
put_exit = lookup.filter(pl.col("call_put") == "P").select(
[
pl.col("date").alias("exit_date"),
"strike",
"expiration",
pl.col("mid_price").alias("put_exit_mid"),
]
)
# Join: entry contract → exit prices for same (strike, expiration)
same_contract = entries.join(call_exit, on=["exit_date", "strike", "expiration"], how="left").join(
put_exit, on=["exit_date", "strike", "expiration"], how="left"
)
# Compute same-contract straddle exit mid
same_contract = same_contract.with_columns(
(pl.col("call_exit_mid") + pl.col("put_exit_mid")).alias("exit_mid"),
)
# Same-contract return (short straddle: positive = profitable)
same_contract = same_contract.with_columns(
((pl.col("entry_mid") - pl.col("exit_mid")) / pl.col("entry_mid")).alias("same_contract_ret"),
)
# How many lookups succeeded?
found = same_contract.filter(pl.col("exit_mid").is_not_null()).height
print(f"Exit prices found: {found} / {len(same_contract)} ({found / len(same_contract):.1%})")
# %% [markdown]
# ### 3.3 Compare Naive vs Same-Contract Returns
# %%
# Naive return: shift-based on constant-maturity series (what 02_labels.py did)
naive_rets = cm_straddles.with_columns(
pl.col("instr_mid").shift(-1).alias("naive_entry"),
pl.col("instr_mid").shift(-(1 + HOLDING_PERIOD)).alias("naive_exit"),
).with_columns(
((pl.col("naive_entry") - pl.col("naive_exit")) / pl.col("naive_entry")).alias("naive_ret"),
)
# Align for comparison
comparison = (
same_contract.filter(pl.col("same_contract_ret").is_not_null())
.select(["entry_date", "same_contract_ret"])
.join(
naive_rets.select([pl.col("date").alias("entry_date"), "naive_ret"]).drop_nulls(),
on="entry_date",
how="inner",
)
)
print(f"Paired observations: {len(comparison):,}")
naive_vs_same = pl.DataFrame(
{
"metric": ["mean_return", "std", "median_return", "pct_positive_short"],
"naive_chained": [
comparison["naive_ret"].mean(),
comparison["naive_ret"].std(),
comparison["naive_ret"].median(),
(comparison["naive_ret"] > 0).mean(),
],
"same_contract": [
comparison["same_contract_ret"].mean(),
comparison["same_contract_ret"].std(),
comparison["same_contract_ret"].median(),
(comparison["same_contract_ret"] > 0).mean(),
],
}
)
corr = comparison.select(pl.corr("naive_ret", "same_contract_ret").alias("correlation")).item()
print(f"Correlation(naive, same-contract) = {corr:.3f}")
naive_vs_same
# %% [markdown]
# The naive chained returns systematically differ from same-contract returns.
# The correlation tells us how much of the naive signal is genuine vs artifact.
# Any label or model trained on naive returns is learning a mix of actual
# straddle P&L and roll mechanics.
# %%
# Scatter plot: naive vs same-contract
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=comparison["naive_ret"].to_list(),
y=comparison["same_contract_ret"].to_list(),
mode="markers",
marker=dict(size=3, color="#3498db", opacity=0.4),
name="Returns",
)
)
fig.add_trace(
go.Scatter(
x=[-0.5, 0.5],
y=[-0.5, 0.5],
mode="lines",
line=dict(dash="dash", color="gray"),
name="45° line",
)
)
fig.update_layout(
template="plotly_white",
title=f"{DEMO_SYMBOL}: Naive vs Same-Contract {HOLDING_PERIOD}d Returns",
xaxis_title="Naive (chained constant-maturity)",
yaxis_title="Same-contract holding return",
width=600,
height=500,
)
fig.show()
# %% [markdown]
# ## 4. Building a Continuous Series for Backtesting
#
# For backtesting, we need a continuous price series where daily returns reflect
# actual P&L — no phantom jumps at rolls. Unlike futures (which roll quarterly
# and suit Panama adjustment), straddles roll almost daily, so we use a
# **return-based reconstruction**:
#
# 1. On non-roll days: daily return = genuine within-contract P&L
# 2. On roll days: set return to 0 (the roll is a transaction, not a return)
# 3. Reconstruct prices: $P_{adj,t} = P_0 \prod_{i=1}^{t} (1 + r_i)$
#
# Roll costs (bid-ask at roll) are modeled separately as transaction costs in
# Chapter 18.
# %%
def build_continuous_straddle_series(cm_series: pl.DataFrame) -> pl.DataFrame:
"""Build a continuous straddle price series from within-contract returns.
On roll days, the return is set to 0 (roll is a transaction, not a return).
The resulting series has no phantom jumps and its returns reflect actual P&L.
"""
df = cm_series.with_columns(
((pl.col("instr_mid") - pl.col("instr_mid").shift(1)) / pl.col("instr_mid").shift(1)).alias(
"raw_daily_ret"
),
)
# On roll days, set return to 0
df = df.with_columns(
pl.when(pl.col("is_roll"))
.then(0.0)
.otherwise(pl.col("raw_daily_ret"))
.alias("clean_daily_ret"),
)
# Reconstruct price from cumulative returns, starting at the first observed price
start_price = cm_series["instr_mid"][0]
cum_rets = df["clean_daily_ret"].fill_null(0.0).to_list()
adj_prices = [start_price]
for r in cum_rets[1:]:
adj_prices.append(adj_prices[-1] * (1.0 + r))
return df.with_columns(pl.Series("price_adjusted", adj_prices))
# %%
adjusted = build_continuous_straddle_series(cm_straddles)
# Compare roll-day returns: raw vs cleaned
adj_roll = adjusted.filter(pl.col("is_roll"))
adj_nonroll = adjusted.filter(~pl.col("is_roll"))
raw_vs_clean = pl.DataFrame(
{
"metric": ["raw_mean", "clean_mean"],
"roll_days": [
adj_roll["raw_daily_ret"].mean(),
adj_roll["clean_daily_ret"].mean(),
],
"non_roll_days": [
adj_nonroll["raw_daily_ret"].mean(),
adj_nonroll["clean_daily_ret"].mean(),
],
}
)
print(
f"Roll-day bias removed: raw roll mean was {adj_roll['raw_daily_ret'].mean():+.4f}, now 0.0000"
)
raw_vs_clean
# %%
# Visualize raw vs adjusted price series
fig = make_subplots(
rows=2,
cols=1,
shared_xaxes=True,
subplot_titles=["Raw Constant-Maturity Series (sawtooth)", "Continuous (roll-zeroed)"],
vertical_spacing=0.08,
)
fig.add_trace(
go.Scatter(
x=adjusted["date"].to_list(),
y=adjusted["instr_mid"].to_list(),
mode="lines",
name="Raw",
line=dict(color="#e74c3c", width=1.5),
),
row=1,
col=1,
)
fig.add_trace(
go.Scatter(
x=adjusted["date"].to_list(),
y=adjusted["price_adjusted"].to_list(),
mode="lines",
name="Adjusted",
line=dict(color="#3498db", width=1.5),
),
row=2,
col=1,
)
fig.update_yaxes(title_text="Straddle Mid ($)", row=1, col=1)
fig.update_yaxes(title_text="Adjusted Price ($)", row=2, col=1)
fig.update_layout(
template="plotly_white",
height=500,
title=f"{DEMO_SYMBOL} Straddle: Raw vs Continuous (Roll-Adjusted)",
)
fig.show()
# %% [markdown]
# The adjusted series shows steady theta decay without the sawtooth jumps.
# This is the series suitable for backtesting — roll costs should be modeled
# separately as transaction costs (Chapter 18).
#
# **Note**: This continuous series approximates the actual P&L but is not exact.
# The precise approach — same-contract holding returns (Section 3 above) — is
# what the case study uses for label construction.
# %% [markdown]
# ## 5. Summary: Three Approaches Compared
#
# | Approach | Mechanism | Suitable For |
# |----------|-----------|--------------|
# | Naive (chained) | `shift(-h)` on constant-maturity series | **Nothing** — contaminates labels |
# | Same-contract | Look up entry contract's price h days later | **Labels** — correct per-trade P&L |
# | Continuous (roll-zeroed) | Zero out roll-day returns, rebuild prices | **Backtesting** — smooth mark-to-market |
#
# The same-contract approach (Section 3) requires the raw option chains to
# look up exit prices. The continuous approach (Section 4) only needs the
# constant-maturity series but must detect rolls and zero out their returns.
# %%
# Final comparison: all three 10d forward return approaches
# Naive
adjusted = adjusted.with_columns(
pl.col("instr_mid").shift(-1).alias("raw_entry"),
pl.col("instr_mid").shift(-(1 + HOLDING_PERIOD)).alias("raw_exit"),
).with_columns(
((pl.col("raw_entry") - pl.col("raw_exit")) / pl.col("raw_entry")).alias("naive_fwd_ret"),
)
# Continuous-adjusted
adjusted = adjusted.with_columns(
pl.col("price_adjusted").shift(-1).alias("adj_entry"),
pl.col("price_adjusted").shift(-(1 + HOLDING_PERIOD)).alias("adj_exit"),
).with_columns(
((pl.col("adj_entry") - pl.col("adj_exit")) / pl.col("adj_entry")).alias("cont_fwd_ret"),
)
# Merge with same-contract returns
comparison_all = (
adjusted.select([pl.col("date").alias("entry_date"), "naive_fwd_ret", "cont_fwd_ret"])
.join(
same_contract.select(["entry_date", "same_contract_ret"]),
on="entry_date",
how="left",
)
.drop_nulls()
)
print(f"Paired observations: {len(comparison_all):,}")
three_way = pl.DataFrame(
{
"metric": ["mean", "std", "corr_with_same_contract"],
"naive": [
comparison_all["naive_fwd_ret"].mean(),
comparison_all["naive_fwd_ret"].std(),
comparison_all.select(pl.corr("naive_fwd_ret", "same_contract_ret")).item(),
],
"continuous": [
comparison_all["cont_fwd_ret"].mean(),
comparison_all["cont_fwd_ret"].std(),
comparison_all.select(pl.corr("cont_fwd_ret", "same_contract_ret")).item(),
],
"same_contract": [
comparison_all["same_contract_ret"].mean(),
comparison_all["same_contract_ret"].std(),
1.0,
],
}
)
three_way
# %% [markdown]
# ## Key Takeaways
#
# 1. **Constant-maturity option series chain different contracts**: The "best"
# 30D ATM straddle changes every 1-2 days on average. Each switch creates a
# phantom price jump from time-value reset.
#
# 2. **Roll contamination is systematic**: Roll-day returns average +1.3% vs
# -0.9% on non-roll days. This 2.2pp bias compounds over ~184 roll days/year
# because options decay faster and roll more frequently than futures.
#
# 3. **Same-contract returns are the correct label**: Looking up the entry
# contract's price h days later in the raw option chain gives the actual
# holding P&L. The raw chain data makes this feasible.
#
# 4. **Roll-zeroed reconstruction creates a backtestable series**: Setting
# roll-day returns to zero and rebuilding prices removes phantom jumps while
# preserving genuine within-contract P&L on non-roll days.
#
# 5. **Roll costs are transaction costs, not returns**: The cost of maintaining
# a constant-maturity position (closing old, opening new) should be modeled
# as execution cost in Chapter 18, not embedded in the price series.
#
# ## Next
#
# The S&P 500 options case study
# ([`02_labels`](../case_studies/sp500_options/02_labels.ipynb)) applies the
# same-contract and roll-zeroed constructions at panel scale across the full
# universe and multi-year sample.