# --- # 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 (~$50–100) 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.