Skip to content

data.continuous

Stitching quarterly futures contracts into one continuous series without inventing P&L at the roll.

continuous

Continuous-contract stitching: many expiries -> one back-adjusted series.

A two-year MNQ backtest spans about eight quarterly rolls. Concatenating the expiries naively leaves a price jump at every seam, and while that jump does NOT corrupt P&L (see below), it badly corrupts INDICATORS — a 50-point roll spread is a spurious Cross, an Atr spike that inflates for a whole lookback, and a false Highest/Lowest breakout. The trades that follow have perfectly correct P&L; they simply should never have been taken.

Why additive back-adjustment is exactly P&L-neutral here. This engine force-flattens every position at 16:10 ET and cancels every working order, with a backstop at the session roll — so no position and no resting order ever survives a trading-day boundary, and a roll seam IS a day boundary. Entry and exit therefore always share one day, one contract, and one adjustment offset:

(exit + delta) - (entry + delta)  ==  exit - entry

The offsets cancel identically. Realized P&L, unrealized marks, the balance and the trailing MLL floor are all unchanged by adjustment. That is a much stronger guarantee than a general-purpose backtester can make, and it is the reason this module adjusts prices rather than trying to model rolls as tradable events.

Additive only, never ratio. A multiplicative adjustment does not cancel in the difference (so P&L WOULD move) and pushes prices off the tick grid, which SimBroker asserts on every fill. The spread between two expiries of the same product is an integer number of ticks — both trade the same grid — so the additive offset preserves grid alignment exactly.

What adjustment does change, and it is on you to avoid:

  • Absolute price levels. Logic keyed to round numbers or a fixed price target ("exit at 24,000") is distorted. Tick-relative logic — stop_loss_ticks, take_profit_ticks, everything in this project's strategy sugar — is not.
  • The reported prices no longer match live history.retrieve_bars, which returns raw single-expiry quotes. Signals are relative so they carry over; the printed numbers do not.

Rolls are forced onto trading-day boundaries. A mid-session roll would break the cancellation argument above, so it is refused rather than approximated.

RollEvent

Bases: Struct

One seam: the day the front month changed, and what it cost to align.

day instance-attribute

day: date

First trading day on which to_contract is the front month.

from_contract instance-attribute

from_contract: str

to_contract instance-attribute

to_contract: str

from_price instance-attribute

from_price: Decimal

to_price instance-attribute

to_price: Decimal

The two expiries' closes at the SAME instant — the last timestamp before the roll where both traded. A spread taken from different instants would fold an overnight move into the adjustment.

offset_ticks instance-attribute

offset_ticks: int

to_price - from_price in ticks, the amount added to everything BEFORE this seam (cumulatively, walking backwards). Integer by construction: both expiries trade the same tick grid.

ContinuousSeries

Bases: Struct

A stitched, back-adjusted series ready to hand to Backtest.

symbol instance-attribute

symbol: str

The contract id the output bars carry — the bare product ticker (e.g. "MNQ"), which spec_for_symbol already resolves. The whole series is one instrument as far as the engine is concerned, so positions, orders and the rule kernel see an unbroken account.

bars instance-attribute

bars: tuple[Bar, ...]

rolls instance-attribute

rolls: tuple[RollEvent, ...]

Every seam, in order. Empty when a single expiry covered the range.

adjusted instance-attribute

adjusted: bool

False when nothing needed shifting (one expiry, or every offset was zero). Distinguishes "no adjustment applied" from "adjustment applied and happened to be small".

roll_days property

roll_days: tuple[date, ...]

The seam days — worth excluding when attributing a bad day, since an indicator's lookback is still crossing the seam for lookback bars.

stitch_continuous

stitch_continuous(series: Mapping[str, Sequence[Bar]], *, spec: InstrumentSpec, symbol: str | None = None, roll_days: Mapping[str, date] | None = None) -> ContinuousSeries

Back-adjust several expiries into one continuous series.

Parameters:

Name Type Description Default
series Mapping[str, Sequence[Bar]]

contract_id -> that expiry's bars, each time-ordered and covering exactly one contract. Overlapping date ranges are expected and required — the roll spread is measured from a shared timestamp.

required
spec InstrumentSpec

The product's InstrumentSpec. Supplies the tick grid the offsets are expressed in and validated against.

required
symbol str | None

Contract id for the output bars. Defaults to spec.symbol (e.g. "MNQ"), which spec_for_symbol resolves already.

None
roll_days Mapping[str, date] | None

contract_id -> the first day it is front. Omit to pick rolls by volume: the first overlap day the successor out-trades its predecessor, applied monotonically.

None

Returns:

Name Type Description
A ContinuousSeries

class:ContinuousSeries whose bars are ordered by ts_init and

ContinuousSeries

labelled with one contract id.

Raises:

Type Description
ValueError

on an empty input, a contract whose bars carry a foreign contract id, a roll day with no shared timestamp to price the spread from, or an offset that is not a whole number of ticks (which would mean the two expiries are not on the same grid).

Source code in src/topstep_backtest/data/continuous.py
def stitch_continuous(
    series: Mapping[str, Sequence[Bar]],
    *,
    spec: InstrumentSpec,
    symbol: str | None = None,
    roll_days: Mapping[str, date] | None = None,
) -> ContinuousSeries:
    """Back-adjust several expiries into one continuous series.

    Args:
        series: ``contract_id -> that expiry's bars``, each time-ordered and
            covering exactly one contract. Overlapping date ranges are expected
            and required — the roll spread is measured from a shared timestamp.
        spec: The product's ``InstrumentSpec``. Supplies the tick grid the
            offsets are expressed in and validated against.
        symbol: Contract id for the output bars. Defaults to ``spec.symbol``
            (e.g. ``"MNQ"``), which ``spec_for_symbol`` resolves already.
        roll_days: ``contract_id -> the first day it is front``. Omit to pick
            rolls by volume: the first overlap day the successor out-trades its
            predecessor, applied monotonically.

    Returns:
        A :class:`ContinuousSeries` whose bars are ordered by ``ts_init`` and
        labelled with one contract id.

    Raises:
        ValueError: on an empty input, a contract whose bars carry a foreign
            contract id, a roll day with no shared timestamp to price the
            spread from, or an offset that is not a whole number of ticks
            (which would mean the two expiries are not on the same grid).
    """
    if not series:
        raise ValueError("no contracts supplied: nothing to stitch")
    for contract_id, bars in series.items():
        foreign = {b.bar_type.contract_id for b in bars} - {contract_id}
        if foreign:
            raise ValueError(
                f"bars filed under {contract_id!r} carry other contract ids {sorted(foreign)}; "
                "each entry must hold exactly one expiry"
            )
        if not bars:
            raise ValueError(f"contract {contract_id!r} has no bars")

    out_symbol = symbol if symbol is not None else spec.symbol
    by_contract = {cid: _bars_by_day(bars) for cid, bars in series.items()}
    # Chain order is by first observation: an expiry that starts trading later
    # is further out, whatever its label says.
    chain = sorted(series, key=lambda cid: min(b.ts_init for b in series[cid]))

    if len(chain) == 1:
        only = chain[0]
        return ContinuousSeries(
            symbol=out_symbol,
            bars=tuple(_relabel(b, out_symbol, _ZERO) for b in sorted(series[only], key=_ts)),
            rolls=(),
            adjusted=False,
        )

    chosen_rolls = (
        dict(roll_days) if roll_days is not None else _volume_roll_days(chain, by_contract)
    )
    missing = [cid for cid in chain[1:] if cid not in chosen_rolls]
    if missing:
        raise ValueError(f"no roll day given for {missing}")

    # --- price each seam, then accumulate offsets BACKWARDS from the newest.
    events: list[RollEvent] = []
    for older, newer in itertools.pairwise(chain):
        day = chosen_rolls[newer]
        from_price, to_price = _seam_prices(by_contract[older], by_contract[newer], day)
        delta = to_price - from_price
        try:
            ticks = to_ticks(delta, spec.tick_size)
        except OffGridError as error:
            raise ValueError(
                f"roll spread {delta} between {older!r} and {newer!r} is not a whole "
                f"number of {spec.tick_size} ticks, so the expiries are not on one "
                "grid and an additive adjustment would push prices off it"
            ) from error
        events.append(
            RollEvent(
                day=day,
                from_contract=older,
                to_contract=newer,
                from_price=from_price,
                to_price=to_price,
                offset_ticks=ticks,
            )
        )

    # The newest segment is truth; each older one carries the sum of every
    # spread between it and the present.
    offsets: dict[str, Decimal] = {chain[-1]: _ZERO}
    running = _ZERO
    for event in reversed(events):
        running += event.to_price - event.from_price
        offsets[event.from_contract] = running

    # --- slice each expiry to the window where it is front month.
    starts = {chain[0]: None} | {cid: chosen_rolls[cid] for cid in chain[1:]}
    ends = {cid: chosen_rolls[nxt] for cid, nxt in itertools.pairwise(chain)}

    stitched: list[Bar] = []
    for cid in chain:
        start = starts[cid]
        end = ends.get(cid)
        for bar in series[cid]:
            day = trading_day_of(bar.ts_init)
            if start is not None and day < start:
                continue
            if end is not None and day >= end:
                continue
            stitched.append(_relabel(bar, out_symbol, offsets[cid]))
    stitched.sort(key=_ts)

    for bar in stitched:
        if not is_on_grid(bar.close, spec.tick_size):  # pragma: no cover - guarded above
            raise ValueError(f"adjusted price {bar.close} left the tick grid")

    return ContinuousSeries(
        symbol=out_symbol,
        bars=tuple(stitched),
        rolls=tuple(events),
        adjusted=any(o != _ZERO for o in offsets.values()),
    )