Continuous Futures

SkillDev tools

Build roll-adjusted continuous futures series without artificial price jumps. Use when backtesting futures strategies that span contract rollovers.

Available today. Use it from your connected AI after setup.

Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Then ask your AI: use the Continuous Futures skill

What this skill tells your AI

The instructions your AI receives, as published by ml4t/skills in data/continuous-futures/SKILL.md and read by ahel’s review.

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

MethodAdjustmentPreservesBest For
Panama (additive)Shift by price gapAbsolute price changesTrend following
Ratio (multiplicative)Multiply by price ratioPercentage returnsCross-asset comparison
Return-basedChain daily returnsReturns onlyPure return signals
No adjustmentNoneNothing usefulNever 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 close of 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_close for 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

Signals

GitHub stars
20
Forks
11
Last commit
Sep 2026
Advanced
Item type
skill
Key
ml4t-continuous-futures
Source
github.com/ml4t/skills