mirror of
https://github.com/manifoldbt/manifoldbt.git
synced 2026-11-02 07:56:11 +00:00
NautilusTrader is an event-driven engine: it replays bars through a simulated exchange and a Python strategy, where the three engines already in the benchmark are whole-series loops. engine_nt.py expresses the six workloads the way a Nautilus user writes them (low-level BacktestEngine, Strategy subclass, the engine's own SMA/EMA/RSI, market orders, native bracket orders) and the parity gate decides what gets published. Five workloads come back exact against manifoldbt: sma_cross, sma_cross_metrics, sma_cross_costs, multi_asset and ema_rsi_fees, with final equity within 6e-11 of capital (1e-9 is the gate), identical round-trips and fees. The same on Windows (9-decimal fixed point) and on Linux (16-decimal wheel), because the adapter uses 9 on both. bracket_sl_tp is documented for this engine, with its cause: entries, exits and fill prices match round-trip for round-trip, including the re-entry on the exit bar, but the re-entry is sized from the cash after the exit rather than from the equity at the close. diagnose() measures the affected re-entries and their quantity gaps. Choices that keep the work equal, each explained in the module docstring: fills at the close of the signal bar, no volume-limited fills (a fill model with deep liquidity, because Nautilus caps a market order at a quarter of the bar's volume), slippage in bps by shifting the book, an 8-decimal quote currency so realised PnL is not rounded to cents. Nautilus has no drawdown statistic, so sma_cross_metrics is gated on money and round-trips for it, and the gate says so (max_drawdown_absent) instead of rebuilding one from an equity curve. Replays after the first are checked against it and stop the run if they drift. nautilus_trader 1.231.0 is pinned in the lock with its runtime dependencies, and the registry carries a per-engine ceiling (100,000 bars, 64 combinations for a grid) because the engine costs seconds per 100,000 bars where the others cost milliseconds.
135 lines
5.5 KiB
Python
135 lines
5.5 KiB
Python
"""The engine registry: who is in the comparison, and what each one can do.
|
|
|
|
One backtester is the *reference* and the others are *challengers*. Every parity
|
|
check joins a challenger to the reference, never two challengers to each other:
|
|
with four engines there are six pairs, and reporting all of them turns a
|
|
speed benchmark into a matrix nobody reads. manifoldbt is the reference because
|
|
this harness ships with it, which is a statement about the plumbing and not a
|
|
claim about the engine: the reference gets no advantage from the position, it is
|
|
simply the one every timing is divided by.
|
|
|
|
A challenger that is not installed is skipped with a printed line rather than
|
|
crashing the run. The CI lock file pins all of them, so a skip on a runner is a
|
|
finding; a skip on a laptop is somebody who did not want to install a competitor
|
|
to read their own numbers.
|
|
|
|
Ratio basis
|
|
-----------
|
|
``ratio_basis`` records how an engine annualises Sharpe and Sortino. manifoldbt
|
|
and the vectorbt adapter both compute them on daily returns annualised by
|
|
sqrt(365), so their ratios are directly comparable and small differences are
|
|
worth reporting. raptorbt and NautilusTrader return their own, on their own
|
|
bases, and comparing those numbers to manifoldbt's would report a units mismatch
|
|
as a disagreement. The parity gate never depends on this either way: it gates on
|
|
money, round-trips and, where an engine offers one, drawdown, all basis-free.
|
|
|
|
Ceilings
|
|
--------
|
|
``max_bars`` is the longest series an engine is run on by default, and it exists
|
|
for one reason: an event-driven engine replays every bar through a simulated
|
|
exchange and a Python callback, so its cost is seconds per 100,000 bars, where
|
|
the others spend milliseconds. An interleaved repetition loop at 10 million bars
|
|
would run for hours inside a job that has to finish. The ceiling is a time
|
|
budget, not a verdict, and it is printed in the report with its reason rather
|
|
than left as an empty cell. ``bench.py --engine-max-bars`` lifts it for a local
|
|
run. ``max_sweep_combos`` is the same idea for a parameter grid: an engine with
|
|
no sweep API loops a full backtest per cell, and a grid of thousands of cells at
|
|
seconds each is hours.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
from dataclasses import dataclass
|
|
from typing import Dict, List
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Engine:
|
|
name: str
|
|
# Short code used by the out-of-process probes, which take argv strings.
|
|
code: str
|
|
module: str
|
|
# "daily_365" ratios are comparable with the reference's; "native" are not.
|
|
ratio_basis: str
|
|
# PyPI distribution name, for the version stamp in the result envelope.
|
|
distribution: str
|
|
# Longest series (in bars) this engine is run on by default; None for no
|
|
# ceiling. See the module docstring.
|
|
max_bars: int | None = None
|
|
# What the report says where the ceiling stopped a run.
|
|
ceiling_why: str = ""
|
|
# Largest parameter grid (in combinations) this engine is looped over by
|
|
# default, or None. Only an engine with no sweep API needs one: its grid is a
|
|
# loop of full backtests, so the cost is combinations times one backtest.
|
|
max_sweep_combos: int | None = None
|
|
|
|
|
|
REFERENCE = "manifoldbt"
|
|
|
|
ENGINES: Dict[str, Engine] = {
|
|
e.name: e
|
|
for e in (
|
|
Engine("manifoldbt", "mbt", "engine_mbt", "daily_365", "manifoldbt"),
|
|
Engine("vectorbt", "vbt", "engine_vbt", "daily_365", "vectorbt"),
|
|
Engine("raptorbt", "rbt", "engine_rbt", "native", "raptorbt"),
|
|
Engine(
|
|
"nautilus_trader", "nt", "engine_nt", "native", "nautilus_trader",
|
|
max_bars=100_000,
|
|
max_sweep_combos=64,
|
|
ceiling_why=(
|
|
"Not run above {limit:,} bars. NautilusTrader is event-driven: every "
|
|
"bar goes through a simulated exchange and a Python strategy "
|
|
"callback, so one call costs seconds per 100,000 bars and grows "
|
|
"linearly with the series. Repeated and interleaved, a longer series "
|
|
"does not fit the time budget of the job. The measured time at the "
|
|
"ceiling is in the table, and `bench.py --engine-max-bars "
|
|
"nautilus_trader=N` lifts the ceiling for a local run."
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
CHALLENGERS: List[str] = [n for n in ENGINES if n != REFERENCE]
|
|
|
|
BY_CODE: Dict[str, Engine] = {e.code: e for e in ENGINES.values()}
|
|
|
|
|
|
def adapter(name: str):
|
|
"""Import an adapter module on demand.
|
|
|
|
Lazy on purpose: the cold-start probe measures a process that has imported
|
|
exactly one engine, and a registry that imported all three at module scope
|
|
would make that measurement impossible to take.
|
|
"""
|
|
return importlib.import_module(ENGINES[name].module)
|
|
|
|
|
|
def installed(name: str) -> bool:
|
|
try:
|
|
adapter(name)
|
|
return True
|
|
except ImportError:
|
|
return False
|
|
|
|
|
|
def present(names: List[str]) -> List[str]:
|
|
"""Filter to the engines actually importable here, preserving order."""
|
|
return [n for n in names if installed(n)]
|
|
|
|
|
|
def sweep_ceiling(name: str, overrides: Dict[str, int] | None = None) -> int | None:
|
|
"""The largest grid (combinations) this engine is looped over; 0 removes it."""
|
|
if overrides and name in overrides:
|
|
return overrides[name] or None
|
|
return ENGINES[name].max_sweep_combos
|
|
|
|
|
|
def ceiling(name: str, overrides: Dict[str, int] | None = None) -> int | None:
|
|
"""The longest series this engine is run on, honouring a command-line override.
|
|
|
|
An override of 0 removes the ceiling.
|
|
"""
|
|
if overrides and name in overrides:
|
|
return overrides[name] or None
|
|
return ENGINES[name].max_bars
|