Skip to content

Strategy

Mattia Papaccioli edited this page Oct 1, 2026 · 17 revisions

Strategy

Core idea: a nautilus strategy on a thin sbt2 base

A strategy is a nautilus Strategy. It subclasses sbt2.core.strategy.Strategy, a thin base over nautilus's own class, and uses nautilus directly:

  • on_bar and the other handlers;
  • order_factory and submit_order, including brackets;
  • self.cache and self.portfolio for real positions and equity;
  • nautilus indicators and timers.

sbt2 only targets nautilus, so there is no plain-Python layer, intent type, reconciler or adapter between the strategy and the engine. An earlier design had one; it added a parallel vocabulary for what nautilus already provides (see Decisions).

The base adds only what the rest of sbt2 needs:

Base provides Why
Params: a dataclass whose fields are the parameter schema and defaults (default: NoParams) Spec validates parameters and hashes them without running the strategy
warmup(params): a classmethod returning a timedelta (default 0) Spec streams data from part start − warm-up
inputs(params): a classmethod returning nautilus BarSpecifications, e.g. 1-MINUTE-LAST Spec loads only the declared data
Subscriptions: on_start subscribes to BarType(instrument, spec, INTERNAL) for every instrument × declared spec, or on a candle run to its composite form …-INTERNAL@1-MINUTE-EXTERNAL. Nautilus delivers either under the standard type, which bar_types() returns bars are aggregated at run time from trades or 1-minute candles, and the strategy's code is the same for both
self.params, self.instrument_ids built from the run's config
Warm-up gate: submit_order and submit_order_list drop orders while self.warming_up, i.e. before the part's start; the drawdown guard closes the same gate once it trips nothing is traded before the part; the strategy's logic and indicators still run during warm-up
Drawdown guard: with a drawdown limit, each bar after warm-up first compares total equity with its peak, before the subclass's on_bar runs. Once equity is the limit or more below the peak, the base calls market_exit(), sets self.drawdown_tripped_at to the bar's time and closes the warm-up gate for the rest of the run the one run-level guard nautilus v2 can't provide (see Sizing and risk)
class Crossover(Strategy[CrossoverParams]):
    Params = CrossoverParams

    @classmethod
    def warmup(cls, params): return timedelta(minutes=params.slow)

    @classmethod
    def inputs(cls, params): return (BarSpecification.from_str("1-MINUTE-LAST"),)

    def on_bar(self, bar): ...   # plain nautilus from here on

A subclass that overrides on_start calls super().on_start(). A subclass's on_bar needs no super() call: the base wraps it so the drawdown guard runs first. close_position and market_exit bypass the gate, which is how the guard's own exit gets through; nothing can be open during warm-up.

Configuration

nautilus builds the strategy from its import path. Spec and Run call importable_config(StrategyRun(strategy, instruments, params, trade_start, aggregated_from, drawdown_limit)), which returns an ImportableStrategyConfig. aggregated_from is "1-MINUTE-EXTERNAL" on a candle run and None on a trades run. drawdown_limit is the spec's risk.drawdown_limit as a Decimal, or None without one.

Nautilus v2 passes that config through JSON primitives: a Decimal or datetime arrives as a string. So:

  • the strategy receives a primitive RunConfig, with trade_start as an ISO string and drawdown_limit as a decimal string;
  • resolve_params converts decimals from strings or numbers, and floats from ints.

Nautilus's own StrategyConfig can't carry the extra fields: it silently drops unknown fields, and a subclass that adds fields fails when nautilus builds it.

import_strategy("pkg.mod:Class") and resolve_params(strategy, values) are what Spec uses to read and validate a strategy. Unknown parameters raise UnknownParameterError, listing the valid ones. Wrong types raise InvalidParameterError.

Sizing and risk

  • Sizing: the strategy sizes its own orders with nautilus: FixedRiskSizer, self.portfolio (equity, net_exposure) and indicators such as ATR. sbt2 has no sizing helpers.
  • Risk is nautilus's RiskEngine, configured per run by the spec's risk key with nautilus's own field names: max_notional_per_order, max_order_submit_rate and max_order_modify_rate.
  • The one custom guard is a drawdown limit, risk.drawdown_limit, a fraction of peak equity between 0 and 1. Nautilus v2 has no Python API to switch its trading state, so the guard lives in the base:
    • It is measured on each bar, before the subclass's on_bar runs, on total equity: the account balance plus unrealized PnL at the valuation price (the equity the equity table holds), in the instrument's settlement currency.
    • The peak is tracked from the part's start; warm-up doesn't count.
    • When equity falls the limit or more below its peak, the base calls market_exit() and its order gate (the one that drops orders during warm-up) drops every new order, submit_order and submit_order_list alike, for the rest of the run.
    • The trip's time is kept in self.drawdown_tripped_at and stored as drawdown_tripped_at in the run's summary (see Results).
  • There are no exposure caps: max_notional_per_order bounds each order, and a strategy reads self.portfolio.net_exposure itself.
  • Per-trade stops are real resting nautilus brackets (OrderFactory.bracket), never emulated at the bar close. How the simulated venue handles them is pinned on both feeds by test_brackets.py (see Quality):
    • The exits rest once the entry fills. The first bar or trade that reaches an exit fills it, and the other exit is cancelled (OCO).
    • A take-profit limit fills at its price. A stop-loss fills at its trigger on a bar that crosses it, but at the trade's price on a trade that gaps through it, so a trades run books the gap as slippage and a candle run doesn't.
    • modify_order moves a resting stop's trigger, and a resting limit's price and quantity; the order then fires only at the new values. Trailing a stop is a modify_order on the bracket's stop-loss, which stays linked: when it fires, the take-profit is still cancelled.
    • A partially filled entry is not protected by nautilus. On the entry's first fill both exits are released for the entry's full quantity, not the filled part; an exit is cut to the position only when it fills. When an exit fills, the entry's unfilled remainder stays open, and its later fills open a position with no stop-loss or take-profit. A strategy that enters with a limit must cancel the entry's remainder when an exit fills.

Other data

Every run streams the asset class's carry and reference data (for crypto perpetuals, funding rates and mark prices), so a strategy subscribes to them with nautilus's subscribe_funding_rates and subscribe_mark_prices. A trades run streams trades, which subscribe_trades delivers. Quotes come with L2 (M6).

Metadata

What Spec reads from a strategy without running it:

  • the parameter schema and defaults (Params);
  • warmup(params);
  • the declared inputs (inputs(params));
  • an optional default benchmark, the class attribute benchmark (e.g. benchmark = BuyAndHold()), None by default; Results reports compare against it;
  • the optimiser's search space, declared on the Params fields as Annotated[..., Range(...)] (see Library).

Rules

  • Any number of instruments: a single-instrument strategy is just one symbol, not a separate base class.
  • Bounded state only: fixed-size buffers or nautilus indicators, never lists that grow for the whole run.
  • One strategy per run. Multi-strategy accounts are Deferred.
  • Strategies live in the user's own installed package, never in sbt2 (see Library).
    • sbt2 never imports a specific strategy except through the class passed to backtest or the import path in a spec file.
    • Port only v1 strategies that have proven themselves.
  • Keep logic out of the handlers. Signal maths and sizing go in small pure functions or nautilus indicators that on_bar calls, so a strategy class doesn't grow into v1's god object.

Clone this wiki locally