Repository navigation
Architecture
Diagrams of everything on this page: C4 model. The user's view of the package: Library.
Everything on this page but the Protocol and Server sections is sbt2.core, in the sbt2-core distribution; module names below are relative to it. The other distributions of the workspace are listed on Library.
One module per thing it owns: a piece of state or a set of files on disk, not one module per step. A module is a package of small files, and no class in it does more than one step. That, not the number of modules, is what keeps v1's god object from coming back.
| Module | Owns | Inside |
|---|---|---|
| Data | raw files, the catalog |
sources (the source adapters), download (network → raw), ingest (raw → catalog, the only catalog writer), catalog (the reader: coverage from nautilus's catalog, notebook loaders; the writer ingest uses) |
| Spec | what to run and on which dates | parse and expand spec files, resolve and hash, splits, venue profiles |
| Strategy | the strategy base | the thin base over nautilus's Strategy (metadata, search space, bar subscriptions, warm-up gate), strategy loading and parameter resolution, the drawdown guard |
| Run | executing backtests | pre-flight (fetching missing data, checks, the test lock), execute (one backtest in-process: the engine), batch (a forked process per run, the launcher, parallelism) |
| Results | stored runs |
store (RunOutput and the output sink, the ResultStore interface and its parquet backend), metrics, pricing (priced runs and benchmarks), costs (costs and exposure), comparisons, report (the Report and tearsheets) |
optimise |
nothing | the optimiser interface, grid search, objectives; runs its candidates through run.batch
|
api |
nothing |
sbt2.core.backtest: the one core both front-ends call |
| CLI | nothing | thin commands over api and data
|
The engine is a function, not a module: run.execute(resolved_spec, sink) runs one backtest in the current process, which is all tests need. run.batch(...) wraps it in processes forked from a forkserver, one per run.
The core never names a specific exchange or asset class. Plug-in points carry everything specific:
-
Data sources (
data/sources/*): how to fetch and parse one source's raw data, and its known data gaps. - Venue profiles: the simulated exchange's settings, and the source that serves its instruments (see Spec). They are configuration, not code: they live in the config folder (below), never in a package.
-
Asset classes (
assets/*): one profile per nautilusAssetClass+InstrumentClasspair: instrument type, calendar, annualization, carry (nautilus-native, or a custom module for a gap), margin defaults, valuation price, buy-and-hold convention.
Each plug-in point defines its interface in its own package (data/sources/base.py, assets/base.py). Plug-ins depend only on nautilus. Sources are discovered through the entry point sbt2.sources, so other packages can add them; sbt2 registers its own the same way. Modules that need one look it up from the resolved run spec (a source by name, an asset profile by its nautilus classes), never by importing a specific plug-in.
Python: sbt2.core.backtest(...) ─┐
CLI: sbt2 run spec.toml ─────────┴─► api ──► spec: parse · expand · resolve · hash
│
▼
data sources ──► data: download · ingest ◄── run: pre-flight ──► results store
│ (fetch missing days, (test lock: earlier
▼ coverage, types, lock) test runs)
root/catalog │ one forked process per run
│ ▼
└──────────────► run: execute ◄── user's strategy (nautilus Strategy on the sbt2 base)
│ ◄── venue profile (nautilus settles carry)
▼
results: output sink ──► runs/{run_id}/ ──► summary written last
│
optimise: rank candidates on validation ◄┤
▼
results: Report, metrics, tearsheets, benchmarks
Dependencies point one way only:
data/sources/*, assets/*, strategy ─► nautilus only (strategy names results' Benchmark as a type only)
user strategies ─► strategy (the strategy base), nautilus
spec ─► strategy (metadata only), assets
data ─► assets
results ─► spec, data, assets (resolved spec type, benchmark prices, annualization)
run ─► spec, data, strategy, results, assets
optimise ─► run, results, spec
api ─► spec, run, optimise, results
cli ─► api, data
-
Type-only imports may cross layers. An import under
TYPE_CHECKINGexists only for the type checker, so the import-linter contracts ignore it (exclude_type_checking_imports). This is how the strategy base types itsbenchmarkas results'Benchmarkwithout importing results at run time. - Each type lives with the module that owns it:
RunSpecin Spec,RunOutput, the output sink andReportin Results, the strategy base in Strategy. There is no shared-types module. -
specimports strategies only for metadata: parameter schema, defaults, search space, warm-up. Strategies run only insiderun.execute, in a child process. -
downloadandingestnever import each other. They share only each source adapter's conventions: URLs, raw paths and the instrument-id format. -
ingestis the only writer to the catalog. Everyone else reads throughdata. -
run.executeknows nothing about processes, the front-ends or where the sink writes. - Strategies use nautilus directly, but never reach the engine or the node; they see only what a nautilus
Strategysees. - Strategies live in the user's own installed package. sbt2 reaches them only through the class passed to
backtestor the import path in a spec file, never by importing one directly.
Everything a user's data and runs produce lives under one data root, which every command that reads or writes data needs: --data PATH, else the SBT2_DATA environment variable; the Python API takes it per call (root=). There is no default and no fallback to the working directory: without either, a command exits with a usage error naming both, and creates nothing. A container mounts its data volume at a fixed path and sets SBT2_DATA. A different disk per folder is done with symlinks or volume mounts.
$SBT2_DATA/
├── settings.toml run settings (memory budget, per-run cap, chunk sizes); optional, defaults built in
├── known_gaps.toml days each source confirmed it lacks, one list per source; optional, none when missing
├── raw/ downloads, one folder per source, the source's own file names kept
├── catalog/ nautilus ParquetDataCatalog
└── results/ default ResultStore backend
├── runs/{run_id}/ one folder per run: the resolved run spec, written when the run starts; equity, fills, positions, carry ledger, [orders] as parquet and summary.parquet (written last) when it ends
└── index DuckDB view over runs/*/summary.parquet; nothing writes to it
Venue profiles are not under the root: they are configuration, in the config folder (below). The known gaps describe the data held, so they live with it. Results hold no absolute paths, so a data root can be moved or mounted elsewhere and its runs still read back.
The configuration a run reads lives in one config folder: sbt2 run --config PATH, else the SBT2_CONFIG environment variable (ConfigFolder in config.py, like Root for data). It is required, with no default and no fallback to the working directory or $XDG_CONFIG_HOME: without either, run exits with a usage error naming both, and creates nothing.
$SBT2_CONFIG/
└── venues.toml the venue profiles, one table per profile (see [[Spec]])
sbt2-core ships no defaults: no venue profile lives in the package. The defaults live in the GUI, which installs them through the server's API when it connects to a server whose config folder is empty; until then the README shows a minimal venues.toml to write. A missing venues.toml fails the run with MissingConfigError, naming the file. The folder is read on every run, never cached, so an edit, from the GUI or by hand, applies to the next run; a run already started is unaffected, since its resolved spec, venue arguments included, is stored in its spec.json.
The GUI talks to sbt2 only through the server (sbt2-server, see Library), in protobuf messages over a WebSocket. The protocol is the GUI's contract, and its single source of truth is the .proto files of sbt2-protocol/ in this repository, in package sbt2.protocol.v1.
sbt2-protocol/
├── buf.yaml buf's lint and breaking rules
├── COPYING, COPYING.LESSER
└── sbt2/protocol/v1/
├── envelope.proto ClientMessage, ServerMessage, the Hello/Welcome handshake
├── types.proto messages shared by every area: Error and its ErrorCode
└── <area>.proto one file per area: runs, studies, config, strategies, results
-
Its own folder: the protocol is public and LGPL-3.0-or-later. The repository's prek hooks format and lint it and check it for breaking changes against
sbt2-protocol/onmain, and its pipeline runs its Python and Rust tests. A message for a new area and the server code that uses it land in one pull request of this repository, in one component or in both. -
Envelope: each binary WebSocket frame is one message: a
ClientMessagefrom the GUI, aServerMessagefrom the server, each with arequest_idand aoneof body. The client picks a non-zerorequest_idper request, and every answer carries it back. A server push hasrequest_id = 0and thesubscription_idit belongs to. -
Errors:
Error { code, message, details }is aServerMessagebody answering the failed request. It lives intypes.proto, notenvelope.proto: the envelope imports every area, so an area that embeds anError(say, per failed item) would otherwise import the file that imports it. -
Handshake: a connection starts with the client's
Hello(its version), answered withWelcome(the server's version and itsCapabilitylist). -
Field rules: prices, quantities and money are decimal strings, never
double; timestamps aregoogle.protobuf.Timestamp; a field whose unset value means something other than zero or empty is proto3optional; a removed field's number and name arereservedand never reused. -
Versions: the package is the protocol's major version. A change that
buf breakingrejects needs a new package,sbt2.protocol.v2, next tosbt2.protocol.v1. -
Code generation, offline with locked tools:
scripts/generate-protocol.shrunsprotocfromgrpcio-toolswith mypy-protobuf's plugin, both pinned insbt2-backend/uv.lock, and writes the Python modules and their stubs tosbt2-backend/packages/sbt2-server/src/sbt2/protocol/v1/, which is committed and never edited by hand. The GUI readssbt2-protocol/from a tag of this repository and generates its Rust types in itsbuild.rs, withprotox(a pure-Rust.protocompiler) andprost-build, both pinned inCargo.lock; nothing in sbt2 serves it. Neither side uses Buf's registry or any other online service to generate code. - sbt2-core knows nothing of the protocol: the server translates between the messages and sbt2-core's types.
sbt2.server, in sbt2-server, serves the protocol to the GUI. It uses only sbt2-core's public interface, and an import-linter contract keeps sbt2-core, sbt2-strategies and sbt2-papers from importing it or sbt2.protocol; another keeps the generated sbt2.protocol from importing any of sbt2.
sbt2/server/
├── auth.py BearerToken: the API token, compared in constant time
├── routing.py Router, Handler, Outbox and offloaded
├── connection.py one client's session: the Hello gate, a task per request, the outgoing queue
├── server.py Server and Settings: the Hello route, health and auth before the upgrade
├── results/ the results area: routes(root) and its private encoding, chunking and tearsheet cache
├── config/ the config area: routes(config, root) and its private encoding
├── transport/ the Transport, Channel and Endpoint interfaces, and WebsocketsTransport
└── cli.py the sbt2-server command (see [[CLI]])
-
Transport: binary WebSocket frames, one envelope per frame, in plain
ws://. sbt2 ships no reverse proxy: TLS, and with itwss://, is the operator's to add in front of the server (see Deployment below). Thewebsocketslibrary (asyncio) serves them behind abstract base classes of the server's own: aTransportserves anEndpointon an address, and hands it each upgraded connection as aChannel(its frames,send,close). A faster library, such aspicows, would replaceWebsocketsTransportby implementing them, if profiling ever shows the socket layer matters. Onlysbt2.server.transportimports its internals. -
Before the upgrade:
GET /healthis answered200 OKon the same port, with no token. Any other request must carryAuthorization: Bearer <token>with the server's one API token,SBT2_SERVER_TOKEN, or gets HTTP 401; the token is meant for one user or a trusted team. A handshake that doesn't ask for the subprotocolsbt2.v1gets HTTP 400. -
Hello first: until a connection's first
Hello, any other request getsError INVALID_MESSAGEwith itsrequest_id; the connection stays open, and is usable onceHelloarrives.Hellois answered withWelcome:server_versionis thesbt2-serverpackage version,capabilitiesthe areas the server was built with;sbt2-serverservesCAPABILITY_RESULTSandCAPABILITY_CONFIG. A repeatedHellogetsWelcomeagain. -
Routing: the router is an explicit registry from a body's field name in
ClientMessageto its handler,async def handler(request: ClientMessage, outbox: Outbox) -> ServerMessage; the router sets the reply'srequest_idto the request's. The server registershelloitself; each area adds its handlers and capability withServer(settings, routes, capabilities), so the server knows no area; thesbt2-servercommand is where the areas are put together. A route for a name that isn't a body ofClientMessagefails when the router is built (UnknownBodyError). -
Errors, never a dropped connection: a frame that isn't a
ClientMessage, or a text frame, getsINVALID_MESSAGEwithrequest_id = 0; a request without a bodyINVALID_MESSAGE;request_id = 0INVALID_ARGUMENT; a body without a handlerUNIMPLEMENTED; a handler that raisesINTERNAL, with a generic message: the traceback goes to the server's log only. The connection stays open in every case. -
The event loop never blocks: each request runs in a task of its own, so a slow one holds up neither the next request nor other connections. A handler runs file, store or CPU-heavy work off the loop:
offloaded(fn)isfnrun throughasyncio.to_thread; backtests run in their own processes (see Run). -
Bounded outgoing queue: replies and pushes go through one queue per connection, of
Settings.queue_sizemessages (256 by default). A push (Outbox.push) never waits: a client that falls that far behind is closed with WebSocket code 1013 (try again later), so a slow client can't grow the server's memory. A reply and the chunks before it (Outbox.send) wait for room in the queue instead, so a long answer slows to the client's pace; a disconnect cancels the waiting request and releases what it holds. -
Chunked replies: an answer too large for one envelope comes as numbered chunks sharing the request's
request_id: each body has anindexfrom 0 and alastflag set on the final one only, and every wholeServerMessageserializes to at most 1 MiB. An empty answer is still one chunk withlastset. A failure after some chunks ends the answer with anErrorfor the samerequest_id, and the chunks before it are void. - One replica, one worker: one process serves every connection on one event loop.
sbt2.server.config.routes(config, root) answers the requests of config.proto: the venue profiles in the config folder's venues.toml, the known gaps in the data root's known_gaps.toml, and the venue model kinds. It uses sbt2-core's public API only: spec.venue_profiles, put_venue_profile, delete_venue_profile, model_kinds, and data.listed_gaps, add_known_gaps, remove_known_gaps. Each answer is one message.
| Request | Answer |
|---|---|
ListVenueProfiles |
VenueProfiles: every profile as stored, without the asset class's venue defaults |
PutVenueProfile |
ConfigWritten; creates the profile, or replaces the one of the same name |
DeleteVenueProfile |
ConfigWritten |
ListKnownGaps |
KnownGaps: every gap, with its source, symbol, data type and day |
AddKnownGaps, RemoveKnownGaps
|
ConfigWritten; a batch, all or nothing; a gap listed twice is kept once, and removing a gap not listed is skipped |
ListModelKinds |
ModelKinds: each model argument's kinds, and each kind's parameters with type, whether required, and default |
-
Profiles: a
VenueProfilehas typed fields for its name, the nautilus venue name, source, asset class and instrument class, each model argument as{kind, config}(modulesas a list), and the otherBacktestVenueConfigarguments in agoogle.protobuf.Struct. AStructholds numbers as doubles, so the server stores an integral number as an integer. - No defaults: the server holds none. An empty config folder answers empty lists; the GUI installs its defaults.
- Validated by core, atomic: sbt2-core checks a write as a run reads it before the file changes, and writes the file to a temp file in the same folder, then replaces it. Writes to one file run one at a time, under a lock per file, and off the event loop; reads go to the files every time, so the next run sees each write. Rewriting a file drops its comments and may reorder its keys.
- Model parameters: their types come from the model builders' signatures; a parameter whose type can't be told is a string.
-
Errors: an invalid profile or gap (an unknown model kind or source, a bad model config, a missing field, a bad day) is
INVALID_ARGUMENT, deleting an unknown profileNOT_FOUND, each with sbt2-core's message.
sbt2.server.results.routes(root) answers the browsing requests of results.proto from the data root's store, through sbt2-core's public results API only (store_at, RunFilter, full_metrics, equity_curve, tearsheet); every store query, parquet read, calculation and rendering runs through offloaded.
| Request | Answer |
|---|---|
ListRuns |
RunList chunks of RunSummary, the finished runs RunFilter selects, oldest first. A run_ids left out selects every run; present and empty, none. |
GetRun |
One RunSummary: the stored summary, with its headline metrics as decimal strings. |
GetMetrics |
Metrics chunks: each of full_metrics' groups, the per-instrument PnL statistics and the probabilistic Sharpe ratio. An undefined value (none, NaN, infinite) is left out, never sent as zero. |
GetSeries |
Series chunks whose bytes, joined, are one Arrow IPC stream. EQUITY is the equity curve the metrics use, on the run's grid: columns ts_event, currency, total_equity. FILLS is the stored fills table as written, indexed by client_order_id; a run without fills sends an empty table with its columns. |
GetTearsheet |
Tearsheet chunks whose bytes, joined, are the UTF-8 HTML sbt2 report tearsheet writes. |
-
Benchmarks: a tearsheet is drawn against the strategy's default benchmark unless the request picks
NONE,BUY_AND_HOLD(on itsinstrument_id, else the run's first instrument) orEQUAL_WEIGHT, as the CLI's--benchmarkdoes; external files are the CLI's only. When the benchmark's strategy or prices are missing the request fails; the server never falls back to no benchmark. -
Tearsheet cache: each rendered tearsheet is kept in the run's folder, under
tearsheets/, one file per benchmark choice andsbt2-serverversion, since a finished run never changes. It is rendered to a temporary file beside it and renamed into place, so a failed render leaves no file behind, and concurrent requests for the same tearsheet render it once. -
Errors: an unknown or malformed run id, a missing table or missing prices are
NOT_FOUND; an unset series kind or a benchmark choice that doesn't fit (an instrument on anything butBUY_AND_HOLD, an instrument id that doesn't parse)INVALID_ARGUMENT; one record larger than an envelopeRESOURCE_EXHAUSTED. Messages never name server paths.
The server image is built from sbt2-backend/Containerfile, with sbt2-backend/ as its build context. sbt2-backend/compose.yaml runs it.
-
The image: a multi-stage build. The first stage installs
sbt2[server]fromsbt2-backend/uv.lock(uv sync --locked --no-dev --extra server --no-editable) into a virtual environment, with uv copied in from its pinned image; the second copies that environment onto the same slim Python 3.14 image, without uv or build tools. It is built for linux/amd64 only, the one platform of the lock and of Nautech's nautilus wheels. The build context has no git history, so the version every member reports (andWelcome.server_versionwith it) comes from the build argumentSBT2_VERSION,0.0.0+localwhen it isn't given. -
At run time: the server runs as the non-root user
sbt2(uid 10001), bound to0.0.0.0:8765(SBT2_SERVER_HOST,SBT2_SERVER_PORT), withSBT2_DATA=/dataandSBT2_CONFIG=/config, two folders the image creates and gives tosbt2, so an empty named volume mounted there is writable.SBT2_SERVER_TOKENis never baked in: a container started without it exits with status 2, naming it. -
Health: the image's
HEALTHCHECKcallsGET /healthwith the image's Python, since a slim image has no curl: every 10 s, after a 30 s start period.HEALTHCHECKis part of the Docker image format only, and podman builds OCI images by default, so a podman build that should keep it passes--format docker;compose.yamldeclares the same check itself. -
compose.yaml: one service,server, built from theContainerfile, with the named volumesdataon/dataandconfigon/config, the token from the deploying shell'sSBT2_SERVER_TOKEN(compose refuses to start without it), port 8765 published, andrestart: unless-stopped. No reverse proxy and no TLS: the server speaks plainws://andGET /healthon the published port, so the token crosses the network in clear text unless the operator puts TLS in front of it. -
Runs execute inside the container, which bounds what an uploaded strategy can reach. A container has no systemd user session, so they use the default
uncappedlauncher, and the container engine limits memory: the operator sets the container's memory limit (mem_limitin compose,--memoryonpodman run).
Deploy from the repository root:
SBT2_SERVER_TOKEN=<token> SBT2_VERSION=<version> podman compose -f sbt2-backend/compose.yaml up -d --buildFrom sbt2-backend/, use podman compose up -d --build. Docker Compose accepts the same file and options. The data and config live in the compose project's volumes and survive down; down -v deletes them.
Full list of terms: Glossary.
- Run spec: one backtest as written: strategy, parameters, instruments, period, split and part, venue profile, capital, seed, equity interval. A spec file may contain lists and expands into many run specs.
- Resolved run spec: the run spec with every name replaced by its contents: instrument ids, asset profile, the part's dates, venue profile values, parameters with defaults filled in, warm-up. This is what gets hashed, so the same hash always means the same backtest request.
- RunOutput: the raw facts a run produced: equity series, fills, positions, carry ledger and status.
- run_id: a UUIDv7, unique per execution. Rerunning the same spec gives the same spec hash and a new run_id.
- Part: train, validation or test. See Spec.
Design
- Architecture
- Library
- C4 model
- Glossary
- Decisions
- Deferred
- Quality
- Lessons from v1
- Paper extraction
- References
Plug-ins
First use case
Modules