Repository navigation
Decisions
Mattia Papaccioli edited this page Oct 4, 2026
·
47 revisions
Each decision with its reason. Newest decisions go at the bottom of their section.
| Decision | Why |
|---|---|
| One module per thing owned (data, spec, strategy, run, results), plus three that own nothing: optimise, api and cli | The earlier 13-module layout split by step, which needed a core module and a separate metrics module just to break dependency cycles, and scattered "what is a run" across spec, runner and engine. A module is a package of small files; no class does more than one step, which is what prevents v1's god object. |
| No shared-types module; each type lives with its owner |
RunSpec in spec, RunOutput and the output sink in results, the strategy base in strategy, plug-in interfaces in data/sources/base.py and assets/base.py. With fewer modules the graph stays one-way without a leaf module for types. |
| Decision | Why |
|---|---|
| Pluggable source adapters; Bybit is the first | The core must not depend on any one exchange. Source-specific decisions live on the source's page, e.g. Source Bybit. |
Separate download and ingest parts of data, which never import each other |
Network and parsing fail differently. Raw files make ingestion offline and repeatable. |
| Streaming L2 conversion, never whole-day loads | Memory safety; nautilus's own loaders load whole days into pandas. |
| Instrument specs are real, fail loudly on mismatch | v1 guessed specs. |
Bars are built by nautilus at run time, from trades or from stored 1-minute candles, as the run's bars key says (trades by default); only the 1-minute candles are stored |
Nautilus's own aggregation either way, so sbt2 has no resampling code; v2 doesn't expose its bar aggregators to Python. Trades give fills with real trade-level liquidity. Candles are far lighter: a month of BTCUSDT takes 4 s to fetch instead of 3 min, 2.5 MB of catalog instead of 2.1 GB, and runs in 1.3 s instead of 54 s, with the same funding (see Quality). Each source is a BarSource subclass in one registry, so a new one (quote bars, a plug-in) is one class. Replaces bars always built from trades. |
| Instrument specs from nautilus's adapter for the source where one exists | No hand-written mapping to nautilus instruments. The Bybit client needs no keys, but its margin is a placeholder, so the source overwrites it. Fees aren't on v2 instruments; they come from the venue profile's fee model. |
Nautilus's public catalog writers (write_trade_ticks, write_order_book_deltas, write_mark_price_updates, …) with explicit day bounds |
In v2 the catalog is Rust and its writers take start / end, so whole-day file names need no private internals. Replaces v1's direct-Arrow writer. |
| Funding rates are the one hand-written catalog file | v2 has no Python writer for FundingRateUpdate. Ingest writes the file in nautilus's own schema, guarded by a round-trip test; it switches to a public writer when one appears. |
| No manifest: coverage and instrument types come from nautilus's catalog | Less code and no second record to keep in sync. Nautilus derives coverage from file names without reading data. |
| Catalog files named with whole-day bounds | Nautilus's coverage query then shows a missing day as an exact gap, instead of tiny gaps between each day's last and first timestamp. |
data module is the only catalog reader |
Engine, runner, research and the CLI stopped reading the catalog each in their own way. |
| Dated instrument-spec snapshots, pinned by ingest | Re-downloading a spec must not silently change old runs. |
| Carry data in the catalog, streamed with the run | Stays inside the run's window like all other data; nautilus's simulated venue settles it from the stream. |
Per-source known-gaps list, kept in the data root's known_gaps.toml
|
A day missing at the source shouldn't block every run over it, but gaps are accepted deliberately and recorded, never silently. They describe the data held, not configuration, so they live with the data; a missing file means none. Replaces config/sources.toml. |
Source is an abstract base class that owns the behaviour every source shares |
Sources are the first plug-in point, so a new source writes only what is specific to it: raw paths, parsing and instrument ids. Holding known gaps, building them from the source's list in the known gaps file and picking data types by name live once in the base. Replaces a Source protocol with free functions for the shared behaviour. |
| Decision | Why |
|---|---|
nautilus_trader v2, pinned to 2.0.0rc6.dev20260930, a prebuilt nightly wheel from Nautech's package index |
v2 is the Rust core with PyO3 bindings; v1 (Cython) only gets security fixes. v2 removes sbt2's custom funding module, equity sampler, direct-Arrow writer and QuantStats. rc5 (the latest on PyPI) can't stream funding rates from the catalog; this wheel can, verified end to end. The API still changes between release candidates, so the pin is exact. Move to 2.0.0rc6 from PyPI once it's released. A nightly, not a develop (+run) wheel: the index keeps only the latest develop build, which is how the first v2 pin (dev20260927+18861) disappeared, while nightlies stay for 30 publication dates. Replaces the earlier 1.231.0 pin. |
| Three parts, train / validation / test, each run separately | Comparable metrics, and no leakage through carried-over positions or equity. Validation is an unseen check you can repeat; test, the locked final answer, only sees what survived validation. |
| A split's boundaries are fractions of the period or dates, resolved to dates that are hashed | A fraction of an explicit period never moves; v1's fraction of the loaded data moved whenever more data was downloaded. Replaces named splits of fixed dates in versioned config. |
The engine is a function, run.execute, not a module; run.batch orchestrates |
execute stays testable in-process without a module boundary; pre-flight and processes sit next to it in one module. |
| Hash the resolved spec, not names | Editing a venue profile must change the hash. |
| Parts are UTC and half-open, with no embargo | No off-by-one at boundaries. A gap between parts only discards data. Replaces the checked embargo. |
Splits are Splitter objects in the run spec, not named in config; FractionSplit and DateSplit first |
A split belongs to the experiment, and its resolved dates are in the hash, so old results stay distinguishable. New schemes arrive as new Splitters. Replaces global named splits. |
| Warm-up declared by the strategy from its params | Warm-up follows the parameters; it can't be forgotten in the spec. |
| Stop-loss / take-profit are nautilus brackets | Real resting orders, where v1 emulated stops at the bar close. Nautilus links entry and exits (OTO/OCO) and cancels the other exit; no order linking in sbt2. |
Risk: nautilus RiskEngine, plus a drawdown guard in the strategy base's order gate; sizing with nautilus only |
Order limits already exist in nautilus. Nautilus v2 has no Python API to switch its trading state, so the drawdown guard exits and then drops orders through the gate that already handles warm-up. No exposure caps (max_notional_per_order bounds each order) and no sizing helpers (FixedRiskSizer, the portfolio and indicators cover them); both return only if real strategies need them. |
| Declared inputs drive fills | Bar strategies never load L2; L2 strategies fill against the book. |
| Strategies referenced by import path in spec files, by class in Python | No registry to maintain or silently collide. |
Liquidation off by default for crypto perps; a spec's liquidation key turns it on, and pre-flight refuses it without quotes |
Nautilus's venue liquidates only on quotes, so a trades-only run never liquidated, logged a warning per trade and ran 2.7× slower. See Crypto perpetuals. Replaces liquidation on by default. |
| Venue models named by kind from enums, never by import path | A profile edited from the GUI through the server must not make it import any module a client names. The kinds sbt2 supports map to nautilus models by an explicit registry; a model joins by a new kind. Replaces {path, config} model tables. |
| Venue = nautilus built-ins only | v2's simulated venue covers fees, fills, latency, margin, liquidation and perp funding (settled at mark price). A custom carry module is written only for an asset class whose carry nautilus lacks. |
| Asset classes, data sources and venue profiles are plug-ins | The core stays general; the first use case (crypto perps) lives on its own pages. |
High-level BacktestNode with streaming |
Config-driven, and streams natively, which is essential for L2 memory safety. |
| One isolated process per run, parallel batches | A crash or out-of-memory kills one run, not the program. |
| run_id is a UUIDv7, plus the spec hash | Unique, time-sortable ids; the spec hash groups reruns of the same backtest request. |
| Seed in the run spec with a fixed default | Random fill models stay reproducible. |
| One strategy per run | Keeps things simple. Multi-strategy accounts are deferred. |
Venue profile names the venue and its nautilus asset and instrument class; spec files name instruments by full nautilus InstrumentId
|
One place decides the asset profile; mismatched instruments fail the pre-flight instrument check. Nautilus can't build venue-specific ids (e.g. Bybit's -LINEAR) from a bare symbol, so sbt2 doesn't either. |
| Resolving lives in spec; spec imports strategies for metadata only | Parse, expand, resolve and hash were split between spec and runner. Parameter validation and warm-up need the metadata; strategy code only runs in the child process. |
run.execute writes through an output sink interface, straight into the run's folder; no staging |
Streaming output without the engine knowing about storage. Staging and commit were more work than they're worth. |
| A process per run, never a reused worker, forked from a forkserver that preloads the run modules | A cgroup memory cap applies per process, and no nautilus global state leaks between runs. The forkserver never loads a strategy, so each run imports its own afresh. A fresh interpreter cost each run about 0.4 s and 200 MB of imports; a forked child starts in about 0 s and shares them copy-on-write. Scripts guard their batches with if __name__ == "__main__":, the standard multiprocessing rule. |
Launchers picked by name; uncapped by default, the systemd scope opt-in (--launcher systemd) |
A container has no systemd user session, so a systemd-only launcher could start no run there; the container engine limits memory instead. A run killed by the engine's OOM killer fails as a plain run failure (exit code −9). |
| No memory estimate in pre-flight; the memory limit (the per-run cgroup cap under systemd, the container's otherwise) is the only memory guard | The row-count estimate was a guess on top of the real guarantee. A run over the limit crashes the program like any other failure. Concurrency = budget ÷ per-run cap; chunk size is fixed per data type. |
| Equity valued at the asset class's valuation price | Mark-to-market needs one defined price; for perps that's the mark price. |
Valuation done by nautilus's portfolio (use_mark_prices) |
No valuation code in sbt2. |
Resolved run spec maps 1:1 onto nautilus's BacktestRunConfig
|
No second config model to translate; run.execute builds the config straight from it. Hashed as canonical JSON of the resolved spec minus machine-only fields, because v2 configs have no JSON form and their id is random. |
Venue profiles are BacktestVenueConfig's own arguments in TOML, plus nautilus's asset_class and instrument_class
|
Nautilus's constructor validates them and rejects unknown fields; no venue schema in sbt2. |
| Fills, positions and account come from nautilus's reports | Nautilus already builds these tables; the sink stores them as they are. |
| Equity comes from nautilus's portfolio snapshots; the equity sampler is dropped | v2 records mark-to-market snapshots natively (PortfolioConfig.equity_curve daily, plus snapshot_interval_ms while a position is open). |
| Carry ledger comes from nautilus's position adjustments | v2 records each funding payment as a PositionAdjusted (type Funding) on the position, so sbt2 reads the ledger instead of writing one. |
| All run output is taken from nautilus at the end of the run | Nothing streams during the run any more; the sink receives equity, carry and reports once, then finalizes. |
Strategies are nautilus Strategy subclasses on a thin sbt2 base, and submit their own orders |
sbt2 only targets nautilus. The earlier plain-Python pipeline (decide(state) → intents → sizing → risk → reconciler → adapter) mirrored what nautilus already provides, and every nautilus feature (brackets, timers, modify) would have needed a new intent type and adapter code. The base adds only metadata, bar subscriptions and the warm-up gate; logic stays out of the god object by keeping signal and sizing maths in small pure functions. Replaces plain-Python strategies, decoupling level A, intents, the reconciler and the adapter. |
Declared inputs are nautilus BarSpecifications |
No sbt2 wrapper type; the base builds the INTERNAL BarType for each instrument. |
Asset profiles are keyed by nautilus's AssetClass + InstrumentClass
|
Every nautilus instrument already carries both (a CryptoPerpetual is CRYPTOCURRENCY / SWAP), so there are no made-up names, and the sbt2 record (AssetProfile) doesn't shadow nautilus's AssetClass enum. |
Details in Library.
| Decision | Why |
|---|---|
| sbt2 is a package published on PyPI; the user writes only a strategy, in their own package | sbt2 is a helper for backtesting your own strategies, not a repo you clone and edit. The strategies in this repo are only examples, shipped as sbt2-strategies. |
Python API and CLI + spec files on equal footing, over one api module |
Notebooks and scripts want a function; batches and reproducible runs want files. Neither is a thin afterthought. |
| Strategies keep the full nautilus API; no sbt2 facade | Same reason as "Strategies are nautilus Strategy subclasses" above: a facade rebuilds what nautilus provides. |
Pluggable optimiser in its own optimise module, grid search built in; it tunes on train and ranks on validation |
Grid covers the common case; Optuna and others plug in without sbt2 depending on them. Ranking on data the candidate wasn't fitted on picks less overfit parameters. Replaces the deferred research module. |
| The optimiser interface is ask-and-tell over batches |
run.batch runs a list of candidates in parallel, so the optimiser must propose batches rather than call an objective one run at a time. It is the shape of Optuna's ask-and-tell API, so an Optuna adapter needs no change to sbt2. nieledran/backtesting-engine drives its nautilus walk-forward with Optuna the same way. |
| Every optimisation reports the deflated Sharpe ratio and the probability of backtest overfitting | The validation ranking still picks the luckiest of many candidates; the DSR and PBO say how much of the winner is luck. Other frameworks leave this to separate tools. The DSR counts the lineage's stored validation runs, since the results store keeps every one. |
| The overfitting formulas are written in sbt2, not taken from a library | pypbo is AGPL-3.0, which sbt2 (LGPL-3.0+) can't depend on or copy without passing the AGPL on to its users, and it pins old statsmodels. The formulas are short and fully specified in the papers. |
Search space on the Params fields with Annotated[..., Range(...)]
|
The strategy file stays self-contained: defaults and ranges sit side by side. |
| Objective is a named metric or a callable | Built-in names keep results comparable; a callable covers custom scores. |
| Only test is locked; validation runs as often as you like | Re-running test after every tweak turns it into a second validation set without anyone noticing. Validation is the check meant to be repeated. |
| The lock is per lineage (the class's qualified name) and checks date overlap | Any version or parameter set counts, and moving the boundary doesn't escape it. |
An override (reuse_test) runs anyway and marks the result contaminated for good |
Deliberate reuse stays possible, but never silent. |
| Renaming or moving the class escapes the lock, and that is accepted | The lock guards against honest mistakes, not deliberate cheating. Catching renames would need a similarity heuristic. |
| Test runs can't be deleted, and count from the moment they start | The lock reads them from the results store; no separate ledger to keep in sync. |
| No record of which code or data a run used: no git revision, package hash or data fingerprint; only a study pins code, its strategy module's source | For a single user they cost more than they give. The spec hash and the known gaps are recorded. A study is different: its runs are compared as differing only in their parameters, which holds only while the code stays the same. So a study stores the source of its strategy's module, and a batch compares each run's module with it by normalised AST, the syntax tree with docstrings dropped: comments, formatting and docstrings are no change, any other change refuses the batch with StudyCodeError. Only the strategy's own module is pinned; a change in a helper module it imports goes unnoticed. A strategy uploaded to the server goes through the same check. |
| Studies: named in the spec file, kept outside the hash, fixing everything but the parameters | A parameter search must be kept, extended and compared over time; a batch can't do that, having no name and no way to grow after its invocation. The study name stays out of the hash, so the same backtest has the same hash in or out of a study. The study's first run fixes its context, the spec-file table without params, part and study; a batch whose run leaves it, changes the code or repeats a stored run is refused whole, before anything runs, like pre-flight. A study holds many batches, and batch_id keeps naming one invocation. |
| Missing data is fetched automatically before a run, from the source the venue profile names | The user shouldn't manage downloads and ingests to run a backtest. |
One required data root, --data or SBT2_DATA, with no default |
Paths relative to the working directory break once sbt2 is installed elsewhere or runs in a container, which mounts its data volume at a fixed path and sets SBT2_DATA; data downloaded once serves every project. raw/, catalog/, results/ and the known gaps stay under the one root; a different disk per folder is a symlink or a volume mount. Replaces the ./data default. |
One required config folder for run, --config or SBT2_CONFIG, with no default; sbt2-core ships no venue profiles |
A path relative to the working directory breaks a container image and any installed sbt2, whose wheel ships no config/. The configuration must be editable at runtime from the GUI through the server's API, so the defaults live in the GUI, which installs them into an empty config folder. The folder is read on every run, never cached; a started run keeps its venue arguments in spec.json. A missing venues.toml fails with MissingConfigError. Replaces ./config and packaged venue profiles. |
| Sources are entry-point plug-ins; Bybit registers the same way | Other packages can add sources without a release of sbt2. |
Output: Report object, HTML tearsheet, CLI summary table; every run is also stored |
For notebooks, for judging a strategy, and for the terminal. |
Published against a stable nautilus-trader from PyPI |
A published package can't carry the Nautech index pin to its users. |
| Decision | Why |
|---|---|
| Mark-to-market equity at 1h by default, configurable, from nautilus's portfolio snapshots | Realized-PnL-only metrics hide drawdowns; L2 strategies need a finer grid. |
Annualization from the asset class's calendar, passed as period to every nautilus statistic |
v1 hard-coded 252 days for a 24/7 market. Nautilus's statistics default to 252, so the value must be set explicitly everywhere. Its tearsheet takes no period and hardcodes TRADING_DAYS_PER_YEAR = 252 in its rolling Sharpe, so sbt2 draws that panel itself. |
Tearsheets from nautilus's create_tearsheet_from_stats, on demand |
QuantStats-like panels (equity, drawdown, monthly heatmap, distribution, rolling Sharpe, stats) with a benchmark overlay, built from stored results. sbt2 adds only its own panels (rolling Sharpe on the asset's calendar, cost waterfall, per-instrument breakdown) through register_tearsheet_chart and TearsheetCustomChart. Replaces the QuantStats dependency. On demand avoids piles of HTML from batches. |
Metrics use nautilus's PortfolioAnalyzer statistics where they exist |
Fewer formulas to write and test; fed mark-to-market returns and the asset class's annualization. |
| Full metrics keep nautilus's three stats dicts under its own names, computed on demand from the stored run | The tearsheet's create_tearsheet_from_stats takes them as they are; only the headline metrics are stored. |
| A trade is a closed position or a position snapshot; positions still open at the end are left out | An open position's realized PnL is only its fees and partial closes so far, which would count as a losing trade. Nautilus's engine analyzer counts it; sbt2 doesn't. |
Store, metrics and reports are one results module |
Metrics were a separate module only so the runner could avoid depending on analysis. Inside one module the problem disappears; metrics stay functions of the stored run, reading the catalog only for prices. |
| Benchmark is strategy metadata, not in the run spec | It doesn't change the backtest, so it mustn't change the hash. |
| Optional, per-strategy benchmarks | A single forced benchmark misleads for market-neutral strategies. |
results owns RunOutput and the output sink interface |
run already depends on results for sinks, so no shared-types module is needed. |
ResultStore abstract base class; parquet + DuckDB by default |
The backend can be swapped later; DuckDB queries the parquet directly. |
| A store travels as a JSON locator, reopened through an explicit registry | A run child needs the parent's store, and a locator ({"kind": "parquet", "root": ...}) carries it in the child's JSON order without pickling. Replaces a store that had to pickle. |
| The run child's order is one JSON document, sent through a one-way pipe | sbt2 loads no pickle of its own, so ruff's S301 stays on; multiprocessing pickles only the child's target and its pipe. The spec travels as its hashed document plus its source, which ResolvedRunSpec.from_document rebuilds equal to the parent's. The order carries the parent's working directory and sys.path, since the forkserver may have started elsewhere. Replaces two pickles, sys.path and the order. |
sbt2 objects don't pickle; sbt2.core registers no copyreg reducers |
Nothing pickles a spec or an asset profile since the order is JSON, and reducers would change the global copyreg table on import. Replaces the reducers for nautilus's enums and AssetProfile. |
Index is a DuckDB view over per-run summary.parquet files, written last by the sink |
No single writer, no parent-side index step, nothing to keep in sync on delete. A run without a summary didn't finish. |
| A failed run crashes the program; no retries or cleanup | Simple, and failures are never hidden. The failed run has no summary, so it isn't listed; any run can be deleted. |
| Asset classes run separately, one settlement currency per account | Avoids FX conversion and mixed trading calendars. |
| Benchmarks priced from the valuation price series in the catalog (mark price for perps) | The valuation price is downloaded for every instrument, while candles are only there for runs that build bars from them. |
| Exposure from the stored fills' positions, valued at the catalog's valuation price on the run's grid; linear instruments only | The stored run keeps fills but no position series, and nautilus's own net_exposure is only live. The valuation price is the one nautilus marks equity with, so leverage and equity agree, and the benchmarks already read it. An inverse instrument's notional isn't quantity × price, so it fails instead of being valued wrong. |
| An instrument's net PnL is its realized PnL plus the unrealized at the part end's price | The instruments then add up to the run's net PnL, which is the equity change. |
| Benchmark-relative statistics from nautilus |
PortfolioAnalyzer.get_performance_stats_returns_vs_benchmark (alpha, beta, information ratio, tracking error, capture ratios) and the tearsheet's benchmark_returns overlay take a benchmark return series; sbt2 only builds that series. |
| Decision | Why |
|---|---|
| Docs in this wiki, not in the repo | Docs kept in the repo drifted from the code in v1. |
| Woodpecker CI with the GitHub forge | Preference; not GitHub Actions. |
| Lint, type checking, golden-run tests from day one | v1 had none of them. |
One repo as a uv workspace: sbt2-core, sbt2-strategies and sbt2-papers, each adding one subpackage to the sbt2 namespace package |
The paper pipeline shares CI, hooks and lint config with sbt2 but stays its own distribution, so its heavy dependencies never reach the backtester. One subpackage per distribution keeps the boundaries clear and gives import-linter one root each. The cost: no code directly in sbt2, so the API is sbt2.core.backtest. |
sbt2 is a meta distribution: core and strategies, papers as an extra |
pip install sbt2 stays light; the paper pipeline will pull in PDF parsers and torch. |
| All distributions released in lockstep under one version | One tag and one changelog; sbt2 pins its members exactly, so they never mix versions. |
| sbt2 and the paper pipeline never import each other, by import-linter contract | Their only link is the record format; see Paper extraction. |
| Characterization tests for nautilus | The design leans on nautilus behaviors that are undocumented or still changing between release candidates (funding boundaries, snapshot cadence, disposal). Pinning them makes a wheel upgrade fail on the exact assumption it breaks. See Quality. |
| The server is deployed as one container image and a compose file that runs only the server; no reverse proxy, TLS is the operator's | A proxy shipped with sbt2 would fix a choice (Caddy, Traefik, a host's existing nginx) and certificate handling that belong to where the server is deployed. The server speaks plain ws:// and GET /health on a published port. CI builds the image on every pull request and publishes it nowhere. |
One repository for the backend, the protocol and the GUI: sbt2-backend/, sbt2-protocol/ and sbt2-gui/, with the hooks, the pipelines and the changelog at the root |
The protocol was a submodule, so a message and the server code that used it needed a pull request in two repositories, and the protocol's own checks ran elsewhere. One repository keeps the message, the generated Python and the server in one review, with the runtime boundaries unchanged: the GUI still needs only the protocol. |
| Builds, hooks and code generation use only tools pinned in a lock file and run offline; no online service such as Buf's registry | Buf's remote plugins rate-limited anonymous callers, and with the generation hook on every commit, parallel worktrees couldn't commit. A service we don't control must never block a commit, a build or CI; packages fetched once from PyPI or crates.io and cached are fine. |
Design
- Architecture
- Library
- C4 model
- Glossary
- Decisions
- Deferred
- Quality
- Lessons from v1
- Paper extraction
- References
Plug-ins
First use case
Modules