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

743 lines
26 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.18.1
# kernelspec:
# display_name: Python 3 (ipykernel)
# language: python
# name: python3
# ---
# %% [markdown]
# # Constructing Continuous Futures Contracts
#
# **Docker image**: `ml4t`
#
# **Purpose**: Walk through the construction of a continuous futures price
# series from individual expiring contracts: detect rolls, compare
# adjustment methods (raw, Panama / additive back-adjustment, ratio /
# multiplicative back-adjustment), and validate against the vendor-built
# continuous series.
#
# **Learning objectives**:
#
# - Detect roll dates using volume-based front-month identification (with a
# no-rollback constraint to avoid spurious switches).
# - Apply Panama (additive) back-adjustment to preserve dollar P&L across
# rolls.
# - Apply ratio (multiplicative) back-adjustment to preserve percentage
# returns across rolls.
# - Cross-check constructed continuous prices against Databento's pre-built
# continuous series and quantify the disagreement.
#
# **Book reference**: §2.2 ("The Asset-Class Market Data Landscape" —
# Futures); the methodology comparison underpins the engineering decision
# in §2.2 to store raw contract histories alongside one or more continuous
# variants.
#
# **Prerequisites**: `data` package on `PYTHONPATH`; individual ES contract
# parquet at `ML4T_DATA_PATH/futures/market/individual/ES/data.parquet` and
# the contract-definitions parquet at
# `ML4T_DATA_PATH/futures/market/contract_definitions.parquet`.
# %%
"""Continuous Futures Construction."""
import re
from datetime import UTC, date, datetime
import plotly.graph_objects as go
import polars as pl
from plotly.subplots import make_subplots
from data import load_cme_futures
from utils import ML4T_DATA_PATH
# %% tags=["parameters"]
# Production defaults — Papermill injects overrides for CI
# %% [markdown]
# ## 1. Understanding the Data
#
# ### 1.1 Load Individual Contracts
# %%
es_individual = load_cme_futures(products=["ES"], frequency="hourly", continuous=False)
print(f"Individual contracts: {es_individual.shape}")
print(f"Unique contracts (by instrument_id): {es_individual['instrument_id'].n_unique()}")
print(f"Date range: {es_individual['timestamp'].min()} to {es_individual['timestamp'].max()}")
print("Sample:")
es_individual.head()
# %%
contract_stats = (
es_individual.group_by("instrument_id")
.agg(
pl.col("timestamp").min().alias("first_trade"),
pl.col("timestamp").max().alias("last_trade"),
pl.col("volume").sum().alias("total_volume"),
pl.len().alias("trading_days"),
)
.sort("first_trade")
)
print(f"Contracts: {len(contract_stats)} (sorted by first trade)")
contract_stats.head(10)
# %% [markdown]
# ### 1.2 Understanding Contract Naming
#
# ES contract symbols follow the pattern: ES + Month Code + Year
#
# Month codes:
# - H = March, M = June, U = September, Z = December (standard quarterly)
# - F = January, G = February, J = April, K = May, N = July, Q = August, V = October, X = November
# %%
_MONTH_CODES = {
"F": 1,
"G": 2,
"H": 3,
"J": 4,
"K": 5,
"M": 6,
"N": 7,
"Q": 8,
"U": 9,
"V": 10,
"X": 11,
"Z": 12,
}
_SYMBOL_RE = re.compile(r"^([A-Z]+)([FGHJKMNQUVXZ])(\d+)$")
def parse_contract_symbol(symbol: str) -> dict:
"""Parse a futures contract symbol like ESH24 or RTYM25 into product / month / year."""
match = _SYMBOL_RE.match(symbol)
if not match:
raise ValueError(f"Cannot parse symbol: {symbol}")
product, month_code, year_str = match.groups()
year = int(year_str)
year = year + 2000 if year < 50 else year + 1900
return {
"product": product,
"month_code": month_code,
"month": _MONTH_CODES[month_code],
"year": year,
}
# %%
defn_path = ML4T_DATA_PATH / "futures" / "market" / "contract_definitions.parquet"
contract_defs = pl.read_parquet(defn_path).filter(pl.col("product") == "ES")
contract_df = (
pl.DataFrame(
[
{**parse_contract_symbol(r["symbol"]), "symbol": r["symbol"]}
for r in contract_defs.iter_rows(named=True)
]
)
.join(contract_defs.select("symbol", "expiration"), on="symbol")
.sort("year", "month")
)
print(f"ES contract definitions: {contract_df.height} contracts")
contract_df.select("symbol", "month_code", "month", "year", "expiration").head(10)
# %% [markdown]
# ### 1.3 Contract Expiration from Symbols
#
# Without a separate definitions file, we can derive expiration information
# from contract symbols. For ES contracts, the pattern is ESH24 (March 2024),
# ESM24 (June 2024), etc.
# %% [markdown]
# `parse_contract_symbol` and the contract-definitions parquet give us actual
# expiration dates. For products where we only see the symbol (no definitions
# file), the expiration can be approximated as the 15th of the contract month
# — close enough for roll detection but not for delivery scheduling.
# %%
es_definition = contract_df.select(
"symbol",
"year",
"month",
pl.struct("year", "month")
.map_elements(lambda x: date(x["year"], x["month"], 15), return_dtype=pl.Date)
.alias("expiration"),
)
print(f"ES definition rows: {es_definition.height}")
es_definition.head(10)
# %% [markdown]
# ## 2. Roll Detection
#
# The "roll" is when we switch from the near-month contract to the next contract.
# There are several approaches:
#
# 1. **Volume-based**: Roll when the next contract has higher daily volume
# 2. **Open Interest-based**: Roll when next contract has higher open interest
# 3. **Fixed Schedule**: Roll N days before expiration (e.g., first Thursday of expiry month)
#
# We'll implement volume-based rolling.
# %%
def identify_front_month(
individual_df: pl.DataFrame, min_outright_price: float = 500.0
) -> pl.DataFrame:
"""Volume-based front-month detection with no-rollback constraint."""
# Filter to outright contracts only (exclude calendar spreads at ~$50-100)
outrights = individual_df.filter(pl.col("close") >= min_outright_price)
# Aggregate to daily volume per contract
daily_volume = (
outrights.with_columns(pl.col("timestamp").dt.date().alias("date"))
.group_by(["date", "instrument_id"])
.agg(pl.col("volume").sum().alias("daily_volume"))
)
daily_leader = (
daily_volume.group_by("date")
.agg(pl.col("instrument_id").sort_by("daily_volume").last().alias("leader"))
.sort("date")
)
# No-rollback constraint — switch to new leaders, never go back
leader_ids = daily_leader["leader"].to_list()
dates = daily_leader["date"].to_list()
used_contracts = {leader_ids[0]}
current_front = leader_ids[0]
front = [current_front]
for i in range(1, len(leader_ids)):
if leader_ids[i] != current_front and leader_ids[i] not in used_contracts:
current_front = leader_ids[i]
used_contracts.add(current_front)
front.append(current_front)
daily_front = pl.DataFrame({"date": dates, "front_symbol": front})
# Expand back to hourly bars
hourly = individual_df.select("timestamp").unique().sort("timestamp")
hourly = hourly.with_columns(pl.col("timestamp").dt.date().alias("date"))
front_month = hourly.join(daily_front, on="date", how="left").drop("date")
front_month = front_month.with_columns(
pl.col("front_symbol").shift(1).alias("prev_front"),
).with_columns(
(pl.col("front_symbol") != pl.col("prev_front")).alias("is_roll"),
)
return front_month
# %%
front_months = identify_front_month(es_individual)
print("Front month identification (2024 sample):")
front_months.filter(pl.col("timestamp") >= datetime(2024, 1, 1, tzinfo=UTC)).head(20)
# %%
roll_dates = front_months.filter(pl.col("is_roll"))
print(f"Total roll events: {len(roll_dates)}")
print("Most recent 10 roll dates:")
roll_dates.tail(10).select("timestamp", "prev_front", "front_symbol")
# %% [markdown]
# ### 2.2 Calendar-Based Roll (Alternative)
#
# An alternative to volume-based rolling is **calendar-based**: roll a fixed number
# of days before contract expiration. This is simpler and more predictable, but may
# not track liquidity as well as volume-based methods.
#
# Common calendar roll schedules:
# - 5 business days before expiry (conservative)
# - First notice day (for physical delivery commodities)
# - 2 weeks before expiry (popular for equity index futures)
# %%
def identify_front_month_calendar(
individual_df: pl.DataFrame,
definition_df: pl.DataFrame,
roll_days_before: int = 5,
) -> pl.DataFrame:
"""Identify front month using calendar-based roll (fixed days before expiry)."""
# Get expiration dates from definitions
# NOTE: Requires individual data to have a "symbol" column with contract names
expirations = definition_df.select(["symbol", "expiration"]).with_columns(
pl.col("expiration").cast(pl.Date).alias("expiry_date")
)
# Join with individual data (requires symbol column)
with_expiry = individual_df.join(expirations, on="symbol", how="left").with_columns(
pl.col("timestamp").cast(pl.Date).alias("trade_date")
)
# Calculate days to expiry
with_expiry = with_expiry.with_columns(
(pl.col("expiry_date") - pl.col("trade_date")).dt.total_days().alias("days_to_expiry")
)
# Filter to contracts with more than roll_days_before to expiry
# Then select the nearest such contract for each day
front_month = (
with_expiry.filter(pl.col("days_to_expiry") > roll_days_before)
.sort(["timestamp", "days_to_expiry"])
.group_by("timestamp")
.first()
.select(["timestamp", pl.col("symbol").alias("front_symbol")])
.sort("timestamp")
)
# Add roll indicators
front_month = front_month.with_columns(
pl.col("front_symbol").shift(1).alias("prev_front"),
).with_columns(
(pl.col("front_symbol") != pl.col("prev_front")).alias("is_roll"),
)
return front_month
# %% [markdown]
# Calendar-based roll detection requires the individual data to carry a symbol
# column that joins to the contract-definitions table. The Databento individual
# parquet uses numeric `instrument_id` rather than ESH24-style symbols, so we
# present the calendar logic above as a teaching reference and use volume-based
# detection for the rest of the notebook.
# %%
volume_rolls = front_months.filter(pl.col("is_roll"))
print(f"Volume-based rolls (ES, 2016-2025): {len(volume_rolls)}")
# %% [markdown]
# **Volume vs Calendar Trade-offs**:
# - **Volume-based**: Follows liquidity naturally, but roll timing varies
# - **Calendar-based**: Predictable timing, easier to automate, but may roll into less liquid contract
#
# For this notebook, we use **volume-based** roll detection as our primary method since it
# better reflects actual market liquidity transitions.
# %% [markdown]
# ## 3. Adjustment Methods
#
# When we roll from contract A to contract B, there's usually a price gap.
# If we don't adjust, our time series will have artificial jumps.
#
# ### 3.1 No Adjustment (Raw)
#
# Simply use prices as-is. Returns calculated on roll dates are invalid.
# %%
def create_continuous_raw(individual_df: pl.DataFrame, front_months: pl.DataFrame) -> pl.DataFrame:
"""Create continuous series with no adjustment (raw prices)."""
# Join individual prices with front month info
continuous = (
individual_df.join(
front_months.select(["timestamp", "front_symbol"]), on="timestamp", how="inner"
)
.filter(pl.col("instrument_id") == pl.col("front_symbol"))
.select(["timestamp", "open", "high", "low", "close", "volume", "instrument_id"])
.sort("timestamp")
)
return continuous
# %%
es_continuous_raw = create_continuous_raw(es_individual, front_months)
print(f"Raw continuous series: {len(es_continuous_raw)} hourly bars")
es_continuous_raw.head(10)
# %% [markdown]
# ### 3.2 Panama (Back-Adjustment)
#
# Add the price gap to all historical prices. This preserves dollar P&L
# but distorts percentage returns for old data.
#
# Gap = Close_new_contract - Close_old_contract
# Adjusted_price = Price + cumulative_gap
#
# Note: We add (not subtract) because we're bringing old prices UP to the
# current contract's level, eliminating the discontinuity at roll dates.
# %%
def _compute_roll_gaps(individual_df: pl.DataFrame, front_months: pl.DataFrame) -> pl.DataFrame:
"""Compute price gaps at each roll date (new - old contract close)."""
roll_info = front_months.filter(pl.col("is_roll"))
prices_lookup = individual_df.select(["timestamp", "instrument_id", "close"])
old_prices = (
roll_info.select(["timestamp", pl.col("prev_front").alias("instrument_id")])
.join(prices_lookup, on=["timestamp", "instrument_id"], how="left")
.rename({"close": "old_close"})
)
new_prices = (
roll_info.select(["timestamp", pl.col("front_symbol").alias("instrument_id")])
.join(prices_lookup, on=["timestamp", "instrument_id"], how="left")
.rename({"close": "new_close"})
)
return (
old_prices.select(["timestamp", "old_close"])
.join(new_prices.select(["timestamp", "new_close"]), on="timestamp", how="inner")
.with_columns((pl.col("new_close") - pl.col("old_close")).alias("gap"))
.select(["timestamp", "gap"])
.drop_nulls()
)
# %% [markdown]
# ### Panama Adjustment
#
# Apply the computed gaps cumulatively backwards through the raw series.
# %%
def create_continuous_panama(
individual_df: pl.DataFrame, front_months: pl.DataFrame
) -> pl.DataFrame:
"""Create continuous series with Panama (back) adjustment.
Uses vectorized Polars joins instead of row-by-row iteration for O(n) complexity.
"""
raw = create_continuous_raw(individual_df, front_months)
roll_info = front_months.filter(pl.col("is_roll"))
if len(roll_info) == 0:
return raw.with_columns(pl.lit(0.0).alias("cumulative_adjustment"))
gaps_df = _compute_roll_gaps(individual_df, front_months)
if len(gaps_df) == 0:
return raw.with_columns(pl.lit(0.0).alias("cumulative_adjustment"))
# Adjustment applies to dates STRICTLY BEFORE each roll date
raw_with_gaps = raw.join(gaps_df, on="timestamp", how="left").with_columns(
pl.col("gap").fill_null(0.0)
)
# Cumulative sum in reverse, shift by 1 to exclude roll date from adjustment
raw_with_gaps = raw_with_gaps.with_columns(
pl.col("gap")
.reverse()
.cum_sum()
.shift(1)
.fill_null(0.0)
.reverse()
.alias("cumulative_adjustment")
)
adjusted = raw_with_gaps.with_columns(
[
(pl.col("open") + pl.col("cumulative_adjustment")).alias("adj_open"),
(pl.col("high") + pl.col("cumulative_adjustment")).alias("adj_high"),
(pl.col("low") + pl.col("cumulative_adjustment")).alias("adj_low"),
(pl.col("close") + pl.col("cumulative_adjustment")).alias("adj_close"),
]
)
return adjusted
# %%
es_continuous_panama = create_continuous_panama(es_individual, front_months)
panama_first = es_continuous_panama["cumulative_adjustment"][0]
print(
f"Panama-adjusted: cumulative_adjustment at the start of the series = {panama_first:+.2f} "
f"(adjusts every historical price up by this amount so the most recent contract is unchanged)"
)
es_continuous_panama.select(
"timestamp", "close", "adj_close", "cumulative_adjustment", "instrument_id"
).head(10)
# %% [markdown]
# ### 3.3 Ratio Adjustment
#
# Multiply historical prices by the ratio of new/old contract prices.
# This preserves percentage returns but distorts dollar amounts.
#
# Ratio = Close_new_contract / Close_old_contract
# Adjusted_price = Price * cumulative_ratio
# %%
def _compute_roll_ratios(individual_df: pl.DataFrame, front_months: pl.DataFrame) -> pl.DataFrame:
"""Compute price ratios (new/old) at each roll date."""
roll_info = front_months.filter(pl.col("is_roll"))
prices_lookup = individual_df.select(["timestamp", "instrument_id", "close"])
old_prices = (
roll_info.select(["timestamp", pl.col("prev_front").alias("instrument_id")])
.join(prices_lookup, on=["timestamp", "instrument_id"], how="left")
.rename({"close": "old_close"})
)
new_prices = (
roll_info.select(["timestamp", pl.col("front_symbol").alias("instrument_id")])
.join(prices_lookup, on=["timestamp", "instrument_id"], how="left")
.rename({"close": "new_close"})
)
return (
old_prices.select(["timestamp", "old_close"])
.join(new_prices.select(["timestamp", "new_close"]), on="timestamp", how="inner")
.filter(pl.col("old_close") != 0)
.with_columns((pl.col("new_close") / pl.col("old_close")).alias("ratio"))
.select(["timestamp", "ratio"])
.drop_nulls()
)
# %% [markdown]
# ### Ratio Adjustment
#
# Apply the computed ratios cumulatively backwards through the raw series.
# %%
def create_continuous_ratio(
individual_df: pl.DataFrame, front_months: pl.DataFrame
) -> pl.DataFrame:
"""Create continuous series with ratio adjustment.
Uses vectorized Polars joins instead of row-by-row iteration for O(n) complexity.
"""
raw = create_continuous_raw(individual_df, front_months)
roll_info = front_months.filter(pl.col("is_roll"))
if len(roll_info) == 0:
return raw.with_columns(pl.lit(1.0).alias("cumulative_ratio"))
ratios_df = _compute_roll_ratios(individual_df, front_months)
if len(ratios_df) == 0:
return raw.with_columns(pl.lit(1.0).alias("cumulative_ratio"))
# Adjustment applies to dates STRICTLY BEFORE each roll date
raw_with_ratios = raw.join(ratios_df, on="timestamp", how="left").with_columns(
pl.col("ratio").fill_null(1.0)
)
# Cumulative product in reverse, shift by 1 to exclude roll date
raw_with_ratios = raw_with_ratios.with_columns(
pl.col("ratio")
.reverse()
.cum_prod()
.shift(1)
.fill_null(1.0)
.reverse()
.alias("cumulative_ratio")
)
adjusted = raw_with_ratios.with_columns(
[
(pl.col("open") * pl.col("cumulative_ratio")).alias("adj_open"),
(pl.col("high") * pl.col("cumulative_ratio")).alias("adj_high"),
(pl.col("low") * pl.col("cumulative_ratio")).alias("adj_low"),
(pl.col("close") * pl.col("cumulative_ratio")).alias("adj_close"),
]
)
return adjusted
# %%
es_continuous_ratio = create_continuous_ratio(es_individual, front_months)
ratio_first = es_continuous_ratio["cumulative_ratio"][0]
print(
f"Ratio-adjusted: cumulative_ratio at the start of the series = {ratio_first:.4f} "
f"(historical prices are scaled up by this factor)"
)
es_continuous_ratio.select(
"timestamp", "close", "adj_close", "cumulative_ratio", "instrument_id"
).head(10)
# %% [markdown]
# ## 4. Validation
#
# Let's compare our construction to DataBento's pre-built continuous series.
#
# We compare our construction against DataBento's production continuous series.
# Both use volume-based roll detection, but the implementations differ in detail
# (daily aggregation window, exact crossover threshold, etc.), so some divergence
# on roll timing is expected — typically by a day or two around the roll date.
# %%
es_databento = load_cme_futures(products=["ES"], tenors=[0], frequency="hourly", continuous=True)
print(f"DataBento continuous: {es_databento.shape}")
es_databento.head()
# %%
comparison = (
es_continuous_raw.select("timestamp", pl.col("close").alias("our_close"))
.join(
es_databento.select("timestamp", pl.col("close").alias("databento_close")),
on="timestamp",
how="inner",
)
.with_columns(
(pl.col("our_close") - pl.col("databento_close")).alias("diff"),
)
)
mean_abs = comparison["diff"].abs().mean()
median_diff = comparison["diff"].median()
max_abs = comparison["diff"].abs().max()
large_diffs = comparison.filter(pl.col("diff").abs() > 1)
print(f"Hours compared: {len(comparison)}")
print(f"Mean absolute difference: ${mean_abs:.2f}")
print(f"Median signed difference: ${median_diff:+.2f}")
print(f"Max absolute difference: ${max_abs:.2f}")
print(
f"Hourly bars with >$1 difference: {len(large_diffs)} ({100 * len(large_diffs) / len(comparison):.1f}%)"
)
comparison.describe()
# %%
print("Sample of bars with large differences:")
large_diffs.head(10)
# %% [markdown]
# Most differences come from roll-timing disagreements — when our volume-based
# detector rolls a day earlier or later than Databento's, the two series report
# the price of a different contract for those hours, and the
# contango/backwardation spread between expiries produces a gap. The median
# signed difference is essentially zero, but mean absolute difference is on the
# order of tens of dollars, with occasional larger gaps around roll dates where
# the two algorithms disagree by more than a day.
# %% [markdown]
# ### 4.1 Visualize the Difference
# %%
# Plot both series
fig = make_subplots(
rows=2, cols=1, shared_xaxes=True, subplot_titles=("Price Comparison", "Difference")
)
comp_pd = comparison.to_pandas()
fig.add_trace(
go.Scatter(x=comp_pd["timestamp"], y=comp_pd["our_close"], name="Our Construction"),
row=1,
col=1,
)
fig.add_trace(
go.Scatter(x=comp_pd["timestamp"], y=comp_pd["databento_close"], name="DataBento"), row=1, col=1
)
fig.add_trace(
go.Scatter(x=comp_pd["timestamp"], y=comp_pd["diff"], name="Difference"), row=2, col=1
)
fig.update_layout(height=600, title="ES Continuous: Our Construction vs DataBento")
fig.show()
# %% [markdown]
# ## 5. Construct + Validate Helper
#
# The construction logic is generic across products. The Databento subscription
# bundled with the book ships individual contract data for ES only; the other
# 29 products are delivered exclusively as pre-built continuous series. We
# therefore wrap the construction-plus-validation in a single function and
# apply it to ES — the only product where individual data is currently
# available on disk.
# %%
def construct_and_validate(product: str) -> dict:
"""Construct a raw continuous series and validate it against the vendor continuous."""
individual = load_cme_futures(products=[product], frequency="hourly", continuous=False)
fronts = identify_front_month(individual)
continuous_raw = create_continuous_raw(individual, fronts)
databento = load_cme_futures(
products=[product], tenors=[0], frequency="hourly", continuous=True
)
cmp = continuous_raw.select("timestamp", pl.col("close").alias("our_close")).join(
databento.select("timestamp", pl.col("close").alias("db_close")),
on="timestamp",
how="inner",
)
diff = (cmp["our_close"] - cmp["db_close"]).abs()
return {
"product": product,
"rows": len(continuous_raw),
"contracts_used": continuous_raw["instrument_id"].n_unique(),
"validation_rows": len(cmp),
"mean_abs_diff": float(diff.mean()),
"max_abs_diff": float(diff.max()),
}
# %%
validation_summary = pl.DataFrame([construct_and_validate("ES")])
print("Construction-vs-vendor validation (ES):")
validation_summary
# %% [markdown]
# ## 6. Production Pipeline
#
# The teaching examples above demonstrate roll detection and adjustment methods on a single product.
# For production use, the pipeline is:
#
# 1. **Download**: Databento provides pre-rolled continuous contracts (hourly OHLCV) for
# front, second, and third month tenors → `data/futures/market/continuous/hourly/`
# 2. **Session aggregation**: [`05_futures_session_aggregation`](05_futures_session_aggregation.ipynb) assigns CME session dates
# and aggregates hourly bars into daily OHLCV → `data/futures/market/continuous/daily/continuous_daily.parquet`
# 3. **Loading**: `load_cme_futures()` reads the daily parquet for downstream analysis
# %% [markdown]
# ---
#
# ## Key Takeaways
#
# 1. **Roll detection (volume-based, ES, 2016-2025)** finds **40 rolls** —
# matching the four quarterly rolls per year × 10 years. The
# no-rollback constraint is necessary because raw daily-volume leadership
# can flicker between contracts during the roll window.
# 2. **Calendar spreads contaminate raw individual data**: CME ships outright
# contracts alongside calendar spreads that trade at the inter-month
# price difference (~$50100) rather than the index level (~$5,000). The
# `min_outright_price` filter in `identify_front_month` is what prevents
# a high-volume spread from being selected as "front month".
# 3. **Panama (additive) adjustment** for the ES series adds about
# **$625 to the earliest 2016 prices** (so the start-of-history close is
# ~30% above the original quote). This preserves dollar P&L across rolls
# but distorts percentage returns the further back you go.
# 4. **Ratio (multiplicative) adjustment** for the same window has a
# **cumulative ratio of ~1.11 at the start of the series** (about
# +11% scaling). Returns stay correct in percentage terms across the
# whole window — the right choice for IC, momentum features, and any
# statistical analysis.
# 5. **Validation against Databento's continuous** (2,581 daily-aligned bars):
# **mean absolute difference ~$27**, **median signed difference ~$2.50**
# (essentially zero relative to ~$3,800 average price). 2,444 of those
# bars differ by more than $1 (most by a few dollars; **maximum absolute
# gap ~$583**). Differences come almost entirely from roll-timing
# disagreements — when our detector rolls a day earlier or later than the
# vendor's algorithm, the two series report the price of a different
# contract for those hours. The signed median near zero means the
# disagreements wash out: neither algorithm is systematically high or
# low.
#
# ### Adjustment Method Selection
# | Use Case | Recommended Method | Reason |
# |----------|-------------------|--------|
# | Backtesting P&L | Panama (additive) | Preserves dollar gains/losses across rolls |
# | Statistical analysis | Ratio | Preserves percentage returns accurately |
# | Live trading | Raw + position management | Handle rolls in execution layer |
#
# ### Next Steps
#
# - **Chapter 8**: Carry and momentum features built on continuous series.
# - **Chapter 16**: Backtesting with adjusted P&L.