Repository navigation
Strategy
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_barand the other handlers; -
order_factoryandsubmit_order, including brackets; -
self.cacheandself.portfoliofor 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 onA 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.
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, withtrade_startas an ISO string anddrawdown_limitas a decimal string; -
resolve_paramsconverts 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: 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'sriskkey with nautilus's own field names:max_notional_per_order,max_order_submit_rateandmax_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_barruns, 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_orderandsubmit_order_listalike, for the rest of the run. - The trip's time is kept in
self.drawdown_tripped_atand stored asdrawdown_tripped_atin the run's summary (see Results).
- It is measured on each bar, before the subclass's
- There are no exposure caps:
max_notional_per_orderbounds each order, and a strategy readsself.portfolio.net_exposureitself. -
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 bytest_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_ordermoves 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 amodify_orderon 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.
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).
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()),Noneby default; Results reports compare against it; - the optimiser's search space, declared on the
Paramsfields asAnnotated[..., Range(...)](see Library).
- 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
backtestor the import path in a spec file. - Port only v1 strategies that have proven themselves.
- sbt2 never imports a specific strategy except through the class passed to
-
Keep logic out of the handlers. Signal maths and sizing go in small pure functions or nautilus indicators that
on_barcalls, so a strategy class doesn't grow into v1's god object.
Design
- Architecture
- Library
- C4 model
- Glossary
- Decisions
- Deferred
- Quality
- Lessons from v1
- Paper extraction
- References
Plug-ins
First use case
Modules