Skip to content
All library documents

Selecting and Backtesting Contracts from an Option Chain

Code NautilusTrader

Summary

This example shows how to run an options backtest from a catalog containing option instruments, quote ticks, and Greeks. It subscribes to periodic option-chain snapshots for a chosen series and selects a contract either at a specified strike or near a target absolute delta, within a configured tolerance. If delta selection finds no match, it falls back to the first quoted contract available.

After selection, the strategy submits two buy orders for the same contract: a post-only limit order at the bid and a market order. It also illustrates choosing the nearest expiry and settlement currency when building a series, and configuring either capped or tiered option fees. This is an implementation example, not evidence of a profitable strategy: the excerpt gives no performance results and does not describe entry signals, exits, or position management. Submitting both orders can create exposure beyond a single intended fill, so the example should not be read as a complete trading plan.

Key ideas

  • The strategy can select an option by fixed strike or by proximity to a target absolute delta.
  • Delta-based selection skips contracts without Greeks and uses a fallback when no contract is within tolerance.
  • The example submits both a bid-side post-only limit buy and a market buy for the selected option.
  • A backtest requires option instruments, quote data, and Greeks to be present in the catalog.
  • The example configures fee models but does not provide performance evidence or a full risk-management method.

Tags

Full text
# tardis_option_chain.py


```py
#!/usr/bin/env python3
# -------------------------------------------------------------------------------------------------
#  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
#  https://nautechsystems.io
#
#  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
#  You may not use this file except in compliance with the License.
#  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS,
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#  See the License for the specific language governing permissions and
#  limitations under the License.
# -------------------------------------------------------------------------------------------------
"""
Example: option-chain backtest from a Tardis-backed catalog.

The catalog must already contain option instruments plus per-instrument QuoteTick
and OptionGreeks data, such as data written by the Tardis Machine replay pipeline.

Run with a built extension:
    python examples/backtest/tardis_option_chain.py --catalog-path /path/to/catalog

"""

from __future__ import annotations

import argparse
from dataclasses import dataclass
from decimal import Decimal
from pathlib import Path
from typing import Any

from nautilus_trader.backtest import BacktestNode  # type: ignore[attr-defined]
from nautilus_trader.config import BacktestDataConfig
from nautilus_trader.config import BacktestEngineConfig
from nautilus_trader.config import BacktestRunConfig
from nautilus_trader.config import BacktestVenueConfig
from nautilus_trader.config import ImportableStrategyConfig
from nautilus_trader.config import StrategyConfig
from nautilus_trader.core import UUID4
from nautilus_trader.execution import CappedOptionFeeModel  # type: ignore[attr-defined]
from nautilus_trader.execution import TieredNotionalOptionFeeModel  # type: ignore[attr-defined]
from nautilus_trader.model import AccountType  # type: ignore[attr-defined]
from nautilus_trader.model import BookType  # type: ignore[attr-defined]
from nautilus_trader.model import ClientOrderId
from nautilus_trader.model import ContingencyType  # type: ignore[attr-defined]
from nautilus_trader.model import InstrumentId
from nautilus_trader.model import LimitOrder  # type: ignore[attr-defined]
from nautilus_trader.model import MarketOrder  # type: ignore[attr-defined]
from nautilus_trader.model import NautilusDataType
from nautilus_trader.model import OmsType  # type: ignore[attr-defined]
from nautilus_trader.model import OptionChainSlice  # type: ignore[attr-defined]
from nautilus_trader.model import OptionSeriesId  # type: ignore[attr-defined]
from nautilus_trader.model import OrderSide  # type: ignore[attr-defined]
from nautilus_trader.model import Price
from nautilus_trader.model import Quantity
from nautilus_trader.model import StrikeRange  # type: ignore[attr-defined]
from nautilus_trader.model import TimeInForce  # type: ignore[attr-defined]
from nautilus_trader.model import TraderId
from nautilus_trader.persistence import ParquetDataCatalog  # type: ignore[attr-defined]
from nautilus_trader.trading import Strategy


VENUE = "DERIBIT"


@dataclass(frozen=True)
class OptionMetadata:
    """
    Collect option metadata tests.
    """

    instrument_id: InstrumentId
    underlying: str
    settlement_currency: str
    expiration_ns: int
    strike: Price


@dataclass(frozen=True)
class SeriesSelection:
    """
    Collect series selection tests.
    """

    series_id: OptionSeriesId
    instrument_ids: list[InstrumentId]
    strikes: list[Price]
    settlement_currency: str


@dataclass(frozen=True)
class SelectedOption:
    """
    Collect selected option tests.
    """

    instrument_id: InstrumentId
    strike: Price
    quote: Any
    delta: float | None


class OptionChainBacktestConfig(StrategyConfig):
    """
    Collect option chain backtest config tests.
    """

    def __init__(
        self,
        *,
        series_id: str,
        selection_mode: str = "delta",
        target_delta: float = 0.25,
        delta_tolerance: float = 0.05,
        target_strike: str | None = None,
        trade_size: str = "1",
        snapshot_interval_ms: int = 1_000,
        **_kwargs: Any,
    ) -> None:
        """
        Initialize the instance.
        """
        super().__init__()
        self.series_id = series_id
        self.selection_mode = selection_mode
        self.target_delta = target_delta
        self.delta_tolerance = delta_tolerance
        self.target_strike = target_strike
        self.trade_size = trade_size
        self.snapshot_interval_ms = snapshot_interval_ms


class OptionChainBacktest(Strategy):
    """
    Collect option chain backtest tests.
    """

    def __init__(self, config: OptionChainBacktestConfig) -> None:
        """
        Initialize the instance.
        """
        super().__init__(config)
        self._series_id = OptionSeriesId.from_str(config.series_id)
        self._selection_mode = config.selection_mode
        self._target_delta = config.target_delta
        self._delta_tolerance = config.delta_tolerance
        self._target_strike = Price.from_str(config.target_strike) if config.target_strike else None
        self._trade_size = Quantity.from_str(config.trade_size)
        self._snapshot_interval_ms = config.snapshot_interval_ms
        self._orders_submitted = False

    def on_start(self) -> None:
        """
        On start.
        """
        if self._selection_mode == "strike":
            if self._target_strike is None:
                raise ValueError("target_strike is required when selection_mode is 'strike'")
            strike_range = StrikeRange.fixed([self._target_strike])
        else:
            strike_range = StrikeRange.delta(self._target_delta, self._delta_tolerance)

        self.subscribe_option_chain(
            self._series_id,
            strike_range=strike_range,
            snapshot_interval_ms=self._snapshot_interval_ms,
        )

    def on_option_chain(self, slice: OptionChainSlice) -> None:
        """
        On option chain.
        """
        log_msg = f"OPTION_CHAIN | {slice.series_id} | atm={slice.atm_strike} | calls={slice.call_count()} puts={slice.put_count()} strikes={slice.strike_count()}"
        self.log.info(log_msg)

        if self._orders_submitted:
            return

        selected = self._select_contract(slice)
        if selected is None:
            return

        log_msg = f"Selected option {selected.instrument_id} at strike {selected.strike} with delta {selected.delta}"
        self.log.info(log_msg)
        self.submit_order(self._maker_order(selected))
        self.submit_order(self._taker_order(selected))
        self._orders_submitted = True

    def on_stop(self) -> None:
        """
        On stop.
        """
        self.unsubscribe_option_chain(self._series_id)

    def _select_contract(self, slice: OptionChainSlice) -> SelectedOption | None:
        if self._selection_mode == "strike":
            return self._select_by_strike(slice)
        return self._select_by_delta(slice) or self._first_quoted_contract(slice)

    def _select_by_delta(self, slice: OptionChainSlice) -> SelectedOption | None:
        best: tuple[float, SelectedOption] | None = None

        for strike in slice.strikes():
            for data in (slice.get_call(strike), slice.get_put(strike)):
                if data is None or data.greeks is None:
                    continue
                distance = abs(abs(data.greeks.delta) - self._target_delta)
                if distance > self._delta_tolerance:
                    continue
                selected = SelectedOption(
                    instrument_id=data.quote.instrument_id,
                    strike=strike,
                    quote=data.quote,
                    delta=data.greeks.delta,
                )

                if best is None or distance < best[0]:
                    best = (distance, selected)

        return best[1] if best is not None else None

    def _select_by_strike(self, slice: OptionChainSlice) -> SelectedOption | None:
        if self._target_strike is None:
            return None

        data = slice.get_call(self._target_strike) or slice.get_put(self._target_strike)
        if data is None:
            return None
        return SelectedOption(
            instrument_id=data.quote.instrument_id,
            strike=self._target_strike,
            quote=data.quote,
            delta=data.greeks.delta if data.greeks is not None else None,
        )

    def _first_quoted_contract(self, slice: OptionChainSlice) -> SelectedOption | None:
        for strike in slice.strikes():
            data = slice.get_call(strike) or slice.get_put(strike)
            if data is not None:
                return SelectedOption(
                    instrument_id=data.quote.instrument_id,
                    strike=strike,
                    quote=data.quote,
                    delta=data.greeks.delta if data.greeks is not None else None,
                )
        return None

    def _maker_order(self, selected: SelectedOption) -> LimitOrder:
        trader_id = self.trader_id
        if trader_id is None:
            raise RuntimeError("Strategy is not registered with a trader")

        return LimitOrder(
            trader_id=trader_id,
            strategy_id=self.strategy_id,
            instrument_id=selected.instrument_id,
            client_order_id=self._client_order_id("M"),
            order_side=OrderSide.BUY,
            quantity=self._trade_size,
            price=selected.quote.bid_price,
            time_in_force=TimeInForce.GTC,
            post_only=True,
            reduce_only=False,
            quote_quantity=False,
            init_id=UUID4(),
            ts_init=self.clock.timestamp_ns(),
            contingency_type=ContingencyType.NO_CONTINGENCY,
        )

    def _taker_order(self, selected: SelectedOption) -> MarketOrder:
        trader_id = self.trader_id
        if trader_id is None:
            raise RuntimeError("Strategy is not registered with a trader")

        return MarketOrder(
            trader_id=trader_id,
            strategy_id=self.strategy_id,
            instrument_id=selected.instrument_id,
            client_order_id=self._client_order_id("T"),
            order_side=OrderSide.BUY,
            quantity=self._trade_size,
            init_id=UUID4(),
            ts_init=self.clock.timestamp_ns(),
            time_in_force=TimeInForce.GTC,
            reduce_only=False,
            quote_quantity=False,
            contingency_type=ContingencyType.NO_CONTINGENCY,
        )

    def _client_order_id(self, label: str) -> ClientOrderId:
        return ClientOrderId(f"{self.strategy_id}-{label}-{UUID4()}")


def parse_args() -> argparse.Namespace:
    """
    Parse args.
    """
    parser = argparse.ArgumentParser()
    parser.add_argument("--catalog-path", type=Path, default=Path("./catalog"))
    parser.add_argument("--underlying", default="BTC")
    parser.add_argument("--selection", choices=("delta", "strike"), default="delta")
    parser.add_argument("--target-delta", type=float, default=0.25)
    parser.add_argument("--delta-tolerance", type=float, default=0.05)
    parser.add_argument("--target-strike")
    parser.add_argument("--fee-model", choices=("capped", "tiered"), default="capped")
    parser.add_argument("--chunk-size", type=int, default=10_000)
    return parser.parse_args()


def main() -> None:
    """
    Run the example.
    """
    args = parse_args()
    options = load_option_metadata(args.catalog_path, args.underlying)
    selection = nearest_series(options)
    target_strike = args.target_strike

    if args.selection == "strike" and target_strike is None:
        target_strike = str(median_strike(selection.strikes))

    venue_config = BacktestVenueConfig(
        name=VENUE,
        oms_type=OmsType.NETTING,
        account_type=AccountType.MARGIN,
        book_type=BookType.L1_MBP,
        starting_balances=[starting_balance(selection.settlement_currency)],
        fee_model=option_fee_model(args.fee_model),
    )
    data = [
        BacktestDataConfig(
            data_type=NautilusDataType.QuoteTick,
            catalog_path=str(args.catalog_path),
            instrument_ids=selection.instrument_ids,
        ),
        BacktestDataConfig(
            data_type=NautilusDataType.OptionGreeks,
            catalog_path=str(args.catalog_path),
            instrument_ids=selection.instrument_ids,
        ),
    ]
    run_config = BacktestRunConfig(
        id="tardis-option-chain",
        engine=BacktestEngineConfig(
            trader_id=TraderId("OPTION-CHAIN-001"),
            run_analysis=False,
        ),
        venues=[venue_config],
        data=data,
        chunk_size=args.chunk_size,
    )

    module_name = Path(__file__).stem
    strategy_config = ImportableStrategyConfig(
        strategy_path=f"{module_name}:OptionChainBacktest",
        config_path=f"{module_name}:OptionChainBacktestConfig",
        config={
            "series_id": str(selection.series_id),
            "selection_mode": args.selection,
            "target_delta": args.target_delta,
            "delta_tolerance": args.delta_tolerance,
            "target_strike": target_strike,
            "trade_size": "1",
            "snapshot_interval_ms": 1_000,
        },
    )

    node = BacktestNode([run_config])
    node.build()
    node.add_strategy_from_config(run_config.id, strategy_config)
    node.run()


def load_option_metadata(catalog_path: Path, underlying: str) -> list[OptionMetadata]:
    """
    Load option metadata.
    """
    catalog = ParquetDataCatalog(str(catalog_path))
    options = [
        metadata
        for instrument in catalog.instruments()
        if (metadata := option_metadata(instrument)) is not None
        and metadata.instrument_id.venue.value == VENUE
        and metadata.underlying.upper() == underlying.upper()
    ]

    if not options:
        raise RuntimeError(
            f"No {underlying} option instruments found for {VENUE} in {catalog_path}",
        )
    return options


def option_metadata(instrument: Any) -> OptionMetadata | None:
    """
    Option metadata.
    """
    if instrument.type_name == "CryptoOption":
        return OptionMetadata(
            instrument_id=instrument.id,
            underlying=instrument.underlying.code,
            settlement_currency=instrument.settlement_currency.code,
            expiration_ns=instrument.expiration_ns,
            strike=instrument.strike_price,
        )
    if instrument.type_name == "OptionContract":
        return OptionMetadata(
            instrument_id=instrument.id,
            underlying=instrument.underlying,
            settlement_currency=instrument.currency.code,
            expiration_ns=instrument.expiration_ns,
            strike=instrument.strike_price,
        )
    return None


def nearest_series(options: list[OptionMetadata]) -> SeriesSelection:
    """
    Nearest series.
    """
    expiration_ns = min(metadata.expiration_ns for metadata in options)
    same_expiry = [metadata for metadata in options if metadata.expiration_ns == expiration_ns]
    settlement_currency = next(
        (
            metadata.settlement_currency
            for metadata in same_expiry
            if metadata.settlement_currency == metadata.underlying
        ),
        same_expiry[0].settlement_currency,
    )
    instrument_ids = [
        metadata.instrument_id
        for metadata in same_expiry
        if metadata.settlement_currency == settlement_currency
    ]
    strikes = sorted(
        {
            metadata.strike
            for metadata in same_expiry
            if metadata.settlement_currency == settlement_currency
        },
        key=lambda strike: strike.as_decimal(),
    )
    series_id = OptionSeriesId(
        VENUE,
        same_expiry[0].underlying,
        settlement_currency,
        expiration_ns,
    )
    return SeriesSelection(series_id, instrument_ids, strikes, settlement_currency)


def median_strike(strikes: list[Price]) -> Price:
    """
    Median strike.
    """
    return strikes[len(strikes) // 2]


def option_fee_model(name: str) -> CappedOptionFeeModel | TieredNotionalOptionFeeModel:
    """
    Option fee model.
    """
    if name == "tiered":
        return TieredNotionalOptionFeeModel(
            maker_rate=Decimal("0.0002"),
            taker_rate=Decimal("0.0005"),
        )
    return CappedOptionFeeModel(
        maker_rate=Decimal("0.0003"),
        taker_rate=Decimal("0.0003"),
    )


def starting_balance(settlement_currency: str) -> str:
    """
    Return the starting balance.
    """
    if settlement_currency in {"BTC", "ETH"}:
        return f"10 {settlement_currency}"
    return f"1000000 {settlement_currency}"


if __name__ == "__main__":
    main()

```

Shown in full with attribution under the source's licence. Licence: LGPL-3.0

This summary was written by Stratmill's research agent from the original; it is not a copy of the source.