Continuous Futures
Naively concatenating front-month futures contracts creates artificial price jumps at each roll, corrupting returns and every feature derived from price levels.
The Problem
Futures contracts expire. When you roll from the March contract to the June contract, the price can jump $5 overnight - not because the market moved, but because the new contract trades at a different level (contango or backwardation). Concatenating contracts without adjustment produces fake returns of 2-5% at every roll date. A momentum signal built on this series will fire on roll artifacts, not real trends. Over a year with 4-12 rolls, this distortion compounds.
The Pattern
WRONG
import polars as pl
# Naive concatenation - price jumps at every roll
contracts = pl.read_parquet("futures_contracts.parquet")
continuous = (
contracts.sort("timestamp")
.group_by("timestamp")
.agg(pl.col("close").first()) # Just take front month
)
# 3-5% fake returns at each quarterly roll
CORRECT
import polars as pl
import numpy as np
def panama_canal_adjust(
front: pl.DataFrame, # timestamp, close: stitched front month
rolls: list[tuple[str, float, float]], # (roll_date, old_close, new_close)
) -> pl.DataFrame:
"""Back-adjust prices using the Panama Canal (additive) method.
The gap is the spread between the outgoing and incoming contract quoted on
the SAME day, which is why both prices have to be passed in. Differencing
adjacent rows of the stitched series instead measures the old contract's
overnight move on top of the spread.
"""
df = front.sort("timestamp")
ts = df["timestamp"].to_numpy()
adj = df["close"].to_numpy().copy().astype(np.float64)
for roll_date, old_close, new_close in sorted(rolls, reverse=True):
# Lift prior history so the roll nets to zero PRICE CHANGE - additive
# adjustment does not preserve percentage returns, ratio does.
# Subtracting the gap doubles the jump: a 5% artifact becomes 10.5%.
adj[ts < np.datetime64(roll_date)] += new_close - old_close
return df.with_columns(adj_close=pl.Series("adj_close", adj))
Roll Methods
| Method | Adjustment | Preserves | Best For |
|---|---|---|---|
| Panama (additive) | Shift by price gap | Absolute price changes | Trend following |
| Ratio (multiplicative) | Multiply by price ratio | Percentage returns | Cross-asset comparison |
| Return-based | Chain daily returns | Returns only | Pure return signals |
| No adjustment | None | Nothing useful | Never use for backtesting |
Term Structure and Carry
Loading multiple contract months enables carry signals - the slope of the futures term structure.
# Carry = (front - back) / front
front = prices.filter(pl.col("position") == 0)
back = prices.filter(pl.col("position") == 1)
carry = front.join(back, on=["product", "timestamp"], suffix="_back").with_columns(
carry=(pl.col("close") - pl.col("close_back")) / pl.col("close")
)
# Negative carry (contango) = cost of holding; positive (backwardation) = benefit
Guardrails
- Never take a return off raw
closeof a stitched series, which jumps at every roll; per-contract raw prices are correct for term-structure work like the carry above - Roll dates vary by product: energy rolls monthly, equity index rolls quarterly
- Panama adjustment changes historical price levels - do not use adjusted prices for margin calculations
- Match the adjustment to the unit you trade: additive
adj_closefor absolute price changes, ratio adjustment for percentage returns, which Panama distorts early in the history - Carry signals require accurate term structure with at least 2 contract months
Production Implementation
ml4t-data provides futures download managers plus configurable continuous-contract builders:
from ml4t.data import FUTURES_REGISTRY
from ml4t.data.futures import ContinuousContractBuilder, FuturesDataManager
manager = FuturesDataManager.from_config("configs/ml4t_futures.yaml")
futures = manager.load_ohlcv("ES")
continuous = ContinuousContractBuilder().build("ES", data_source="databento")
# Access roll specifications
es_spec = FUTURES_REGISTRY["ES"] # Multiplier, tick size, margin, exchange
Checklist
- Roll method chosen and documented (Panama for most uses)
- All prior history back-adjusted at each roll
- Roll dates show no artificial jump in the series the method preserves
- Multiple contract months available for carry signals
- Roll dates sourced from exchange calendar, not hardcoded