manifoldbt docs

Documentation

Strategy Authoring Guide

Define trading strategies using the manifoldbt Python DSL. Strategies compile into vectorized expression graphs executed by the Rust engine.

Installation

Requirements

Python 3.9+ on Linux, macOS, or Windows.

Install

The engine on its own, enough to run backtests and read metrics:

shell
pip install manifoldbt

Add interactive charts (Plotly) and native chart windows, needed for anything in Visualization:

shell
pip install manifoldbt[plot]

For everything (charts, windows, static PNG/SVG export, pandas/polars, rich progress bars):

shell
pip install manifoldbt[all]

Verify

python
import manifoldbt as mbt
print(mbt.__version__)
print(mbt.license_info())

Output (your version may differ; pip install manifoldbt always installs the latest release):

0.34.0
("Community", None)

On import, manifoldbt prints one line with its version and tier. It goes to stderr, so it never mixes with what your script prints, and it is coloured only on a terminal, never when NO_COLOR is set. MANIFOLDBT_QUIET=1 silences it, along with the update notice:

shell
MANIFOLDBT_QUIET=1 python my_backtest.py

Data Store

Most users get a DataStore directly from mbt.ingest() (see Data Ingestion). If you already have Arrow IPC files on disk, you can create one manually:

python
store = mbt.DataStore(
    data_root="data",
    metadata_db="metadata/metadata.sqlite",
    arrow_dir="data/mega",
)

Quick Start

A strategy is a Python script that imports manifoldbt, defines indicators and signals as expression objects, builds a Strategy, configures the backtest, and calls mbt.run().

python
import manifoldbt as mbt
from manifoldbt.indicators import close, ema
from manifoldbt.helpers import time_range, Slippage, Interval

# -- Indicators
fast = ema(close, 10)
slow = ema(close, 25)

# -- Strategy
signal = mbt.when(fast > slow, 1.0, 0.0)

strategy = (
    mbt.Strategy.create("ema_crossover")
    .signal("fast", fast)
    .signal("slow", slow)
    .size(signal)
)

# -- Config
start, end = time_range("2021-01-01", "2026-01-01")

config = mbt.BacktestConfig(
    universe={"binance": ["BTCUSDT"]},
    time_range_start=start,
    time_range_end=end,
    bar_interval=Interval.hours(1),
    initial_capital=10_000,
    fees=mbt.FeeConfig.binance_perps(),
    slippage=Slippage.fixed_bps(2),
    warmup_bars=25,
)

# -- Run (ingest downloads data and returns a DataStore)
store = mbt.ingest(provider="binance", symbol="BTCUSDT", symbol_id=1,
                   interval="1h", start="2021-01-01T00:00:00Z", end="2026-01-01T00:00:00Z")
result = mbt.run(strategy, config, store)
print(result.summary())
mbt.plot.tearsheet(result, show=True)

Data Ingestion

Use mbt.ingest() to download bar data from supported providers directly into your local store. Returns a DataStore ready for backtesting.

Binance / Hyperliquid

python
# Single symbol
store = mbt.ingest(
    provider="binance",
    symbol="BTCUSDT",
    symbol_id=1,
    start="2020-01-01T00:00:00Z",
    end="2026-01-01T00:00:00Z",
)

# Multiple symbols (sequential, with per-symbol progress bar)
store = mbt.ingest(
    provider="binance",
    symbols=[("BTCUSDT", 1), ("ETHUSDT", 2), ("SOLUSDT", 3)],
    start="2020-01-01T00:00:00Z",
    end="2026-01-01T00:00:00Z",
)

result = mbt.run(strategy, config, store)

Ingestion fetches data with 8 concurrent workers, stores as per-symbol Arrow IPC files, and merges with the data already stored: a bar at a timestamp already present replaces the stored one, so a second ingest refreshes a bar that was still forming the first time.

dYdX

dYdX v4 perpetual futures via the Indexer API. No API key required. Supports candles (1m–1d), trades, and funding rates.

python
store = mbt.ingest(
    provider="dydx",
    symbol="BTC-USD",
    symbol_id=1,
    start="2024-01-01T00:00:00Z",
    end="2025-01-01T00:00:00Z",
    asset_class="crypto_perp",
)

# dYdX uses dash-separated tickers: BTC-USD, ETH-USD, SOL-USD
# Supported intervals: 1m, 5m, 15m, 30m, 1h, 4h, 1d

Bitstamp

Bitstamp spot market data via the public REST API v2. No API key required. Supports OHLC candles (1m–1d) and recent trades.

python
store = mbt.ingest(
    provider="bitstamp",
    symbol="BTCUSD",
    symbol_id=1,
    start="2024-01-01T00:00:00Z",
    end="2025-01-01T00:00:00Z",
    asset_class="crypto_spot",
)

# Bitstamp uses concatenated tickers: BTCUSD, ETHEUR, XRPUSD
# Supported intervals: 1m, 3m, 5m, 15m, 30m, 1h, 2h, 4h, 1d

Dukascopy

FX, metals, indices, commodities, stock CFDs and crypto from Dukascopy Bank. Free, no API key and no account. Three intervals are native, 1m, 1h and 1d; anything else is refused, so ingest 1m and resample.

python
store = mbt.ingest(
    provider="dukascopy",
    symbol="EUR/USD",        # or "XAU/USD", "USA500.IDX/USD", "AAPL.US/USD"
    symbol_id=1,
    start="2025-01-06T00:00:00Z",
    end="2025-01-11T00:00:00Z",
    interval="1h",
    asset_class="forex",
    dataset="both",          # fills bid, ask and spread
)

A request for bars downloads bars and nothing else: the bid side by default, dataset="ask" for the other one. dataset="both" fetches the second side too and fills the bid, ask and spread columns, which almost no other free connector does, at twice the number of requests.

Asset classTickersHistory starts
FX majors, gold and silverEUR/USD, USD/JPY, XAU/USD, XAG/USD2003-05-04
Indices (CFD)USA500.IDX/USD, USATECH.IDX/USD, DEU.IDX/EUR2012
Commodities (CFD)BRENT.CMD/USD, LIGHT.CMD/USD2013
US stocks (CFD) and cryptoAAPL.US/USD, NVDA.US/USD, BTC/USD2017

Minute bars come one file per day, so a long 1m ingest is thousands of requests: the host starts refusing after a burst, and the connector paces itself and retries.

Polymarket

Prediction-market prices from Polymarket. Free, no API key and no account. Each outcome of a market is its own symbol, "<market slug>/<outcome>", where the slug is the last part of the market's address on Polymarket. Prices run from 0 to 1, in dollars per share, and intervals from 1m to 1d.

python
store = mbt.ingest(
    provider="polymarket",
    symbol="will-x-happen/Yes",   # "<market slug>/<outcome>"
    symbol_id=1,
    start="2026-01-01T00:00:00Z",
    end="2026-03-01T00:00:00Z",
    interval="1h",
)

A full run, from the slug to a sweep over 400 resolved markets: backtest Polymarket strategies in Python. What each Polymarket API returns, and the datasets and archives beyond it: Polymarket historical data, tested.

Contracts That Settle on an Event

mbt.event_contract(resolved_at, payout=1.0) describes a share that pays a fixed amount when an event resolves, whatever its last price: 1.0 for the side that won, 0.0 for the side that lost, what the venue paid when it voided the market. A Polymarket ingest records it on its own; for bars you imported yourself (Kalshi, your own data), pass it in option_contracts:

python
config = mbt.BacktestConfig(
    universe=[1],
    ...,
    option_contracts={1: mbt.event_contract("2026-03-01T14:00:00Z", payout=1.0)},
)

resolved_at is RFC 3339 text or nanoseconds since the epoch. The position settles at the first bar at or after it, or on the last bar when trading stopped earlier, as long as the run's range covers resolved_at; a run that ends sooner keeps its last price. Nothing here is visible to a strategy before that. To bet against an outcome, buy the other side's share: selling one short is not refused, but no collateral is reserved for it.

Databento Pro

Requires a Pro license and a DATABENTO_API_KEY environment variable. Activate once, the key is saved to disk and reloaded automatically on future imports.

python
# First time only
mbt.activate("YOUR_PRO_KEY")

# Then use directly
store = mbt.ingest(
    provider="databento",
    symbol="ESH5",
    symbol_id=1,
    start="2025-01-01T00:00:00Z",
    end="2025-01-31T00:00:00Z",
    dataset="GLBX.MDP3",
    exchange="CME",
    asset_class="future",
)

Massive Pro

Requires a Pro license and a MASSIVE_API_KEY environment variable. Covers stocks, ETFs, futures, options, forex, indices, and crypto. Its trades and quotes also reach the tick layer, as do Databento's.

python
store = mbt.ingest(
    provider="massive",
    symbol="AAPL",
    symbol_id=1,
    start="2025-01-01T00:00:00Z",
    end="2025-03-01T00:00:00Z",
    exchange="MASSIVE",
    asset_class="equity",
)

CLI

Data can also be ingested from the command line:

shell
manifoldbt ingest --provider binance --symbol BTCUSDT --symbol-id 1 \
    --start 2025-01-01T00:00:00Z --end 2025-03-01T00:00:00Z

Import from CSV

Load your own OHLCV bars from a CSV file, no provider needed (free on all tiers). The standard format is a header row plus timestamp,open,high,low,close,volume where timestamp is Unix milliseconds; MetaTrader 4/5 exports are auto-detected. A backtest's time_range_start and time_range_end are nanoseconds, not milliseconds: take them from time_range(), not from the CSV's column.

python
store = mbt.import_csv(
    "BTCUSDT_1m.csv",
    symbol="BTCUSDT",
    symbol_id=1,
    interval="1m",
    asset_class="crypto_spot",
)
# Backtest on it like any built-in connector (universe=[1])
result = mbt.run(strategy, config, store)

Import from a DataFrame

Load OHLCV bars already sitting in memory, no file or provider needed (free on all tiers). Accepts a pandas or Polars DataFrame with timestamp,open,high,low,close,volume columns. vwap, trade_count, bid, ask, spread, buy_volume and sell_volume are kept when present; without them the vwap is the typical price (O+H+L+C)/4 and the trade count 0. A bar at a timestamp already stored replaces it, so a corrected frame can be imported again.

python
store = mbt.import_dataframe(
    data,
    symbol="BTCUSDT",
    symbol_id=1,
    interval="1m",
    data_root="data",
    metadata_db="metadata/metadata.sqlite",
    exchange="DATAFRAME",
    asset_class="crypto_spot",
)
# Backtest on it like any built-in connector (universe=[1])
result = mbt.run(strategy, config, store)
ParameterRequiredDefaultDescription
datayespandas or Polars DataFrame with OHLCV columns
symbolyesTicker symbol (e.g. "BTCUSDT")
symbol_idyesUnique integer ID for the symbol
interval"1m"Bar interval
data_root"data"Output directory for Arrow IPC files
metadata_db"metadata/metadata.sqlite"Metadata database path
exchange"DATAFRAME"Exchange label recorded in metadata
asset_class"crypto_spot"Asset class label recorded in metadata

Parameters

ParameterRequiredDefaultDescription
provideryes"binance", "bybit", "hyperliquid", "dydx", "bitstamp", "deribit", "yahoo", "dukascopy", "polymarket", "lse" (your own LSE_API_KEY), "databento" Pro, "massive" Pro
symbol*Ticker symbol (e.g. "BTCUSDT"). Use with symbol_id
symbol_id*Unique integer ID for the symbol. Use with symbol
symbols*List of (ticker, id) tuples for multi-symbol ingest
startyesRFC3339 start timestamp
endyesRFC3339 end timestamp
interval"1m"Bar interval: 1s, 1m, 5m, 15m, 30m, 1h, 4h, 1d. Simulating below 1m needs Pro
datasetProvider-specific: the Databento dataset (e.g. "GLBX.MDP3"), or "ask" / "both" on Dukascopy
data_root"data"Output directory for Arrow IPC files
progressTrueShow rich progress bar (requires rich)
exchangeprovider nameExchange name for metadata
asset_class"crypto_spot"crypto_spot, crypto_perp, equity, future, forex. On Binance and Bybit, crypto_perp stores the venue's spot bars, and ingest warns that the prices are the spot market's

Indicators

All indicators live in manifoldbt.indicators and return Expr objects that compose into the expression graph. Pre-built column references: open, high, low, close, volume, timestamp. vwap is not one of them: in this module it names the vwap() indicator, a function to call. The vwap column that bars rebuilt from a tape carry is read with mbt.col("vwap").

Moving Averages

sma(source: Expr, period: int | param) -> Expr

Simple Moving Average. Period can be a literal int or a param() for sweeps.

ArgumentTypeDefaultDescription
sourceExpr--Input series (e.g. close)
periodint | param--Lookback window
ema(source: Expr, span: float | int | param) -> Expr

Exponential Moving Average (span-based, alpha = 2/(span+1)).

ArgumentTypeDefaultDescription
sourceExpr--Input series
spanfloat | int | param--EMA span (converted to float internally)
dema(source: Expr, period: int = 14) -> Expr

Double Exponential Moving Average.

tema(source: Expr, period: int = 14) -> Expr

Triple Exponential Moving Average.

wma(source: Expr, period: int = 14) -> Expr

Weighted Moving Average.

hma(source: Expr, period: int = 14) -> Expr

Hull Moving Average.

kama(source: Expr, period: int = 10) -> Expr

Kaufman Adaptive Moving Average.

python
from manifoldbt.indicators import close, sma, ema, dema, hma

fast = ema(close, 10)
slow = sma(close, 60)
hull = hma(close, 20)
double = dema(close)  # period defaults to 14

Momentum

rsi(source: Expr, period: int = 14) -> Expr

Relative Strength Index (Wilder's smoothing, single-pass O(n)). Returns values in [0, 100].

ArgumentTypeDefaultDescription
sourceExpr--Input price series
periodint | param14Lookback window
roc(source: Expr, period: int = 1) -> Expr

Rate of Change.

momentum(source: Expr, period: int = 1) -> Expr

Raw price difference (source - source.lag(period)).

macd(source: Expr, fast_period: int = 12, slow_period: int = 26, signal_period: int = 9) -> Tuple[Expr, Expr, Expr]

Moving Average Convergence Divergence. Returns a 3-tuple: (macd_line, signal_line, histogram).

ArgumentTypeDefaultDescription
sourceExpr--Input price series
fast_periodint12Fast EMA span
slow_periodint26Slow EMA span
signal_periodint9Signal line EMA span
stoch_k(period: int = 14) -> Expr

Stochastic %K oscillator (native Rust, uses high/low/close columns).

adx(period: int = 14) -> Expr

Average Directional Index (native Rust, uses high/low/close).

cci(period: int = 20) -> Expr

Commodity Channel Index (native Rust, uses high/low/close).

williams_r(period: int = 14) -> Expr

Williams %R oscillator (native Rust, uses high/low/close).

python
from manifoldbt.indicators import close, rsi, macd, adx

my_rsi = rsi(close, 14)
macd_line, signal_line, histogram = macd(close)
trend_strength = adx(14)

Volatility

atr(period: int = 14) -> Expr

Average True Range (Wilder's smoothing, single-pass O(n)). Uses high, low, close columns.

true_range() -> Expr

True Range (single bar, uses high/low/close).

natr(period: int = 14) -> Expr

Normalized ATR (ATR as percentage of close, uses high/low/close).

bollinger_bands(source: Expr, period: int = 20, num_std: float = 2.0) -> Tuple[Expr, Expr, Expr]

Bollinger Bands (native Rust). Returns a 3-tuple: (upper, middle, lower).

ArgumentTypeDefaultDescription
sourceExpr--Input price series
periodint20SMA lookback window
num_stdfloat2.0Number of standard deviations
bollinger_width(source: Expr, period: int = 20, num_std: float = 2.0) -> Expr

Bollinger Bandwidth (upper - lower, normalized).

keltner_channels(period: int = 20, multiplier: float = 1.5) -> Tuple[Expr, Expr, Expr]

Keltner Channels (native Rust, uses high/low/close). Returns (upper, middle, lower).

ArgumentTypeDefaultDescription
periodint20EMA and ATR lookback window
multiplierfloat1.5ATR multiplier for channel width
supertrend(period: int = 10, multiplier: float = 3.0) -> Expr

SuperTrend indicator (native Rust, uses high/low/close).

python
from manifoldbt.indicators import close, atr, bollinger_bands, keltner_channels

vol = atr(14)
upper, middle, lower = bollinger_bands(close, 20, 2.0)
k_upper, k_mid, k_lower = keltner_channels(20, 1.5)

Volume

obv(source: Expr = None, vol: Expr = None) -> Expr

On-Balance Volume. Defaults to close and volume columns.

vwap() -> Expr

Volume Weighted Average Price (uses high/low/close/volume).

ad_line() -> Expr

Accumulation/Distribution Line (uses high/low/close/volume).

mfi(period: int = 14) -> Expr

Money Flow Index (uses high/low/close/volume).

Crossover Signals

crossover(a: Expr, b: Expr) -> Expr

True on bars where a crosses above b.

crossunder(a: Expr, b: Expr) -> Expr

True on bars where a crosses below b.

Linear Regression

linreg_slope(source: Expr, window: int) -> Expr

Rolling linear regression slope (single-pass O(n)).

linreg_value(source: Expr, window: int) -> Expr

Predicted y at the last point of the rolling window.

linreg_r2(source: Expr, window: int) -> Expr

Coefficient of determination R-squared in [0, 1] (single-pass O(n)).

Trend

parabolic_sar(af_start: float = 0.02, af_max: float = 0.2) -> Expr

Parabolic SAR (native Rust, uses high/low).

Math Helpers

abs_val(x: Expr) -> Expr
sqrt(x: Expr) -> Expr
log(x: Expr) -> Expr
exp(x: Expr) -> Expr
max_val(a: Expr, b: Expr) -> Expr
min_val(a: Expr, b: Expr) -> Expr

Element-wise math operations: absolute value, square root, natural log, exponential, max, min.

Datetime Extraction

hour(source: Expr = None) -> Expr       # 0-23 UTC
minute(source: Expr = None) -> Expr     # 0-59
day_of_week(source: Expr = None) -> Expr # 0=Mon, 6=Sun
month(source: Expr = None) -> Expr      # 1-12
day_of_month(source: Expr = None) -> Expr # 1-31

Extract datetime components from a timestamp column. All default to the bar timestamp column.

python
from manifoldbt.indicators import hour, day_of_week

# Trade only during US equity hours (14:30-21:00 UTC)
us_hours = (hour() >= 14) & (hour() < 21)
# Skip weekends
is_weekday = day_of_week() < 5

Filters (Scan-based)

kalman(source: Expr = None, q: float = 1e-5, r: float = 1e-2) -> Expr

1-D Kalman filter (constant-velocity model). Uses the scan primitive -- runs entirely in Rust as a flat scalar VM.

ArgumentTypeDefaultDescription
sourceExprcloseInput price series
qfloat1e-5Process noise covariance
rfloat1e-2Measurement noise covariance
garch(source: Expr = None, omega: float = 1e-6, alpha: float = 0.1, beta: float = 0.85) -> Expr

GARCH(1,1) conditional volatility estimator. Defaults to close.pct_change(1) for the return series. Returns conditional standard deviation.

ArgumentTypeDefaultDescription
sourceExprclose.pct_change(1)Return series
omegafloat1e-6Long-run variance weight
alphafloat0.1Weight on lagged squared return (ARCH term)
betafloat0.85Weight on lagged conditional variance (GARCH term)

Statistics

rolling_median(source: Expr, window: int) -> Expr

Rolling median (native Rust).

Expr Method Chaining

Every Expr exposes chainable methods for rolling computations. All methods return a new Expr.

MethodSignatureDescription
.lag(n)lag(n: int) -> ExprValue at t - n
.lead(n)lead(n: int) -> ExprValue at t + n
.diff(n)diff(n: int = 1) -> Exprx[t] - x[t-n]
.pct_change(n)pct_change(n: int = 1) -> ExprFractional change
.rolling_mean(w)rolling_mean(window: int) -> ExprRolling mean (SMA)
.rolling_std(w)rolling_std(window: int) -> ExprRolling standard deviation
.rolling_sum(w)rolling_sum(window: int) -> ExprRolling sum
.rolling_min(w)rolling_min(window: int) -> ExprRolling minimum
.rolling_max(w)rolling_max(window: int) -> ExprRolling maximum
.rolling_median(w)rolling_median(window: int) -> ExprRolling median
.ewm_mean(s)ewm_mean(span: float) -> ExprExponentially weighted mean
.zscore(w)zscore(window: int) -> Expr(x - mean) / std over window
.rsi(p)rsi(period: int = 14) -> ExprRelative Strength Index
.cumsum()cumsum() -> ExprCumulative sum
.cumprod()cumprod() -> ExprCumulative product
.rank()rank() -> ExprRank of each value over the whole series, 1..N. Not causal: the rank at bar t depends on bars after t, so it must not feed an entry signal. For a causal rank use rolling_rank(w)
.cs_mean()cs_mean() -> ExprCross-sectional mean (multi-asset)
.cs_rank()cs_rank() -> ExprCross-sectional rank (multi-asset)
.cross_above(other)cross_above(other: Expr) -> ExprTrue when self crosses above other
.cross_below(other)cross_below(other: Expr) -> ExprTrue when self crosses below other
.of_symbol(sym)of_symbol(symbol: str) -> ExprReference column from another symbol
.hour()hour() -> ExprExtract hour (0-23 UTC)
.minute()minute() -> ExprExtract minute (0-59)
.day_of_week()day_of_week() -> ExprExtract day of week (0=Mon)
.month()month() -> ExprExtract month (1-12)
.day_of_month()day_of_month() -> ExprExtract day of month (1-31)
.dema(p)dema(period: int) -> ExprDouble Exponential Moving Average
.tema(p)tema(period: int) -> ExprTriple Exponential Moving Average
.wma(p)wma(period: int) -> ExprWeighted Moving Average
.hma(p)hma(period: int) -> ExprHull Moving Average
.kama(p)kama(period: int) -> ExprKaufman Adaptive Moving Average
.roc(p)roc(period: int) -> ExprRate of Change
python
from manifoldbt.indicators import close

z     = close.zscore(60)
slope = close.linreg_slope(20)
r2    = close.linreg_r2(20)
std   = close.rolling_std(30)
rng   = close.rolling_max(14) - close.rolling_min(14)
cross = close.ewm_mean(10).cross_above(close.ewm_mean(25))

Windows in time

Every window above is a bar count: close.rolling_mean(30) averages the last thirty rows. On an irregular grid -- thin sessions, halted hours, bars that only print when something traded -- thirty bars span thirty seconds on a busy minute and four minutes on a quiet one, so the same expression measures a different thing depending on the flow.

Pass an Interval instead of an integer and the window is read on the timestamps:

python
from manifoldbt.helpers import Interval
from manifoldbt.indicators import close, volume, time_since, count_over

mid = close.rolling_mean(Interval.seconds(30))      # 30 seconds, not 30 bars
vol = close.rolling_std(Interval.minutes(5))
z = close.zscore(Interval.minutes(5))
decay = close.ewm_mean(halflife=Interval.seconds(2))
quiet_for = time_since(volume > 1000)                # seconds since it was last true
printed = count_over(Interval.seconds(30))           # rows the window actually holds

The window is (t - d, t]: closed on the right, open on the left, the same convention as pandas.rolling("30s"), and a row exactly d old is already out. While the series holds less than d of history the output is NaN, exactly as a bar-count window is NaN over its first window - 1 rows. Past that point the values equal pandas.rolling("30s") row for row. A NaN inside the window yields NaN for rolling_mean, rolling_sum, rolling_std, zscore and the pair statistics, while rolling_min and rolling_max ignore the NaN rows -- the same rule as the bar-count versions, so switching a window from bars to seconds changes the window and nothing else.

Where a duration is accepted.

OperatorBar countDuration
rolling_mean, rolling_sum, rolling_std, rolling_min, rolling_maxyesyes
zscore, rolling_varyesyes
rolling_corr, rolling_cov, rolling_betayesyes
count_overyesyes
ewm_meanspan=halflife=
everything else (rsi, atr, rolling_median, lag, pivots, ...)yesrefused by name

An operator with no time-based implementation refuses a duration and says so, rather than reading it as a bar count:

TypeError: this operator counts BARS, not time: pass an integer. Windows in
time are available on rolling_mean, rolling_sum, rolling_std, rolling_min,
rolling_max, zscore, rolling_var, rolling_corr, rolling_cov, rolling_beta,
count_over, and on ewm_mean(halflife=...)

ewm_mean(span=20) counts bars and is the plain recurrence. ewm_mean(halflife=Interval.seconds(2)) decays with the time elapsed between two rows, so a row dt old weighs 0.5 ** (dt / halflife). It matches pandas.ewm(halflife="2s", times=...).mean(). Having no window it has no warmup either: the value exists from the first row, like the span form.

Two operators exist only in time. time_since(cond) is the twin of bars_since(cond): seconds since the last row where the condition was true, 0.0 on a true row, NaN until it first is. count_over(Interval.seconds(30)) -- a duration with no condition -- counts the rows the window holds, which is how a strategy reads how gappy its own grid is; count_over(cond, Interval.seconds(30)) still counts the true ones.

Signals & Sizing

Strategies use the fluent builder pattern. Add named signals with .signal() and set the position sizing expression with .size().

DSL Functions

col(name: str) -> Expr

Reference a data column (e.g. "close", "volume", or a named signal).

lit(value: Any) -> Expr

Create a literal constant expression. Required when a Python number appears on the left side of an operator.

hold() -> Expr

Returns NaN -- tells the engine to hold the current position unchanged.

param(name: str, *, default: Any = None, range: Any = None, description: str = "") -> Expr

Create a sweepable parameter reference. Metadata is auto-collected by Strategy.

ArgumentTypeDefaultDescription
namestr--Parameter name (must be unique)
defaultAnyNoneDefault value
range(min, max)NoneBounds for sweeps
descriptionstr""Human-readable description
symbol_ref(symbol: str, column: str) -> Expr

Reference a column from a specific symbol's data for cross-asset strategies. With dict universe, use qualified names: symbol_ref("binance:BTCUSDT", "close").

exo(name: str, column: Optional[str] = None) -> Expr

Reference an exogenous data column. If column is omitted, defaults to name. Equivalent to col("exo.{name}.{column}"). See Exogenous Data.

asset(symbol: str) -> AssetRef

Create a reference to a specific symbol. Call .col("close") on the returned object.

scan(state: dict[str, Expr], update: dict[str, Expr], output: str) -> Expr

Stateful fold expression. Executes entirely in Rust as a flat register-based scalar VM. Use s.prev("name") for previous state and s.var("name") for intra-step references.

mbt.when()

when(condition: Expr, true_value: Any = 1.0, false_value: Any = NaN) -> Expr

Conditional expression (if/else). Omit true_value to default to 1.0 (full position). Omit false_value to hold current position (NaN).

ArgumentTypeDefaultDescription
conditionExpr--Boolean expression
true_valueAny1.0Value when condition is true
false_valueAnyNaNValue when condition is false (NaN = hold)
python
# Long when oversold, flat when overbought, hold otherwise
signal = mbt.when(zscore < -1.0, 1.0,
         mbt.when(zscore > 1.0, 0.0))

# Simple binary: long or flat
signal = mbt.when(fast > slow, 0.5, 0.0)

# Long/short
signal = mbt.when(fast > slow, 1.0, -1.0)

Arithmetic with mbt.lit()

For expressions that start with a Python number, use mbt.lit() to create an explicit literal:

python
# This works -- Expr on left side
weighted = zscore * 0.5

# This needs lit() -- number on left side
inverse = mbt.lit(1.0) - zscore

Sizing Values

ValueMeaning
1.0Full long position (clamped by max_position_pct)
0.5Half position
0.0Go flat -- exit all positions
-0.5Half short (requires allow_short=True)
NaNHold current position unchanged

Sizing Modes

ModeDescription
FractionOfEquityTarget 1.0 = 100% of current equity (compounds)
FractionOfInitialCapitalTarget 1.0 = 100% of initial capital (no compounding)
UnitsTarget 1.0 = 1 unit (share/contract/coin)
python
execution = mbt.ExecutionConfig(
    position_sizing_mode="FractionOfEquity",  # default
    max_position_pct=1.0,
)

Parameters & Sweeps

Use mbt.param() in indicator periods. Parameters are auto-collected from all signal expressions -- no .param() call on Strategy needed.

A plain run reads each parameter's default. run(), run_batch(), run_batch_lite() and detect_lookahead() raise on a param() without one: only a sweep sets it. An indicator window counts bars, so its period is a whole number (5 or 5.0; 5.4 raises), written directly, as a param() or in a grid. The span of ema() can stay fractional.

python
# Define parameterized indicators
fast_p = mbt.param("fast", default=10)
slow_p = mbt.param("slow", default=25)

fast = ema(close, fast_p)
slow = ema(close, slow_p)

strategy = (
    mbt.Strategy.create("ema_sweep")
    .signal("fast", fast)
    .signal("slow", slow)
    .size(mbt.when(fast > slow, 1.0, 0.0))
)

# Run a parameter sweep (Cartesian product, parallel via rayon)
sweep = mbt.run_sweep(
    strategy,
    param_grid={"fast": [5, 10, 15, 20], "slow": [30, 50, 60]},
    config=config,
    store=store,
)

print(sweep.best("sharpe"))
df = sweep.to_df()

Run Functions

run(strategy: Strategy, config: BacktestConfig, store: DataStore) -> Result

Run a single backtest and return a rich Result with DataFrame conversion, summaries, and plotting methods.

run_sweep(
    strategy: Strategy,
    param_grid: dict[str, list],
    config: BacktestConfig,
    store: DataStore,
    *, max_parallelism: int = 0,
) -> SweepResult

Cartesian product parameter sweep (parallel via rayon). Returns a SweepResult with .to_df(), .best(metric), .plot_metric().

ArgumentTypeDefaultDescription
strategyStrategy--Strategy definition
param_griddict--Mapping of param names to value lists, e.g. {"fast": [10, 20, 30]}. Every key must match a name declared with mbt.param() in the strategy, or be stop_loss, take_profit or trailing_stop, which sweep the strategy's own exit orders -- any other key raises StrategyError before any backtest runs
configBacktestConfig--Backtest configuration
storeDataStore--Data store
max_parallelismint0Max threads. 0 = all cores

SweepResult

The SweepResult returned by run_sweep() is an iterable, indexable collection of results with convenience methods for analysis.

Method / AttrSignatureDescription
.best(metric)best(metric: str) -> ResultReturn the result with the highest value for the given metric (e.g. "sharpe")
.worst(metric)worst(metric: str) -> ResultReturn the result with the lowest value for the given metric
.to_df()to_df(backend: str = "auto") -> DataFrameAll results as a DataFrame with params + metrics columns
len(sweep)__len__() -> intNumber of results in the sweep
sweep[i]__getitem__(index: int) -> ResultAccess a single result by index
for r in sweep__iter__() -> Iterator[Result]Iterate over all results
run_sweep_lite(
    strategy: Strategy,
    param_grid: dict[str, list],
    config: BacktestConfig,
    store: DataStore,
    *, max_parallelism: int = 0, device: str = "auto", precision: str = "fp64",
) -> list[BatchResultLite]

Parameter sweep returning only metrics (no Arrow output). Much faster for large grids. Each result has .name, .metrics, .equity, .trade_count. Extract whole metric columns as numpy arrays with sweep_columns(). Same param_grid validation as run_sweep(): every key must be declared with mbt.param(), or be one of the exit orders (stop_loss, take_profit, trailing_stop). Since 0.17.0 signals and position sizing compile as a single graph evaluated in cache-resident blocks, and higher-timeframe columns are resampled once per sweep rather than once per combination, so a strategy carrying real logic sweeps several times faster than it did in 0.16 for identical results.

ArgumentTypeDefaultDescription
max_parallelismint0Max worker threads for the CPU path (0 = all cores)
devicestr"auto""auto" (dispatch by grid size), "cpu" (Rayon), or "cuda" (GPU sweep, Researcher). See below
precisionstr"fp64""fp64" (default, bit-identical to the CPU) or "fp32" (approximate single precision, GPU scan mode). "fp32" requires device="cuda"
run_batch(
    strategies: list[Strategy],
    config: BacktestConfig,
    store: DataStore,
    *, max_parallelism: int = 0,
) -> list[Result]

Run many strategies in parallel sharing a single data load. Loads bars once, aligns timestamps once, then evaluates each strategy on a separate rayon thread.

run_batch_lite(
    strategies: list[Strategy],
    config: BacktestConfig,
    store: DataStore,
    *, max_parallelism: int = 0,
) -> list[BatchResultLite]

Like run_batch but returns only metrics (no Arrow output). Ideal for screening many strategies.

Research Functions

run_walk_forward(
    strategy: Strategy,
    wf_config: dict,
    config: BacktestConfig,
    store: DataStore,
) -> dict

Walk-forward analysis (Pro only). Rebuilt in 0.18.0: four fold geometries behind one mechanism, and a warm out-of-sample run. The walk-forward guide covers how to choose between them.

Geometry. anchored (the default) and blocked keep the historical parameterisation: you choose n_splits and a train_ratio. pardo and custom are parameterised by window lengths instead, and the fold count is derived from them rather than picked: how many tests fit in your history is a property of the data, not a knob. Every duration accepts either an Interval or a *_bars twin counted in signal bars.

Out-of-sample is warmed up. Each fold's OOS run simulates from the start of that fold's training window with trading suppressed until the test window opens, so indicators are hot when the first out-of-sample bar arrives. Before 0.18.0 an EMA(200) restarted empty at every boundary, and the OOS metrics largely measured indicators filling up. The reported oos_equity and oos_metrics cover the test window only.

wf_config keyTypeDescription
geometrystr"anchored" (default), "blocked", "pardo" or "custom"
n_splitsintNumber of folds. anchored/blocked only, derived otherwise
train_ratiofloatTraining fraction in (0, 1). anchored/blocked only
traindictpardo: {"length": Interval.days(365)}, a fixed window that slides. custom: {"mode": "anchored", "min_length": ...} or {"mode": "rolling", "length": ...}
testdict{"length": Interval.days(90), "step": Interval.days(30)}. step defaults to length, the only shape whose OOS segments chain into one tradable curve; step < length overlaps and is flagged by folds_overlap; step > length is refused rather than silently leaving holes in the record
optimize_metricstre.g. "sharpe", "sortino"
param_griddictParameter grid for optimization. Every key must be declared with mbt.param() in the strategy or be one of the exit orders, same validation as run_sweep()
devicestr"auto" (default), "cpu" or "cuda". The per-fold selection is a sweep, so with a Researcher licence it runs on the card; on Pro, "auto" stays on the CPU and "cuda" raises a LicenseError. "auto" applies the same ~1,000-combo crossover as run_sweep_lite, which bites harder here: one fold carries the whole grid, so a small grid leaves the GPU under-occupied and the protocol relaunches it once per fold
max_parallelismintMax threads

Returned dict:

KeyDescription
foldsOne dict per fold: fold_index, train_range, test_range, is_metrics, oos_metrics, best_params, all_is_results, is_equity, oos_equity, oos_timestamps, wfe
best_params_per_foldThe winning parameter set of each fold, in order
n_foldsNumber of folds produced
folds_overlapTrue when test windows overlap (step < length): the same date is then tested by several folds
effective_foldsIndependent folds: the union of the test windows divided by the test length. A custom run showing 16 overlapping folds may be worth 4. This is the number that carries statistical weight, not n_folds
walk_forward_efficiencyPardo's WFE: the mean of the per-fold oos.cagr / is.cagr. None when no fold has a positive in-sample base, since a ratio on a negative base means nothing and is not fabricated

The legacy method="Anchored" is still accepted as a geometry. method="Rolling" is refused with a named error: that mode was renamed geometry="blocked", because it never did what Pardo calls rolling. For Pardo's walk-forward, use geometry="pardo".

run_sweep_2d(
    strategy: Strategy,
    sweep_config: dict,
    config: BacktestConfig,
    store: DataStore,
) -> dict

2D parameter sweep (heatmap). Returns dict with metric_grid, x_values, y_values. Runs on Community within the session's sweep budget, like run_sweep() (see Licences & Activation).

sweep_config keyTypeDescription
x_paramstrFirst parameter name
x_valueslistValues for x_param
y_paramstrSecond parameter name
y_valueslistValues for y_param
metricstrMetric to collect (e.g. "sharpe")
max_parallelismintMax threads
run_stability(
    strategy: Strategy,
    stability_config: dict,
    config: BacktestConfig,
    store: DataStore,
) -> dict

Parameter stability analysis. Returns dict with stability_score, metric_values, mean_metric, std_metric. Runs on Community within the session's sweep budget, like run_sweep() (see Licences & Activation).

stability_config keyTypeDescription
param_namestrParameter to vary
valueslistValues to test
metricstrMetric to evaluate
max_parallelismintMax threads
python
results = mbt.run_sweep_lite(
    strategy,
    param_grid={"fast": list(range(5, 100)), "slow": list(range(50, 200))},
    config=config,
    store=store,
)
# Each result has: .name, .metrics, .equity, .trade_count

# device="auto" is the default: this ~14k-combo grid dispatches to the GPU
# when a CUDA build + device + Researcher license are present, else the CPU.
# Force it either way with device="cuda" (Researcher) or device="cpu". A "cuda"
# call that hits an unsupported config warns and runs on the CPU.
results = mbt.run_sweep_lite(strategy, param_grid, config, store, device="cuda")

# Pull whole metric columns out as numpy arrays (~20x faster than looping
# .metrics on a large sweep), then map argmax straight back onto the batch.
sharpe = mbt.sweep_columns(results, "sharpe")
best = results[int(sharpe.argmax())]

sweep_columns

sweep_columns(
    batch: list[BatchResultLite],
    names: str | list[str],
) -> np.ndarray | dict[str, np.ndarray]

Extract whole metric columns from a run_sweep_lite() result as numpy arrays. Reading a single metric off a large sweep by looping .metrics builds millions of throwaway floats; sweep_columns walks the results once and copies each requested column straight into a float64 array, about 20x faster (a 1M-combo sweep drops from ~1.1s of extraction to ~0.05s). Arrays are in combo order, so argmax/argsort indices map straight back onto the batch list.

ArgumentTypeDescription
batchlist[BatchResultLite]The list returned by run_sweep_lite()
namesstr | list[str]One column name, or a list. A string returns one array; a list returns a {name: array} dict. Available: final_equity, trade_count, and every PerformanceMetrics field (sharpe, sortino, calmar, max_drawdown, total_return, cagr, volatility, alpha, beta, and the rest)

Stochastic Simulation

Generate synthetic price paths from stochastic differential equations (SDEs). Define custom models via string expressions, all compiled to native Rust and executed with Rayon parallelism (CPU) or CUDA (GPU). No Python callback overhead.

run_stochastic(
    model,
    *, s0=100.0, n_paths=1000, n_steps=252, dt=1/252,
    params=None, seed=None, confidence_levels=None,
    store_paths=False, device="cpu",
) -> dict

Run a stochastic simulation. Returns dict with final_price, final_return, max_drawdown, annualized_return, annualized_vol (each with percentiles, mean, std, min, max), and optionally paths (Arrow array).

ArgumentTypeDefaultDescription
modelstr | StochasticModel--Preset name ("gbm", "heston", "merton", "garch_jd") or a StochasticModel instance
s0float100.0Initial price
n_pathsint1000Number of simulation paths (Community: max 1000)
n_stepsint252Time steps per path
dtfloat1/252Time step in years (1/252 = daily, 1/252/390 = minute)
paramsdict | NoneNoneParameter overrides (merged with model defaults)
seedint | NoneNoneRNG seed for reproducibility
store_pathsboolFalseStore full price paths (memory-intensive, CPU only)
devicestr"cpu""cpu" (Rayon) or "cuda" (GPU, Researcher; ships in the standard wheel since 0.18.0, and needs the NVIDIA driver plus NVRTC via manifoldbt[gpu])

Built-in Presets

PresetSDEDefault Params
"gbm"dS = μSdt + σSdWmu=0.05, sigma=0.2
"heston"dS = μSdt + √v·SdW, dv = κ(θ−v)dt + ξ√v dW₂mu=0.05, kappa=2, theta=0.04, xi=0.3
"merton"dS = μSdt + σSdW + JSdN(λ)mu=0.05, sigma=0.2, lambda=1, mu_j=-0.05, sigma_j=0.08
"garch_jd"dS = μSdt + √h·SdW + JSdN, h₀=ω+α(r−μ)²+βhmu=0.08, omega=1e-6, alpha=0.1, beta=0.85, lambda=5, mu_j=-0.02, sigma_j=0.04

Custom Models (StochasticModel)

StochasticModel(
    *, drift: str, diffusion: str,
    jump_intensity: str = None,
    jump_size: str = None,
    state_vars: dict = None,
    state_update: dict = None,
    params: dict = None,
    name: str = "custom",
)

Define a custom SDE via string expressions. The model has the form:
dS = drift(S,t,state) · S · dt + diffusion(S,t,state) · S · dW + jump_size · dN(jump_intensity)

ArgumentTypeDescription
driftstrDrift expression (μ). E.g. "mu" or "mu - 0.5 * h"
diffusionstrDiffusion expression (σ). E.g. "sigma" or "sqrt(h)"
jump_intensitystrJump rate (λ). E.g. "lambda"
jump_sizestrJump magnitude. E.g. "normal(mu_j, sigma_j)"
state_varsdictExtra state variables with initial values. E.g. {"h": 1e-4}
state_updatedictUpdate expressions for state vars after each step
paramsdictModel parameters (name → value)

Available identifiers in expressions: any key in params, any key in state_vars, S (price), ret (last log-return), dt, t, step.

Available functions: sqrt, abs, log, exp, floor, max, min, pow, normal(mu, sigma), uniform(lo, hi), randn(), if(cond, then, else).

Operators: + - * / **, > < >= <= ==, && || !, ternary cond ? a : b.

python
# Preset, GBM with 10M paths on GPU (Researcher; past 1,000 paths needs Pro)
result = mbt.run_stochastic(
    "gbm", s0=100, n_paths=10_000_000, n_steps=252, dt=1/252,
    params={"mu": 0.05, "sigma": 0.2}, seed=42, device="cuda",
)
print(result["final_price"]["mean"])  # ~105.12

# Custom GARCH(1,1) Jump Diffusion
model = mbt.StochasticModel(
    drift="mu",
    diffusion="sqrt(h)",
    jump_intensity="lambda",
    jump_size="normal(mu_j, sigma_j)",
    state_vars={"h": 1e-4},
    state_update={"h": "omega + alpha * (ret - mu) ** 2 + beta * h"},
    params={"mu": 0.08, "omega": 1e-6, "alpha": 0.1, "beta": 0.85,
            "lambda": 5.0, "mu_j": -0.02, "sigma_j": 0.04},
)
result = mbt.run_stochastic(model, s0=100, n_paths=10_000_000, device="cuda")

# Custom mean-reverting model
mean_rev = mbt.StochasticModel(
    drift="kappa * (log(100.0) - log(S))",
    diffusion="sigma",
    params={"kappa": 2.0, "sigma": 0.25},
)
result = mbt.run_stochastic(mean_rev, s0=80, n_paths=1000, store_paths=True)
mbt.plot.stochastic_paths(result, show=True)

Configuration

BacktestConfig

FieldTypeDescription
universeList[int|str] | Dict[str, List[str]]Symbol IDs, ticker names, or dict mapping provider to symbols (e.g. {"binance": ["BTC-USDT:perp"]}). The dict form is recommended: it auto-resolves symbol_names and is required for cross-exchange. All three forms work with run(), run_sweep(), and the diagnostics helpers
time_range_startint (ns)Start, in nanoseconds since the Unix epoch (UTC), not milliseconds. Use time_range("2021-01-01", "2026-01-01"), which returns both bounds
time_range_endint (ns)End, in nanoseconds, like the start
bar_intervaldictBar resolution. Use Interval.minutes(1), Interval.hours(12), etc. Community stops at Interval.minutes(1), Pro goes down to Interval.seconds(1), Researcher adds the trade clock (Interval.trades()) and, for a strategy that quotes, a wake-up clock in milliseconds
initial_capitalfloatStarting capital (default 1000.0)
warmup_barsintBars to skip before trading (let indicators stabilize)
accuracyboolWhen True, simulation runs on the finest bars the store holds, 1s or 1m (hybrid mode)
output_resolutiondictDownsample output timeseries. Community: daily. Pro: down to 1 second
trading_days_per_yearfloatAnnualization factor (365.25 crypto, 252 equities)
risk_free_ratefloatAnnual risk-free rate for Sharpe/Sortino (excess return). Default 0.0 = raw Sharpe (matches most reporting and other engines). Set e.g. 0.025 for an excess-return Sharpe
symbol_namesdictName-to-ID mapping for cross-asset references
currencystrQuote currency for the portfolio (default "USD")
executionExecutionConfigExecution settings (signal delay, fill model, sizing mode, etc.)
feesFeeConfigFee model. Use FeeConfig.binance_perps() or construct manually
slippageAnySlippage model. Use Slippage.fixed_bps(), .volume_impact(), etc.
exo_dataList[str]Exogenous data series names to inject (e.g. ["hashrate", "fear_greed"]). See Exogenous Data
extra_timeframesDict[str, dict]Additional timeframes for multi-TF strategies (e.g. {"4h": Interval.hours(4)})
rng_seedOptional[int]Random seed for reproducible Monte Carlo and stochastic fills
resample_toOptional[dict]Resample loaded bars before simulation (e.g. Interval.hours(4))
book_levelsOptional[int]Levels a side a run reads from a stored book: every stored level when left out. Bounds the book columns, the queue, the fill marks and the book a quote reads. See Order Book Columns
liquidationOptional[Liquidation]Liquidate a leveraged position at the maintenance margin, during the run. Off when left out. See Liquidation

Interval Helpers

python
Interval.seconds(1)    # {"Seconds": 1}
Interval.minutes(1)    # {"Minutes": 1}
Interval.hours(12)     # {"Hours": 12}
Interval.days(1)       # {"Days": 1}
Interval.millis(100)   # {"Millis": 100}, the wake-up clock of a quoting strategy
Interval.trades()      # "Trades", the trade clock: one row per print (Researcher)

FeeConfig

FeeConfig(
    maker_fee_bps: float = 0.0,
    taker_fee_bps: float = 0.0,
    funding_rate_column: Optional[str] = None,
    borrow_rate_annual_bps: float = 0.0,
    min_fee: float = 0.0,
    default_fill_type: str = "Taker",
)
FieldTypeDefaultDescription
maker_fee_bpsfloat0.0Maker fee in basis points
taker_fee_bpsfloat0.0Taker fee in basis points
funding_rate_columnstr | NoneNoneColumn name for funding rate (perps only). A NaN in it counts as no funding on that bar, and the run warns with their count
borrow_rate_annual_bpsfloat0.0Annual borrow rate in bps (for shorts)
min_feefloat0.0Minimum fee per trade
default_fill_typestr"Taker""Maker" or "Taker" (conservative default)

FeeConfig Presets

PresetMakerTakerFunding
FeeConfig.binance_perps()2 bps5 bpsfunding_rate column
FeeConfig.binance_spot()10 bps10 bpsNone
FeeConfig.zero()00None

Slippage Models

Slippage models simulate the cost of market impact when executing trades. The engine applies slippage after the fill price is determined, adjusting the effective price against the trader.

FixedBps -- Constant Basis Points

Slippage.fixed_bps(bps: float) -> dict

Applies a fixed cost in basis points (1 bps = 0.01%) on every fill. The effective fill price shifts by bps / 10000 * price against the trade direction (up for buys, down for sells).

ArgumentTypeDescription
bpsfloatSlippage in basis points per trade. 1.0 = 0.01%, 5.0 = 0.05%

Formula: effective_price = fill_price * (1 + bps/10000) for buys, * (1 - bps/10000) for sells.

Use case: Quick research on liquid markets (crypto majors, large-cap equities). Simple and predictable.

VolumeImpact -- Market Impact Model

Slippage.volume_impact(impact_coeff: float, exponent: float = 1.5) -> dict

Models market impact as a function of participation rate (order size relative to bar volume). Larger orders relative to available liquidity incur more slippage. Based on the square-root market impact model.

ArgumentTypeDefaultDescription
impact_coefffloat--Impact coefficient. Higher = more slippage. Typical: 0.05-0.3
exponentfloat1.5Power law exponent. 0.5 = square-root (Almgren), 1.0 = linear, 1.5 = super-linear

Formula: slippage = impact_coeff * (order_qty / bar_volume) ^ exponent

Use case: Capacity analysis, illiquid assets, large position sizes. Essential for strategies trading more than 1-5% of bar volume.

SpreadBased -- Bid-Ask Spread

Slippage.spread_based(spread_fraction: float = 1.0) -> dict

Uses the actual bid-ask spread from bar data. Slippage = fraction of the spread. Requires bid and ask columns in the data.

ArgumentTypeDefaultDescription
spread_fractionfloat1.0Fraction of half-spread to apply. 1.0 = full half-spread, 0.5 = half

Formula: slippage = spread_fraction * (ask - bid) / 2

Use case: When bid/ask spread data is available. Most realistic for market orders on venues with order book data.

No Slippage

Slippage.none() -> dict

Zero slippage. Use only for debugging or when slippage is already embedded in the fill price model.

python
# Liquid crypto (BTC, ETH) -- 1-3 bps
Slippage.fixed_bps(2.0)

# Mid-cap altcoins -- volume impact matters
Slippage.volume_impact(0.1, exponent=0.5)

# With order book data -- most realistic
Slippage.spread_based(spread_fraction=1.0)

# Development only
Slippage.none()

ExecutionConfig

ExecutionConfig(
    signal_delay: int = 0,
    execution_price: str = "AtClose",
    max_position_pct: float = 1.0,
    allow_short: bool = True,
    allow_fractional: bool = True,
    skip_gap_bars: bool = False,
    position_sizing_mode: str = "FractionOfEquity",
    pyramiding: bool = False,
    fill_model: Optional[dict] = None,
    fill_resolution: Optional[str] = None,
    fill_marks: bool = False,
    latency: Optional[dict] = None,
    orders: Optional[OrderConfig] = None,
    tick_size: Optional[float] = None,
)
FieldTypeDefaultDescription
signal_delayint0Bars between the signal bar and the execution bar. 0 is accepted where it is sound: AtClose, NextBarClose, or custom on a signal, which decide and fill on the same close. With any other execution price (AtOpen, AtVwap, MidPrice, NextBarOpen, NextBarVwap, custom on a bar column) a delay of 0 would fill at a price that precedes the close that produced the signal, and since 0.27 the run raises a ConfigError: use 1. A resting entry order (.limit_entry(), .stop_entry(), .market_if_touched(), .stop_limit_entry()) needs 1 whatever the execution price, AtClose included: at 0 its level would come from the very bar whose high and low it is tested against, and the run is refused
execution_pricestr"AtClose"Fill price: AtClose, AtOpen, AtVwap, NextBarOpen, NextBarClose, NextBarVwap, MidPrice
max_position_pctfloat1.0Maximum position as fraction of equity
allow_shortboolTrueAllow negative positions
allow_fractionalboolTrueAllow fractional units
skip_gap_barsboolFalseSkip bars with gaps in data
position_sizing_modestr"FractionOfEquity"FractionOfEquity, FractionOfInitialCapital, or Units
pyramidingboolFalseWhen True, signal is a delta to ADD to current position each bar
fill_modeldict | NoneNoneFill model config. Use FillModel.participation(0.1); passive_fill ("touch" or "traverse"), and on the tick layer queue, adverse and custom (see Queue Model)
fill_resolutionstr | NoneNone"bar" (the bar's high and low) or "ticks": level orders settled on the prints inside each bar. Needs a stored tape. See Fills on the Tape
fill_marksboolFalseMark every fill at +100 ms, +1 s and +10 s into result.fill_marks. Needs the tape. See Fill Marks
latencydict | NoneNone{"order": ..., "cancel": ...}, plus "response" and "feed" for a strategy that quotes. Needs the tape. See Latency
ordersOrderConfig | NoneNoneOrder management: SL, TP, trailing stops
tick_sizefloat | NoneNoneThe venue's tick, e.g. 0.1 for BTCUSDT: every level an order rests at is put on that grid before it is posted, a bid down and an ask up. Aggressive legs are not touched

ExecutionPrice Constants

ConstantValueDescription
ExecutionPrice.AT_CLOSE"AtClose"Fill at current bar's close
ExecutionPrice.AT_OPEN"AtOpen"Fill at current bar's open
ExecutionPrice.AT_VWAP"AtVwap"Fill at current bar's VWAP
ExecutionPrice.NEXT_BAR_OPEN"NextBarOpen"Fill at next bar's open
ExecutionPrice.NEXT_BAR_CLOSE"NextBarClose"Fill at next bar's close
ExecutionPrice.NEXT_BAR_VWAP"NextBarVwap"Fill at next bar's VWAP
ExecutionPrice.MID_PRICE"MidPrice"Fill at mid-price
ExecutionPrice.custom(name){"Custom": name}Fill at a named bar column (vwap, bid, ...) or at a signal the strategy defines. On a bar column it needs signal_delay=1

FillModel Helpers

FillModel.atomic()                          # entire order at single price (default)
FillModel.participation(rate=0.1)           # fill max 10% of bar volume
FillModel.participation(0.1, "TypicalPrice") # intra-bar price: SinglePoint, TypicalPrice, OhlcAverage

Liquidation

Without it, a leveraged position survives every wick that would have closed it on the venue, and the equity curve shows a recovery the account never traded. With cfg.liquidation set, the account is cross-margined: one balance carries every position, and the whole book is closed the moment equity falls to the maintenance margin, rate × |position| × price summed over the symbols held. Since 0.29.0, opt-in: a config without the field runs exactly as before.

python
import manifoldbt as mbt

cfg.execution.max_position_pct = 3.0            # 3x
cfg.liquidation = mbt.Liquidation(
    maintenance_margin_rate=0.005,                # 0.5 % of notional
    per_symbol={"DOGEUSDT": 0.01},               # optional overrides, by name or id
    fee_bps=50,                                   # liquidation fee
)
FieldTypeDefaultDescription
maintenance_margin_ratefloat0.005Maintenance margin as a fraction of notional, in [0, 1)
per_symbolDict[str|int, float]{}Per-symbol overrides of the rate, keyed by symbol name or integer id
fee_bpsfloat0.0Liquidation fee in basis points of the notional closed. It replaces the trading fee on a liquidation, as venues charge it

Inside the bar. The rule walks every position from the bar's open toward its adverse extreme (the low for a long, the high for a short) and closes the book where equity meets the margin: on one symbol, at the venue's liquidation price exactly; on several, as if their adverse extremes coincided. A bar that opens past that point fills at the open. A stop placed before the liquidation price fires first; one placed beyond it never does. A gap through the bankruptcy price leaves the account at zero rather than in debt, as a venue's insurance fund does.

What a run reports. A 3x long from 100 on 1,000 of capital, through one bar that wicks to 60: without liquidation the position survives and the run ends at 850; with Liquidation(maintenance_margin_rate=0.005) it is closed at 67.00, the price where equity meets the margin. The liquidation is a row of the trade log with exit_reason == 7, and result.warnings says how many positions were liquidated, and whether a gap past the bankruptcy price was absorbed.

Orders

Attach stop-loss, take-profit, and trailing stop orders via the fluent builder:

python
strategy = (
    mbt.Strategy.create("with_stops")
    .signal("z", zscore)
    .size(signal)
    .stop_loss(pct=3.0)          # exit if 3% below entry
    .take_profit(pct=5.0)       # exit if 5% above entry
    .trailing_stop(pct=2.0)     # trail 2% from peak
)

One side only

Each of the three takes side=: "both" (the default), "long" or "short". An order armed on one side leaves the other bare, exactly as if it were not set, so a strategy that trades both directions can stop its shorts and let its longs run.

python
strategy = (
    mbt.Strategy.create("long_and_short")
    .signal("trend", ema(close, 10) > ema(close, 40))
    .size(mbt.when(mbt.col("trend"), 1.0, -1.0))
    .stop_loss(pct=2.0, side="short")   # shorts are stopped, longs are not
    .take_profit(pct=5.0)                # both sides, as before
)

The side holds wherever the strategy runs: run(), the fast kernel and the GPU give the same fills. A distance swept from a grid (stop_loss as a sweep parameter) replaces the distance and keeps the side the strategy set.

Alternatively, use OrderConfig directly on the execution config:

python
from manifoldbt.config import OrderConfig

execution = mbt.ExecutionConfig(
    orders=OrderConfig.bracket(stop_pct=3.0, profit_pct=5.0),
)

A distance that comes from a signal

pct=2.0 is one number for the whole run. signal="name" reads the distance from a series the strategy computes, so a stop can be two ATR wide on a quiet day and twice that on a violent one. The series holds a percentage of the price, the same unit pct uses, and the two are exclusive. All three orders take it: stop_loss, take_profit and trailing_stop.

python
from manifoldbt.indicators import atr, close, ema

# 2 ATR, expressed as a percentage of the price
stop_dist = mbt.lit(2.0) * atr(14) / close * mbt.lit(100.0)

strategy = (
    mbt.Strategy.create("atr_stop")
    .signal("stop_dist", stop_dist)          # named, so the order can reference it
    .size(mbt.when(ema(close, 12) > ema(close, 50), 1.0, 0.0))
    .stop_loss(signal="stop_dist")
)

Three rules make that a well-defined order rather than a moving target.

The distance is read on the signal bar. The bar whose signal decided the entry, the same row limit_entry(signal=...) reads its level at, so there is no look-ahead: the distance is known before the fill happens.

It is frozen for the life of the trade. The level is computed once, from the price the entry actually filled at, slippage included, exactly as pct does it, and the trade keeps it until it closes. A trailing stop freezes its trail distance the same way and then ratchets normally.

A distance the series cannot give arms nothing, loudly. If the series holds NaN, zero or a negative number on the signal bar, no bracket is armed on that trade at all, result.brackets_not_armed counts it, and result.warnings names the order. A position you believe is protected and is not is the worst failure this family has, so the engine refuses to invent a level.

python
result = mbt.run(strategy, config, store)
print(result.brackets_not_armed)   # 0 when every trade got its bracket

A warm-up is the usual cause: atr(14) is NaN for its first 13 bars. Set warmup_bars on the config, or gate the sizing on the indicator being ready. A param() inside the series makes the distance sweepable like any other expression, with nothing else to declare.

Conditional Entries

By default an entry takes the signal bar's close. These builders let it wait for a price instead. The level can be a distance from the signal close, a fixed price, or any series the strategy computes. A resting entry needs ExecutionConfig(signal_delay=1) or more: with the default 0 the run is refused (see signal_delay under Configuration).

python
strategy = (
    mbt.Strategy.create("pullback")
    .signal("entry_level", sma * 0.99)
    .size(signal)
    .limit_entry(signal="entry_level")   # rest at the level the strategy computed
)

# the level, three ways (pass exactly one)
.limit_entry(offset_bps=25)              # 25 bps more passive than the signal close
.stop_entry(price=60000)               # a fixed level
.market_if_touched(signal="band")     # any series the DSL produces
.stop_limit_entry(stop=60000, limit=59950)
BuilderBehaviour
.limit_entry()Rests passively and waits for a pullback. Maker fees, no slippage, lands on the level exactly. May never fill.
.stop_entry()Fires on a breakout. Taker fees, and a bar that gaps through the level fills at the open.
.market_if_touched()Waits for the level to trade, then crosses the spread.
.stop_limit_entry()The stop arms the order, which then rests at limit and fills there with maker fees. Each level takes a number or a signal name (stop_signal, limit_signal).

time_in_force accepts "GTC" (default, rests until filled or the signal changes), {"GTB": 5} to expire after 5 bars, or "IOC" (fill on the arrival bar or cancel). Pass size_at_fill_price=True to size off the order's level rather than the close.

python
strategy = (
    mbt.Strategy.create("requote")
    .signal("entry_px", close - atr(14))
    .size(mbt.when(ema(close, 12) > ema(close, 50), 1.0, 0.0))
    .limit_entry(signal="entry_px", time_in_force={"GTB": 1})
    .stop_loss(pct=3.0)
)

# config built with execution=mbt.ExecutionConfig(signal_delay=1, ...): a resting entry needs it
result = mbt.run(strategy, config, store)
for w in result.warnings:
    print(w)   # "N entry order(s) expired unfilled and M were still resting ..."

Dataset Auto-Resolution

The store holds bars at four resolutions: 1s, 1m, 1h and 1d. For a given bar_interval, the engine loads the coarsest of them that is not coarser than the interval and has data, and resamples it up to the interval:

bar_intervalDataset loadedNotes
< 1mbars_1s or provider/1s/Pro (Community stops at 1m)
1m to < 1h (5m, 15m, ...)bars_1m or provider/1m/Read as is at 1m, resampled above
1h to < 1d (4h, 12h, ...)bars_1h or provider/1h/Read as is at 1h, resampled above
1d and abovebars_1d or provider/1d/Read as is at 1d, resampled above

A resolution with no data is skipped for the next finer one: 4h bars on a store that holds only 1-minute bars are resampled from those. The engine tries the provider layout first (binance/1h/BTCUSDT.arrow), then falls back to legacy (bars_1h/201.arrow).

Accuracy Mode

When accuracy=True, the engine loads the finest bars the store holds: 1-second bars when it has them, 1-minute bars otherwise. Signals are evaluated at bar_interval resolution, but the simulation loop runs on those finer bars for precise intrabar SL/TP fills and drawdown tracking.

python
config = mbt.BacktestConfig(
    bar_interval=Interval.hours(12),
    accuracy=True,  # hybrid: signals on 12h, sim on the finest stored bars
    ...
)

Prop-Firm Rules

A prop firm does not judge a finished equity curve: an account that breaches a limit is closed. So the rule runs inside the simulation. The moment the account breaches, it liquidates, cancels its orders and stops trading, and its equity stays flat to the end of the data — because that is what an account the firm took away is worth.

This is the whole difference. A verdict applied afterwards gets the first breach right and everything after it wrong: on a series that dips below the floor and then recovers well above the start, a post-hoc reading sees the recovery. A closed account never traded it.

python
config.account_rules = mbt.AccountRules(
    phases=[
        mbt.AccountPhase(name="Challenge",    profit_target=0.10,
                         max_daily_loss=0.05, max_total_loss=0.10,
                         min_trading_days=4),
        mbt.AccountPhase(name="Verification", profit_target=0.05,
                         max_daily_loss=0.05, max_total_loss=0.10,
                         min_trading_days=4),
    ],
    sessions=mbt.account_sessions(start, end, tz="Europe/Prague"),
    consistency=0.50,
)

result = mbt.run(strategy, config, store)

if result.account is None:
    print("account still alive at the end of the data")
else:
    a = result.account
    print(f"{a['outcome']} in {a['phase']} on day {a['trading_days']}")
    print(a["reason"])
# FAILED_MAX_LOSS in Challenge on day 15
# overall loss floor 90000.00 breached (equity 89984.55)

Phase limits

Every limit is a fraction of the capital that phase starts with.

FieldMeaning
profit_targetJudged at a session close, never on an intraday touch. None = surviving is enough.
max_daily_lossFloor at session_open - X%, reset every session. None = the firm sets no daily limit, which a whole family of futures evaluations does.
max_total_lossThe floor that closes the account. Always present.
drawdown_type"Static", "TrailingEod" (the floor follows the close) or "TrailingEquity" (it follows the peak equity mark by mark inside the session).
trailing_lock_atLevel at which a trailing floor freezes for good. 0.0 = at the balance the phase started with.
min_trading_daysA trading day is a session that opened a position. Holding does not count, and neither does closing.
max_calendar_daysNone = unlimited, which is what the mainstream programs offer.

The two trailing drawdowns are not the same rule

Both are sold as “your drawdown follows your profit”. TrailingEod moves the floor only at the close, so profit made and handed back inside one session costs nothing. TrailingEquity follows the peak equity as it happens, unrealised profit included: make +4k at 10:30, give it back by 11:00, and the floor has already moved up. An account can be closed on a day that ends in profit.

Sessions, not a time zone

A firm's day is not UTC midnight. An FX firm resets at 00:00 CE(S)T; a futures evaluation follows the exchange session, which opens at 17:00 in Chicago. Naming a zone and an hour would cover those two and nothing else, because real calendars also have holidays and early closes.

So the boundary travels as the list of instants each session begins at, and account_sessions builds it with zoneinfo from the standard library. Windows Python ships no time-zone database, so pip install tzdata there first, even for the default tz="UTC". A holiday is simply a start that is not in the list, and the session then runs to the next one.

python
# daily sessions on the firm's clock; DST is handled by zoneinfo
sessions = mbt.account_sessions(start, end, tz="Europe/Prague")

# a futures evaluation: the CME session opens at 17:00 local
sessions = mbt.account_sessions(start, end, tz="America/Chicago", hour=17)

Phases start on a fresh account

A 2-Step is two phases, and the firm hands out a new account between them. A trader who finishes the Challenge at +10% does not begin the Verification already past its +5% target: each phase measures its limits from the equity it started at. Reading the running curve instead turns a 2-Step into a 1-Step and inflates every pass rate computed from it.

Consistency blocks the payout, it does not close the account

consistency=0.50 caps the share of the winning sessions' profit a single session may hold. Breaking it is not a breach: the account stays alive and can still pass. What it loses is the payout, so the verdict reports it as consistency_ok alongside the outcome.

Reading the result

result.account is None while the account is alive, otherwise a dict with outcome (PASSED, FAILED_DAILY_LOSS, FAILED_MAX_LOSS, RAN_OUT_OF_TIME), phase, phase_index, consistency_ok, timestamp, equity, floor, trading_days and a plain-English reason.

Three things worth knowing before you read anything else:

  • equity and floor are on the phase's own balance, which restarts at the initial capital for every phase, while equity_df() keeps compounding. On a two-phase program the two numbers are deliberately different.
  • A closed account stops trading, so metrics is computed over a mostly flat curve and can read as a success on an account the firm took away. The engine says so in warnings. Read account first.
  • RAN_OUT_OF_TIME only appears when a phase sets max_calendar_days. Without one, an account that never reaches its target simply stays open and account is None — which means “still alive”, not “failed”.

Where the rule applies

Account rules run on the full simulation loop and on the lite path used by sweeps, so a sweep gives one outcome per combination and a pass rate is computable. The fast kernels and the GPU sweep do not apply them and refuse the configuration by name rather than running it as if the rule were not there.

One limit worth stating: the account can only close at a bar boundary, so a coarse bar_interval gives a coarse verdict. Finer bars make it finer. The firm's own liquidation costs are not modelled.

Exogenous Data

Inject external data series (hashrate, funding rates, sentiment, on-chain metrics) into your strategy expressions. The engine ASOF-joins exogenous data onto bar timestamps at the requested resolution: a value holds until the next one, so the last value stamped before the run is in force from its first bar, and nothing stamped after a bar is read on it.

Register

Use mbt.register_exo() to write a DataFrame to the local store. Requires a timestamp column and one or more value columns.

python
import pandas as pd

df = pd.DataFrame({
    "timestamp": pd.date_range("2020-01-01", "2026-01-01", freq="D", tz="UTC"),
    "hashrate": hashrate_values,  # your data
})

mbt.register_exo("hashrate", df, store=store)
# Writes to: data/mega/exo/hashrate.arrow

Declare in Config

List the exo series names in exo_data:

python
config = mbt.BacktestConfig(
    exo_data=["hashrate", "fear_greed"],
    ...
)

Use in Expressions

Access exo columns with the exo() helper or directly via col():

python
from manifoldbt.expr import exo, col
from manifoldbt.indicators import ema, close

# exo() helper (shorthand)
hr = exo("hashrate")
hr_smooth = ema(hr, 30)

# Equivalent col() syntax
hr = col("exo.hashrate.hashrate")

# Multi-column exo series
active_addr = exo("onchain", "active_addresses")

# Use in strategy: spread between price and hashrate
price_ratio = close / ema(close, 30)
hr_ratio = hr / ema(hr, 30)
spread = price_ratio - hr_ratio
spread_z = spread.zscore(90)

register_exo() Reference

ArgumentTypeDefaultDescription
namestr--Series identifier (e.g. "hashrate")
dataDataFrame | dict--Must have a timestamp column. Value columns cast to Float64
storeDataStoreNoneTarget store. If None, uses default data root
providerstrNoneProvider for unified layout (e.g. "binance"). Omit for global exo
timeframestr"1d"Timeframe label for unified layout

Cross-Asset References

Access columns from other symbols using mbt.symbol_ref() or the .of_symbol() method. This enables stat-arb, relative-value, and basket strategies.

python
# Method 1: symbol_ref()
btc_close = mbt.symbol_ref("BTCUSDT", "close")

# Method 2: .of_symbol() on any column
btc_close = mbt.col("close").of_symbol("BTCUSDT")

# Method 3: asset() helper
btc = mbt.asset("BTCUSDT")
btc_close = btc.col("close")

# Use in strategy
spread = mbt.col("close") - btc_close
strategy = (
    mbt.Strategy.create("arb")
    .signal("btc_close", btc_close)
    .signal("spread", spread)
    .size(mbt.when(spread.zscore(60) < -2.0, 1.0, 0.0))
)

Cross-Exchange Backtesting Pro

Compute signals from one exchange's data and execute trades at another exchange's prices. Spread arbitrage, cross-venue basis trades, or simply using a venue with better data quality for signal generation while trading on another.

Universe Dict Format

The simplest way to set up cross-exchange strategies is with the dict universe format. Each key is a provider name, each value is a list of normalized symbols:

python
config = mbt.BacktestConfig(
    universe={
        "dydx":    ["BTC-USD:perp"],      # execution (fills here)
        "binance": ["BTC-USDT:perp"],     # signal source (via symbol_ref)
    },
    bar_interval=Interval.hours(6),
    initial_capital=10_000,
    warmup_bars=30,
    execution=mbt.ExecutionConfig(signal_delay=1),
    fees=mbt.FeeConfig(maker_fee_bps=1.0, taker_fee_bps=2.5),
    slippage=Slippage.fixed_bps(2),
    ...
)

The engine automatically resolves each provider:symbol pair to its symbol ID and populates symbol_names. The first provider's symbols are the execution targets (fills happen at their prices).

Full Example, RSI Signal from Binance, Execution on dYdX

python
import manifoldbt as mbt
from manifoldbt.indicators import rsi, ema
from manifoldbt.expr import col, symbol_ref, lit, when
from manifoldbt.helpers import time_range, Interval, Slippage

# Signal, RSI + EMA from Binance BTC
bn_btc_close = symbol_ref("binance:BTC-USDT:perp", "close")
bn_btc_rsi   = rsi(bn_btc_close, 14)
bn_ema_fast  = ema(bn_btc_close, 15)
bn_ema_slow  = ema(bn_btc_close, 30)
trend_up     = bn_ema_fast > bn_ema_slow

# Size uses named signals (col references, not inline SymbolRef)
signal = when(
    (col("trend") > lit(0.5)) & (col("bn_rsi") > lit(70.0)), 1.0,
    when((col("trend") < lit(0.5)) & (col("bn_rsi") < lit(30.0)), -1.0,
    0.0),
)

# Strategy, register signals, then use them in size
strategy = (
    mbt.Strategy.create("cross_exchange_rsi")
    .signal("bn_rsi", bn_btc_rsi)
    .signal("trend", when(trend_up, 1.0, 0.0))
    .size(signal)
)

# Config, dict universe handles everything
start, end = time_range("2024-02-01", "2025-03-01")
config = mbt.BacktestConfig(
    universe={
        "dydx":    ["BTC-USD:perp"],
        "binance": ["BTC-USDT:perp"],
    },
    time_range_start=start, time_range_end=end,
    bar_interval=Interval.hours(6),
    initial_capital=10_000,
    warmup_bars=30,
    execution=mbt.ExecutionConfig(signal_delay=1),
    fees=mbt.FeeConfig(maker_fee_bps=1.0, taker_fee_bps=2.5),
    slippage=Slippage.fixed_bps(2),
)

result = mbt.run(strategy, config, store)

Multi-Timeframe Strategies

Combine signals from different timeframes in a single strategy. Use a slow trend filter on higher-timeframe bars while entering positions on faster bars, all in one vectorized backtest.

How It Works

The engine resamples native bars to each extra timeframe, then forward-fills the completed values back onto the native grid. A completed 1h bar becomes available at the start of the next 1h period, no lookahead bias.

python
import manifoldbt as mbt
from manifoldbt.indicators import ema, rsi, close
from manifoldbt.helpers import Interval

# 1. Reference higher timeframes
h4 = mbt.tf("4h")
d1 = mbt.tf("1d")

# 2. Build indicators on any timeframe
trend   = ema(d1.close, 20) > ema(d1.close, 50)   # daily trend
dip     = rsi(h4.close, 14) < 30.0              # 4h oversold
trigger = rsi(close, 14) < 25.0                 # native entry

# 3. Combine in strategy
strategy = (
    mbt.Strategy.create("multi_tf")
    .signal("trend", trend)
    .signal("dip", dip)
    .signal("trigger", trigger)
    .size(mbt.when(
        mbt.col("trend") & mbt.col("dip") & mbt.col("trigger"),
        0.5, 0.0
    ))
    .stop_loss(pct=3.0)
)

Config

Declare the extra timeframes in BacktestConfig. The label (e.g. "4h") must match what you pass to mbt.tf().

python
config = mbt.BacktestConfig(
    bar_interval=Interval.minutes(15),   # native resolution
    extra_timeframes={
        "4h": Interval.hours(4),
        "1d": Interval.days(1),
    },
    warmup_bars=60,                       # account for slower TF indicators
    # ... other config
)

tf() Reference

mbt.tf(label) returns a TimeframeRef with shortcuts for OHLCV columns:

PropertyEquivalent
tf("4h").opencol("4h.open")
tf("4h").highcol("4h.high")
tf("4h").lowcol("4h.low")
tf("4h").closecol("4h.close")
tf("4h").volumecol("4h.volume")
tf("4h").col("x")col("4h.x")

Tick Data Researcher

Everything above runs on bars. The tick layer adds what a bar cannot hold: every print, the order book level by level, and on Nasdaq every order. It is one layer with two doors: the store holds what the venue published, and the config says what the engine does with it. The DSL, mbt.run() and the result object stay the same.

It needs a Researcher licence. A Pro licence does not unlock it, and each ingest says so before touching the network. This page is the reference; the full semantics, with what every setting refuses and why, are in the strategy authoring guide on GitHub.

CallWhat it storesFrom
ingest_trades()The tape: one row per print, with its time, price, size and aggressor sideBybit (spot, USDT and coin-margined perpetuals), Binance spot aggTrades, Massive (the US consolidated tape), Databento, Polymarket
ingest_book()The book by price level: the first levels prices and sizes a side, at every instant one of them changedBybit (spot and perpetuals)
ingest_mbo()Every order: its entry, each execution, cancellation, deletion and replacementA Nasdaq TotalView-ITCH 5.0 file
bars_from_trades()Bars rebuilt from the tape, with buy_volume, sell_volume, vwap and trade_countThe stored tape
attach_quotes()The bid, ask and spread columns of 1-minute bars already storedBybit's book archive, Massive (NBBO), Databento (BBO)

Trades

python
# One day of BTCUSDT perpetual trades, from Bybit's public archive
store = mbt.ingest_trades("bybit", "BTCUSDT", symbol_id=1,
                          start="2025-06-02", end="2025-06-02",
                          category="linear")

category names the market: "spot" (the default, on either provider), "linear" for USDT perpetuals such as BTCUSDT, "inverse" for coin-margined ones such as BTCUSD, whose sizes are in contracts. Bybit's derivatives archive starts the day a contract listed: 2020-03-25 for BTCUSDT, 2019-10-01 for BTCUSD. start and end are YYYY-MM-DD, end inclusive, and a day the venue did not publish raises a named error. cache_dir keeps Bybit's raw daily archives for reuse.

"polymarket", free and without a key, serves the trades of one outcome, symbol="will-x-happen/Yes", stamped to the second. Both outcomes share one book, so a trade on the other one is restated in the terms of the one asked for: buying No at 0.38 is stored as selling Yes at 0.62. Polymarket serves a market's trades newest first, so reaching a day means reading everything after it: ask for the end of a market's life first.

Bybit stamps a derivative trade to a tenth of a millisecond, except from 2021-12-07 to 2022-12-20, when it stamped the whole second; spot trades are stamped to the millisecond. Prints that share a timestamp keep the order the venue matched them in, and nothing shorter than the stamp (a latency, a time_in_force, a window in time) can tell them apart.

One symbol is one market. The tape and the book of a symbol share its symbol_id and its category: a store that holds the spot tape of a symbol refuses its linear one, and a second id for the same ticker, before anything is downloaded.

Trades of Stocks and Futures

python
# Every print of the day on the US consolidated tape (MASSIVE_API_KEY)
store = mbt.ingest_trades("massive", "AAPL", symbol_id=3,
                          start="2026-10-26", end="2026-10-27")

# A CME future from Databento's trades schema (DATABENTO_API_KEY)
mbt.ingest_trades("databento", "ESZ6", symbol_id=4,
                  start="2026-10-26", end="2026-10-27",
                  dataset="GLBX.MDP3", asset_class="future")

The data vendors fill the same tape, with your own key read from the environment as for ingest(). Massive pages through every print of the UTC day and stamps it with the SIP's nanosecond; asset_class defaults to "equity", and trades come with Massive's Stocks Developer plan and above. Databento reads the trades schema of dataset, stamped by the matching engine; asset_class is required, since a dataset can hold stocks, futures or options.

The consolidated tape does not say which side was the aggressor, and Databento marks some prints N. Those are classified by the tick rule: above the previous price bought, below it sold, at the same price the side of the last move. A side the venue gives is kept. Every print the vendor serves is stored, odd lots and off-exchange prints included, and a day nobody traded (a weekend, a holiday) stores nothing without stopping the range.

The Order Book

python
# The book of the same market, same symbol_id, same category
mbt.ingest_book("bybit", "BTCUSDT", symbol_id=1,
                start="2025-06-02", end="2025-06-02",
                category="linear")              # 200 levels for a new symbol

# A day Bybit published 500 levels deep, all of it
mbt.ingest_book("bybit", "BTCUSDT", symbol_id=1,
                start="2025-08-20", end="2025-08-20",
                category="linear", levels=500)
categoryDepthHistory
"linear"500 levels up to 2025-08-20, 200 from 2025-08-21BTCUSDT from 2023-01-18
"inverse"500 levels up to 2025-08-20, 200 from 2025-08-21Sizes in contracts
"spot"200 levelsFrom the end of April 2025

levels=None keeps the depth the symbol is already stored at, 200 for a new symbol. levels=500 holds only on a range Bybit published that deep: a range with a shallower day in it is refused before anything is downloaded, and the error names the day to end it before. A day is a download of 100 to 400 MB, streamed into the store and never held whole.

Books are stored as changes by default (book_format="deltas"): a day of BTCUSDT 200 levels deep takes about 44 MB instead of 2.7 GB as full ladders, and loads in a fraction of a second at any depth. book_format="ladder" stores the whole ladder on every row. A symbol keeps the shape of its first stored day, and every reader sees the same states either way.

Bars Rebuilt from the Tape

python
# One-second bars rebuilt from the stored tape (end is exclusive here)
mbt.bars_from_trades(store, "BTCUSDT", "2025-06-02", "2025-06-03", interval="1s")

flow = mbt.col("buy_volume") - mbt.col("sell_volume")

A venue's own bars carry one total volume. Bars rebuilt from the tape split it by the aggressor side, and add vwap and trade_count; the DSL reads them by name. They are ordinary bars for everything downstream: run() reads them at interval or resamples them coarser. Seconds with no trade produce no bar, and one second is the finest step the store holds. attach_quotes() fills 1-minute bars only: to put quotes on a tape, rebuild it at interval="1m" as well.

Real Quotes on Bars

python
store = mbt.ingest(provider="bybit", symbol="BTCUSDT", symbol_id=1,
                   start="2026-07-01T00:00:00Z", end="2026-08-01T00:00:00Z")
mbt.attach_quotes(store, 1, "2026-07-01", "2026-07-31")
# The bars now carry real bid, ask and spread: MidPrice execution and
# Slippage.spread_based() read actual quotes instead of nulls.

The bar schema has always had bid, ask and spread columns; attach_quotes() fills them on the symbol's 1-minute bars, and only on those, with the best bid and ask standing at each bar close, streamed from Bybit's book archive. Only those few kilobytes reach the store. On another venue's bars, Bybit's spread is a reasonable proxy for the majors and an approximation for thin altcoins.

python
# A stock's NBBO on its own bars
store = mbt.ingest(provider="massive", symbol="AAPL", symbol_id=3,
                   start="2026-10-01T00:00:00Z", end="2026-10-28T00:00:00Z",
                   asset_class="equity")
mbt.attach_quotes(store, "AAPL", "2026-10-01", "2026-10-27", provider="massive")

# The NBBO on a tape: its 1-minute bars first, then the quotes
tape = mbt.ingest_trades("massive", "AAPL", symbol_id=3,
                         start="2026-10-26", end="2026-10-27")
mbt.bars_from_trades(tape, "AAPL", "2026-10-26", "2026-10-28", interval="1m")
mbt.attach_quotes(tape, "AAPL", "2026-10-26", "2026-10-27", provider="massive")

# A future's BBO on its Databento bars
es = mbt.ingest(provider="databento", symbol="ESZ6", symbol_id=4,
                start="2026-10-01T00:00:00Z", end="2026-10-28T00:00:00Z",
                dataset="GLBX.MDP3", asset_class="future")
mbt.attach_quotes(es, "ESZ6", "2026-10-01", "2026-10-27",
                  provider="databento", dataset="GLBX.MDP3")

With provider="massive" the quotes are the consolidated national best bid and offer, every update of the day paged through and reduced as it comes; a liquid stock quotes tens of millions of times a day, so a day is many requests, and quotes come with Massive's Stocks Advanced plan and above. With provider="databento" they are the venue's best bid and offer at each minute close. Either way the quote standing at a bar close is carried across bars with no update, and a quote with a side missing or crossed leaves the bars it stands over without one rather than with a stale one. provider_symbol defaults to the store ticker as is.

Order by Order (L3)

python
# A Nasdaq TotalView-ITCH 5.0 day, read in one pass for every symbol asked
store = mbt.ingest_mbo("S120925-v50.txt.gz", {"AAPL": 1, "QQQ": 2},
                       date="2025-12-09")

Where ingest_book() stores how much rests at each price, ingest_mbo() stores every order. The file is a Nasdaq TotalView-ITCH 5.0 day, either the .gz Nasdaq publishes or a stream of its messages (plain or ZSTD). symbols maps each ticker, as ITCH names it, to its id, and date is the session's date in New York. Each day is checked as it is written: an event about an order that is not in the book, or one taking out more than is left of it, refuses the day. A market loaded from these days serves a resting order in its exact place in the queue (see Queue Model).

A Book Stored by an Earlier Version

python
mbt.convert_book_to_deltas("BTCUSDT", data_root="data",
                           metadata_db="metadata/metadata.sqlite")

Versions before 0.27 stored books as ladders. 0.27 still reads them; convert_book_to_deltas() rewrites one as changes, day by day, and replaces a day only once its changes replay into exactly the stored ladders. An interrupted conversion is finished by calling it again. A converted book is not read by earlier versions.

Order Book Columns Researcher

A stored book is readable by the strategy itself, like any bar column, on bars as well as on the trade clock. mbt.book builds the column names:

python
imb = mbt.book.imbalance(5)                  # col("book_imbalance_5")
thin_ask = mbt.book.ask_depth(3) < 2.0       # col("ask_depth_3") < 2.0

strategy = (
    mbt.Strategy.create("imbalance")
    .signal("long", mbt.when((imb > 0.3) & thin_ask, 1.0, 0.0))
    .size(mbt.col("long"))
)
FunctionColumnReads
book.bid_price(k)bid_price_<k>The price of bid level k, 1 = best; ask_price(k) for the asks
book.bid_size(k)bid_size_<k>The size resting at bid level k; ask_size(k) for the asks
book.bid_depth(k)bid_depth_<k>The size resting over bid levels 1 to k; ask_depth(k) for the asks
book.imbalance(k)book_imbalance_<k>(bid_depth - ask_depth) / (bid_depth + ask_depth) over k levels, in [-1, 1]

Nothing in the config names them. The engine sees which columns the strategy reads and loads the book once, only as deep as the deepest k asked; a strategy that names none loads no book. A store with no book for a symbol, or one shallower than the level asked, is refused by name. BacktestConfig.book_levels bounds how deep a run reads, for these columns and for everything else that reads the book.

When a Row Reads the Book

  • On bars, at the bar's close: the ladder standing one nanosecond before the next bar opens, so the book and the close describe the same instant.
  • On the trade clock, at the print itself, a book update and a trade sharing a millisecond ordered book first.
  • In a strategy that quotes, at each wake-up, as the strategy sees it: execution.latency["feed"] earlier.

A value is null where the book says nothing: before its first update, on a day with no stored book, at a level that does not exist at that instant, and for the imbalance of an empty book. bid_depth and ask_depth are zero on an empty side. A null flows through an expression like a NaN close: a null at the top of the sizing holds the position.

Fills on the Tape Researcher

Two settings put the stored tape to work. Turn them on one at a time and keep the run before as the reference: each one moves the numbers.

Ticks Inside a Bar

python
config = mbt.BacktestConfig(
    universe=[1],
    time_range_start=start, time_range_end=end,
    bar_interval=Interval.minutes(1),
    execution=mbt.ExecutionConfig(signal_delay=1, fill_resolution="ticks"),
)
result = mbt.run(strategy, config, store)
result.tape_resolution      # how many bars the prints decided

fill_resolution="ticks" keeps the bar grid and settles every level order (stop-loss, take-profit, trailing stop, limit and stop entries) on the prints inside the bar, in the order they printed, instead of on the bar's high and low. When a stop and a target sit in the same bar, the tape says which came first. A bar with no trade in its window falls back to the bar rule; result.tape_resolution counts both cases. It can also be set as fill_model={"fill_resolution": "ticks"}.

The Trade Clock

python
config = mbt.BacktestConfig(
    universe=[1],
    time_range_start=start, time_range_end=end,
    bar_interval=Interval.trades(),           # one simulation row per print
    execution=mbt.ExecutionConfig(signal_delay=1),
)

Interval.trades() replaces the time grid with the tape: one simulation row per print, in the order the venue published them.

ColumnOn the trade clock
timestampThe trade's exchange time, in nanoseconds
closeThe trade's price, as are open, high, low and vwap
volumeThe trade's quantity
side+1.0 when the aggressor bought, -1.0 when it sold
buy_volumeThe quantity when the aggressor bought, 0.0 otherwise; sell_volume the other way round
bidThe best bid of the stored book as of the print, null when no book is stored; ask and spread the same way
depth_at_best_bidThe size resting at the best bid, same rule; depth_at_best_ask for the ask

Events, not bars. Every count the engine expresses in bars counts prints on this clock: signal_delay, warmup_bars, time_in_force={"GTB": n} and any window given as an integer. A window given as a duration still counts time: rolling_sum(Interval.seconds(34)) reads thirty-four seconds of prints, rolling_sum(34) thirty-four prints. When you mean time, write time, including time_in_force=Interval.seconds(1).

How an order fills. A market order decided on print i fills at the price of print i + signal_delay. A maker level (a limit entry, a take-profit) obeys passive_fill, or the queue when fill_model["queue"] is set. An aggressive level (a stop-loss, a trailing stop, a stop entry) fills on the first print at or beyond it, at that print's price: a stop at 98 whose first print below it is 96.5 fills at 96.5. When one print hits both the stop and the take-profit, the stop wins, as on bars.

What comes out. A liquid perpetual prints two to three million events a day, so output_resolution defaults to one second on this clock. output_resolution=Interval.trades() returns one row per event, with the memory that takes. Size a study in days, not months.

Queue, Latency & Marks Researcher

A resting order is not filled because the market reached its price. It is filled because everything resting ahead of it at that price went first. fill_model["queue"] replaces the price convention (passive_fill) with that rule, for the orders that really rest: a limit_entry, an armed stop-limit and a take_profit.

The Queue

python
config.execution.fill_model = {
    "fill_resolution": "ticks",      # a queue is served print by print
    "queue": {
        "model": "risk_adverse",     # the default; or "power" (with "n") or "log"
        "depth_source": "book",      # the depth stored at the order's level
        "assumed_queue": 2.0,        # the fallback, in MULTIPLES OF THE ORDER SIZE
        "cancel_ahead_rate": 0.0,    # share of the queue ahead cancelling per second
    },
}
KeyValuesWhat it does
model"risk_adverse" (default), "power", "log", "fifo"How the volume ahead moves when the level shrinks with no print. risk_adverse puts every unseen cancellation behind the order; power (with n) and log take a share of it from in front, by a probability. fifo is the exact queue of a market stored order by order
na number above 0The exponent of power
depth_source"book" (default), "assumed"Join behind the depth stored at the order's own level at the instant it is posted, or behind assumed_queue times its own size
assumed_queuea multiple of the order sizeThe queue under "assumed", and the declared fallback under "book" when the book cannot answer. Without it, such an instant is refused, never read as an empty queue
cancel_ahead_ratea fraction per secondRetires that share of the queue ahead, as exp(-rate * dt). 0, the default, assumes nobody ahead ever cancels

A print at the level from the side that consumes the order burns the volume ahead first, and only the rest fills the order: partial fills are the normal case. A print strictly through the level, or the best opposite price crossing it, fills the rest of the order whatever the queue. Requoting is cancelling and posting again, so a replacement starts at the back of a fresh queue. A level off the venue's tick grid finds no depth in the book: set execution.tick_size or snap the level with .floor_to() / .ceil_to().

The queue needs the prints: fill_resolution="ticks", or the trade clock. result.fill_fragility["queue"] says how each fill was served: queue_decided_fills, traverse_fills, partial_fills, book_unknown_at_post and fills_from_book_cross, beside would_fill_touch and would_fill_traverse, the fills the two price conventions would have given the same orders.

The three book-by-price models reproduce hftbacktest's RiskAdverseQueueModel, PowerProbQueueModel and LogProbQueueModel, and agree with it on 99.955% to 100% of quotes on the same BTCUSDT days: see Manifold-BT vs hftbacktest. On a market stored by ingest_mbo(), the order-level simulation (Strategy.quote, mbt.sim) serves the exact queue by default; a run on bars or on the trade clock refuses "fifo" by name.

Latency

python
config.execution.latency = {
    "order": Interval.millis(20),    # a decision reaches the market
    "cancel": Interval.millis(20),   # a cancellation takes effect
}

order: a decision taken on event t reaches the market at t + order, which is where the order joins the queue and where a market order fills, at the first print at or after it. cancel: a cancellation takes effect at t + cancel; until then the stale quote still stands and can still be filled, and the engine books that fill. Both keys are optional and zero when left out, and a config without latency is the run it always was, byte for byte. Two more keys, response and feed, are read only by a strategy that quotes. Latency needs the tape; without one the run is refused by name.

Under latency, result.order_activity gains stale_fills, the fills taken by a quote after its cancellation was decided, and overlapping_live_orders.

Fill Marks

python
config.execution.fill_marks = True
result = mbt.run(strategy, config, store)

result.fill_marks          # per horizon: mean_bps, median_bps, stderr_bps, marked_fills
result.fill_marks_df()     # one row per fill

Fees and a fill count say what a strategy paid to trade, not what it paid to be quoted. Each fill is marked against the mid of the stored book (or the last print when no book answers) at the fill and at +100 ms, +1 s and +10 s, in basis points of the fill price, signed in the direction the fill takes the position: negative is adverse selection. The anchor is the close of the bar the fill was booked on, the next print on the trade clock. half_spread_captured_bps and book_half_spread_bps put the result beside what a quote at the touch could have earned. It needs the tape.

Quoting Researcher

A market maker keeps orders at the venue rather than asking for a target position. In the DSL it is written as quotes: per order, three expressions saying where to stand, how much, and whether to stand at all. The engine compiles them once and runs the whole span on the order-level venue without calling back into Python, and a strategy written this way sweeps like any other.

python
import manifoldbt as mbt
from manifoldbt import book
from manifoldbt.helpers import Interval, time_range

CLIP, MAX_INVENTORY = 0.01, 0.05

inventory = mbt.position()
clips = mbt.round(inventory / CLIP)            # one level back per clip held
bids, asks = book.bid_levels(), book.ask_levels()
book_ok = (bids > 0) & (asks > 0)
bid = book.bid_price_at(mbt.clip(clips, 0, bids - 1) + 1)
ask = book.ask_price_at(mbt.clip(-clips, 0, asks - 1) + 1)

strategy = (
    mbt.Strategy.create("maker")
    .quote("buy", mbt.when(book_ok, bid), CLIP, tif="GTX",
           enabled=inventory < MAX_INVENTORY - 1e-12)
    .quote("sell", mbt.when(book_ok, ask), CLIP, tif="GTX",
           enabled=inventory > -MAX_INVENTORY + 1e-12)
)

start, end = time_range("2025-08-13", "2025-08-14")
config = mbt.BacktestConfig(
    universe=[1], time_range_start=start, time_range_end=end,
    bar_interval=Interval.millis(100),            # the wake-up clock
    initial_capital=100_000.0,
    fees=mbt.FeeConfig(maker_fee_bps=0.0, taker_fee_bps=4.0),
    execution=mbt.ExecutionConfig(
        latency={"order": Interval.millis(5), "cancel": Interval.millis(5),
                 "response": Interval.millis(5), "feed": Interval.millis(2)},
        fill_model={"queue": {"depth_source": "book", "assumed_queue": 2.0}},
    ),
)
res = mbt.run(strategy, config, store)          # a QuoteResult
res.fills_df()

The store needs the book and the tape of the symbol over the range. This is the maker of examples/31_dsl_market_maker.py in the repository, and on the same days it gives the same order journal, fill for fill, as the same maker written for mbt.sim.

Strategy.quote()

Strategy.quote(side, price, size, tif="GTX", enabled=None, cooldown=None)
ArgumentDescription
side"buy" or "sell"
priceThe limit, an expression or a number. Put on the symbol's tick grid before it is compared or posted: between two ticks a buy goes down and a sell up
sizeThe quantity in units, an expression or a number
tif"GTX" (post-only, the default: refused if it would cross), "GTC", "IOC" or "FOK"
enabledA condition; the quote stands while it holds. Always, when left out
cooldownHow long the quote posts nothing after it sent a cancellation, e.g. Interval.millis(500)

At each wake-up (every bar_interval), every quote is evaluated, then acts, in the order the quotes were declared:

  1. enabled null, or price or size not a number: the quote holds whatever it has.
  2. enabled false, or size zero or less: its live order is cancelled.
  3. A live order already at price: kept, with its place in the queue.
  4. Otherwise the live order is cancelled and a new one posted at price, at the back of the queue.

A strategy that quotes takes no .size(), no bracket and no entry order: each quote carries its own size. run_sweep, run_sweep_lite, run_batch and run_batch_lite take it as they take any strategy, each combination being the run mbt.run() makes alone.

What a Quote Reads

Computed before the run, on the grid of wake-ups: the book columns, the strategy's own signals, parameters and mbt.col("timestamp"). Read at each wake-up, as the strategy knows it (fills and answers latency["response"] after the venue booked them, the book latency["feed"] earlier):

ReaderValue
mbt.position()The position, as the fills that have reached the strategy say
mbt.cash()The cash: initial_capital, minus what the buys cost, plus what the sells brought, fees included
mbt.live_qty(side)What is left to fill of the live orders on "bid" or "ask"; 0 when there is none
mbt.order_price(side)The price of the newest live order on that side
mbt.order_age(side)Seconds since that order was sent
mbt.queue_ahead(side)The size queued ahead of it, as the venue last answered; on a market stored order by order, exactly
mbt.last_fill_px(side)The price of the last fill on that side; NaN before the first
mbt.last_fill_age(side)Seconds since that fill reached the strategy (either side when side is left out)
mbt.position_age()Seconds since the position left zero or changed sign
mbt.last_cancel_age(side)Seconds since the strategy sent its last cancellation on that side
book.bid_price_at(level)The price of a bid level at the wake-up, at a level the expression may compute; ask_price_at() for the asks
book.bid_levels()How many bid levels the book holds at the wake-up; ask_levels() for the asks

They combine with arithmetic, comparisons, when, round, clip, min_val / max_val, abs_val and round_to / floor_to / ceil_to. Each reads NaN before its event (no live order, no fill yet), and a comparison with NaN is false.

QuoteResult

mbt.run() returns a QuoteResult for a strategy that quotes: everything a Result has, read the same way (equity and positions at output_resolution, one second when left out; one row of trades per fill; metrics), plus the venue's own record: orders_df(), fills_df(), events_df(), fill_marks_df() and the whole of it as .sim. order_activity counts orders posted, requotes and orders cancelled unfilled, and fill_fragility what the queue decided.

Orders, One by One Researcher

mbt.run() turns a strategy into orders itself. mbt.sim does the opposite: it hands a Python function a simulated venue, and the function sends its own orders as it would to an exchange. It moves the clock, reads the book and the tape as they reach it, posts, cancels and replaces, and learns what happened to each order when the venue's answer comes back. Every resting order is served by the same queue model as fill_model["queue"], so a strategy that rests one order at a time gets the same fills through either door.

python
import manifoldbt as mbt
from manifoldbt.helpers import Interval

def maker(sim):
    while sim.elapse(Interval.millis(100)):     # False once the data is over
        book = sim.book("BTCUSDT")               # as it reached the strategy
        if book.best_bid is None:
            continue
        if not sim.orders("BTCUSDT", side="buy", status="live"):
            sim.post("BTCUSDT", "buy", book.best_bid, 0.01, tif="GTX")

config = mbt.sim.Config(
    latency={"entry": Interval.millis(5), "response": Interval.millis(5)},
    queue={"depth_source": "book", "assumed_queue": 2.0},
)
res = mbt.sim.run(maker, config, store, symbols=["BTCUSDT"],
                  start="2025-08-13", end="2025-08-14")
res.fills_df()

The store needs the book and the tape of every symbol over the range. A complete maker is examples/30_order_api_market_maker.py in the repository.

The Strategy's Side

CallWhat it does
sim.now()The strategy's local time, in nanoseconds
sim.elapse(dt)Move the clock dt forward; False once it reaches the end of the data
sim.wait(...)wait(trade=, book=, order=, timeout=): move the clock to the next event of the kinds named (any kind when none is), or timeout from now; returns a Wakeup whose kind says why: "trade", "book", "order", "timeout" or "end". It is false at the end of the data, so while sim.wait(trade=True): ends with it
sim.book(symbol)The book as the strategy sees it: best_bid, best_ask, best_bid_qty, best_ask_qty, mid, spread, qty_at(price), levels(n), depth_through(price), tick
sim.trades(symbol)The prints that reached the strategy since the last call
sim.post(...)post(symbol, side, price, qty, tif="GTC", expire_at=None, trigger=None, trigger_on="last", after_trigger="rest"): send an order, which reaches the venue after the entry latency; price=None is a market order, trigger= makes it a stop (see Stop Orders). Returns its id
sim.post_bracket(...)post_bracket(symbol, side, qty, *, price=None, trigger=None, trigger_on="last", tif="GTC", expire_at=None, stop=None, target=None, legs_on="last", exit_after=None, exit_at=None): send an order and the exits the venue holds for the position it opens (see Brackets). Returns the id of the entry
sim.cancel(id)Cancel an order; it can fill until the cancellation reaches the venue
sim.replace(id, ...)replace(id, price=None, qty=None): change its price, its size, or both. A smaller size at the same price keeps its place in the queue
sim.order(id)One order as the strategy knows it: status, filled, remaining, avg_price, queue_ahead, ...
sim.orders(...)Its orders in id order, filtered by symbol=, side= and status=
sim.position(symbol)The position, as the fills that reached the strategy say
sim.cash()The cash, the same way

The clock starts on the first event of the data, already applied: the first print is in sim.trades() and the first book in sim.book() before the first wait or elapse, and wait wakes on the events after it. A strategy that acts on every print reads the state once before its loop:

python
def strategy(sim):
    on_prints(sim)                      # the first event of the data
    while sim.wait(trade=True):         # false once the data has ended
        on_prints(sim)

Config and Run

mbt.sim.Config(latency=None, queue=None, maker_fee_bps=0.0, taker_fee_bps=0.0,
               initial_cash=0.0, seed=0, prints_update_book=None, stop_in_market="trigger")
mbt.sim.run(strategy, config=None, store=None, symbols=None, start=None, end=None,
            *, markets=None, levels=None, by_day="auto", device="cpu")

latency takes four delays: "entry" for a post or a replacement to reach the venue, "cancel" for a cancellation ("entry" when left out), "response" for an answer or a fill to reach the strategy, "feed" for a market event to. Note the difference with ExecutionConfig.latency, which names the first one "order" and leaves "cancel" at zero when it is left out. queue takes what fill_model["queue"] takes; left out, it is the exact queue on a market stored order by order and risk_adverse on the others. seed is accepted and passed to the venue, but no queue model draws from it today: every queue model is deterministic, so two seeds give the same run to the bit. prints_update_book and stop_in_market are described below.

strategy is a function of the venue, or a plan. levels bounds how many levels a side the venue and the strategy read. by_day="auto" holds a run longer than a day one UTC day at a time, with the same result bit for bit. The run returns a SimResult: fills_df(), orders_df(), events_df(), equity_df(), fill_marks, fill_fragility, metrics and summary(). The *_df methods return a pandas DataFrame when pandas is installed, a polars one when only polars is (backend="pandas" or "polars" picks one), and a dict of column name to list with neither, or with backend="arrow".

What the Venue Does

  • An order that would execute on arrival takes the displayed opposite ladder within its limit, level by level, and the rest of it expires. The simulated fills take nothing out of the stored book.
  • An order that would not execute rests at the back of its level's queue, behind the size the book shows there and behind the strategy's own earlier orders at that price.
  • At one venue instant the market comes first: the prints, then the book state that shows them, then the actions that arrive at that instant.
  • On a market stored order by order, a resting order is served in its exact place in the queue: when the market executes an order that arrived after it, goes through its level, or brings an order of the other side to its price.

When a Print Updates the Book

A stored book is a series of states, and between two of them the tape goes on: a seller can sell through the bid the last state shows before the next state says the bid moved. By default an order that executes on arrival meets the last stored state, so a market sell sent on that print fills at the old bid. A venue that matches on trades fills it at the print instead, and so does the simulated venue with prints_update_book:

python
config = mbt.sim.Config(prints_update_book="aggressed_side")   # or "both_sides"
  • "aggressed_side": from each print to the next stored state, the side the print traded against is updated. A print where the seller crossed, at p for q, leaves no bid above p, and q at p (or the stored size there, when it is larger). The other side is the stored side.
  • "both_sides": both sides, whoever crossed, as a top-of-book venue that executes on trades has them. Each side is the print alone, p for q, no stored level behind it; a market order larger than q fills its rest one tick worse than p. This follows the convention of trade-driven execution in other event-driven engines such as Nautilus Trader.

The update lives at the venue's clock. With zero latency, a strategy that wakes on a print (sim.wait(trade=True)) and sends a market order meets that print; an order that arrives after the next stored state meets that state. None, the default, is the stored book alone.

A market of prints alone. With no stored state at all, each side keeps its last print to the end of the run, so orders execute against the prints. sim.book() still reads the stored book, empty there: best_bid and best_ask stay None, and a strategy that waits for a price in the book never sends an order. Read prices off sim.trades() instead. mbt.sim.run warns of such a market, and says when no order can fill there at all, without prints_update_book.

Stop Orders

A stop order waits at the venue, out of the book, until the market reaches its trigger, and then executes:

python
# A stop-market: buys 100 at market once a print reaches 190.50.
sim.post("AAPL", "buy", None, 100, trigger=190.50)
# A stop-limit on the bid, until the close: sells at 189.80 or better once the
# stored best bid is at or below 189.90.
sim.post("AAPL", "sell", 189.80, 100, trigger=189.90, trigger_on="bid_ask",
         tif="GTD", expire_at=close_ns)

mbt.sim.Config(stop_in_market="reject")   # "trigger" by default
  • What sets it off. trigger_on="last", the default: a print at or through the trigger, whichever side crossed. trigger_on="bid_ask": a stored book state whose best ask (a buy stop) or best bid (a sell stop) is at or through it.
  • The trigger goes on the tick grid away from the market: a buy stop asked at 100.33 on a 0.1 tick waits for 100.4.
  • On arrival, a stop the market has already reached is set off at once, or refused with stop_in_market="reject". Otherwise it is armed: live, out of the book, listed by sim.orders(status="armed"). Its tif is its life while it waits, "GTC" or "GTD" with expire_at.
  • Set off at a venue instant, it executes at the end of that instant, after every market event stamped there, as a taker. A stop-market is a market order; a stop-limit is a limit order at its price, which rests in the queue when it does not execute (after_trigger="rest", the default) or expires ("ioc").
  • A waiting stop is cancelled as any order, after the cancel latency; it is not replaced. sim.order(id) carries trigger_price, trigger_on, after_trigger and triggered_at; the journal shows armed and triggered.

A run without a waiting stop costs nothing more: the venue reads its stops only when one is armed.

Brackets Held by the Venue

A bracket is an order that opens a position, and the exits the venue holds for it: a stop, a target, an exit at a time. The venue arms them once the order has filled, takes the others off when the first one goes, and closes the position whole.

python
# Buys 100 at market; once filled, a stop at 189.50, a target at 192.00 and an
# exit 30 minutes later, whichever comes first.
parent = sim.post_bracket("AAPL", "buy", 100, stop=189.50, target=192.00,
                          exit_after=Interval.minutes(30))
# A limit entry until t_ns, its legs on the bid and the ask, out at the third
# session close after the entry at the latest.
sim.post_bracket("AAPL", "sell", 100, price=191.20, tif="GTD", expire_at=t_ns,
                 stop=192.40, target=189.00, legs_on="bid_ask", exit_at=(closes_ns, 3))
  • The entry is an order as sim.post sends it: market (price=None), limit at price, or a stop with trigger=.
  • The legs are armed when the entry ends having filled something, for the size it filled, with no latency: a "stop" leg, a "target" leg (market-if-touched) and a "time" leg, set off by the last print (legs_on="last", the default) or the stored best bid or ask ("bid_ask"). exit_at takes an instant, a list of instants (the first after the entry), or (instants, n), the n-th of them.
  • One exit. The first leg set off takes the others off at the same instant. It executes as a market order; what the book does not fill of it waits for the venue's next event and executes again, until the position is closed (carried in the journal).
  • The legs are orders of their own, with parent and role set in sim.orders() and res.orders_df(). Cancelling one takes that leg alone off.

A Market From Your Own Data

Market.from_arrays() builds a market from columns: a day of your own quotes, top of book or deeper, and prints, without one Python object per event. A trading day of quotes and prints builds in milliseconds, and the market is then reused by every run and every sweep you give it to.

python
market = mbt.sim.Market.from_arrays(
    "AAPL", tick_size=0.01,
    quotes=nbbo,    # ts_ns, bid_px, bid_qty, ask_px, ask_qty
    trades=prints,  # ts_ns, price, qty, and aggressor when the tape has one
)
res = mbt.sim.run(strategy, markets=[market])
  • quotes and trades are each a pandas or polars DataFrame, a pyarrow Table or RecordBatch, or a dict of numpy arrays. Columns it does not read are ignored, and either may be left out.
  • ts_ns is integer nanoseconds since the epoch, UTC, or a timestamp column of any unit (timestamp is read when there is no ts_ns). Rows come in time order, several at one instant allowed: of several quotes at one instant the last one stands; prints at one instant keep their order, and come before the book at an instant holding both.
  • A side column of numbers is the top of book. A book several levels deep lists each side best first, bids falling and asks rising: list columns (a 2-D numpy array, an Arrow or polars list, a pandas column of lists), or one column a level, bid_px_0, bid_px_1, ... or bid_px_00, ..., read when there is no bid_px. A side ends at its first level without a price, and the book keeps as many levels as the deepest side.
  • A quote whose best price is missing (zero, negative, NaN or null) or crossed shows an empty book until the next quote, both sides of it; a locked quote stands. A book with one side is not supported: a market where one side is legitimately absent (a prediction market bucket nobody bids on) keeps the other side with a token level on the missing one, a price on the grid and a size near zero. from_arrays() counts the book states left empty in a warning.
  • aggressor is "buy" / "sell", or 1 for a buy and -1 or 2 for a sell. A print without one takes the tick rule. A 0 is refused rather than guessed.
  • A column missing, of another length or of a type it cannot read is refused by name, and so is a priced level with a size of zero. The four side columns are all needed, bid_px included on a market with no bids.

Market.from_ladders() below builds the same book from Python lists, and a market can also come from the store.

A Market Written by Hand

python
market = mbt.sim.Market.from_ladders(
    "BTCUSDT", tick_size=0.1,
    states=[(1_000_000, [(100.0, 2.0), (99.9, 9.0)], [(100.1, 5.0)])],
    trades=[(3_000_000, 100.0, 3.0, "sell")],
)
res = mbt.sim.run(maker, markets=[market])

Market.from_ladders() takes book states as (ts_ns, bids, asks), each side best first as (price, qty) pairs, and prints as (ts_ns, price, qty, aggressor). It is the way to test a strategy function on a situation you wrote, before running it on a stored day. Market.from_store(store, symbol, start_ns, end_ns) builds the same object from the store.

A Prediction Market From Its Tape

Market.from_store() refuses a symbol whose store holds prints and no book. A venue that publishes no book history, such as a Polymarket market ingested with mbt.ingest_trades(), is replayed from its tape on request, with tape_only=True, and the prints stand for the book:

python
store = mbt.ingest_trades("polymarket", "will-x-happen/Yes", symbol_id=1,
                          start="2026-11-01", end="2026-11-08")
market = mbt.sim.Market.from_store(store, "will-x-happen/Yes", start_ns, end_ns,
                                   tape_only=True)
sym = market.name                  # "WILL-X-HAPPEN/YES", as the store files it

def strategy(sim):
    bought = False
    while sim.wait(trade=True):     # false once the data has ended
        prices = [p for _, p, _, _ in sim.trades(sym)]
        if not bought and min(prices) < 0.30:
            sim.post(sym, "buy", None, 10.0, tif="IOC")
            bought = True

res = mbt.sim.run(strategy, mbt.sim.Config(prints_update_book="both_sides"),
                  markets=[market])

A market read so is held whole and takes neither levels, by_day nor device; a range without a print is refused by name. Several outcomes of one event are several markets in the same run. Three things the simulation does not do on such a market, and how to do them from what it returns:

  • The book. sim.book() stays empty: read prices off sim.trades(), as above. Orders execute against the last print of each side, its size included. Quotes of your own, with the tape, through Market.from_arrays(quotes=, trades=), replace that assumption.
  • Fees. taker_fee_bps is a rate on the notional, and Polymarket charges a rate on p * (1 - p) per share, set market by market. Leave the fees at zero and charge them on the fills.
  • Settlement. A position still open at the end of the data is marked at its last price, not settled. Settle it at the outcome's payout (1, 0, or 0.5 for a market cancelled 50/50), or run the market's bars through mbt.run, which settles a resolved market ingested with mbt.ingest(provider="polymarket") on its own.
python
fills = res.fills_df(backend="arrow")
fee_rate = 0.02          # the market's taker fee rate, read off Polymarket
fees = sum(fee_rate * q * p * (1 - p) for p, q in zip(fills["price"], fills["qty"]))
sign = [1 if s == "buy" else -1 for s in fills["side"]]
cost = sum(k * p * q for k, p, q in zip(sign, fills["price"], fills["qty"]))
shares = sum(k * q for k, q in zip(sign, fills["qty"]))
payout = 1.0             # what one share of the outcome paid at resolution
pnl = shares * payout - cost - fees

Sweeping an Order Plan Researcher

When what a strategy function does is a schedule (decide at set instants or at the rows of a table, send one order, hold the position with exits the venue keeps), it can be written down as a plan: mbt.sim.Plan. The engine runs it on the venue of Orders, One by One without calling back into Python, and mbt.sim.sweep runs every variant of a grid of its parameters, in parallel. Each variant is the run mbt.sim.run makes of the same plan, with the same parameters and the same config, to the bit.

python
import manifoldbt as mbt
from manifoldbt.helpers import Interval
S = mbt.sim

# A market order at the first print at or after each deadline (the open + k x
# the interval), sides in turn.
plan = S.Plan(
    when=S.every(mbt.param("every", default=Interval.minutes(5)), on="print",
                 anchor="session"),
    entry=S.market(),
    side=S.alternate("buy"),
    size=mbt.param("qty", default=100),
    sessions=S.sessions(open="09:30", close="16:00", tz="America/New_York"),
)
res = S.sweep(plan, {"every": [Interval.minutes(m) for m in (1, 2, 3, 5, 10)],
                     "qty": [100, 200, 500]},
              markets=[market], config=S.Config(prints_update_book="both_sides"))
res.to_df()            # one row per variant: param_*, exec_*, the metrics
res.fills_df(3)        # the fills of variant 3, computed again on demand
res.run(3)             # variant 3 run again: the whole SimResult

# Stop entries that expire, a bracket, positions held over several sessions.
plan = S.Plan(
    when=S.setups(signals),                    # ts_ns, side, level
    entry=S.stop(offset=mbt.param("off", default=1), unit="ticks", on="last",
                 expire_after=mbt.param("expiry", default=Interval.minutes(30))),
    side="from_setups",
    size=S.notional(10_000, lot=1),
    exit=S.bracket(stop=mbt.param("sl", default=1.5), target=mbt.param("tp", default=3.0),
                   unit="pct", on="last", hold=S.closes(3)),
    sessions=S.sessions(open="09:30", close="16:00", tz="America/New_York",
                        entries=("09:45", "15:30")),
)
res = S.sweep(plan, {"off": [0, 1, 2, 3], "sl": [1.0, 1.5, 2.0], "tp": [2.0, 3.0]},
              markets=[market])

S.run(plan, config, markets=[market])          # one plan, its defaults: a SimResult

Once the market is built, a sweep of a hundred stop-and-bracket variants over a trading day takes a few milliseconds.

What a Plan Is

PartWhat it takes
whenS.every(period, on=, anchor=): at anchor + k * period, the anchor the market's first instant ("start", the default) or each session's open ("session"); on the clock (on="clock", the default) or at the first print at or after it ("print"). Or S.setups(table): one decision per row, at its instant
entryS.market(); S.limit(offset, unit=, tif=), a limit through the market that executes at its arrival ("IOC", the default, or "FOK"); S.stop(offset, unit=, on=, expire_after=); S.stop_limit(offset, limit_offset, unit=, on=, expire_after=, after_trigger=)
side"buy", "sell", S.alternate(first) (then the other side, entry after entry), or "from_setups"
sizeA size; S.notional(amount, lot=1), floor(amount / price / lot) * lot; or "from_setups"
exitS.bracket(stop=, target=, unit=, on=, hold=): a stop and a target at a distance from the price the entry filled at, and a hold, a duration or S.closes(n)
sessionsS.sessions(open=, close=, tz=, entries=): the weekday sessions, in a time zone, and the window of each in which entries are decided
sample_everyThe step of the equity samples the metrics read; a minute when left out

Every number and every duration of a plan takes a value or mbt.param("name", default=...); any other expression is refused by name. Durations are an Interval, a datetime.timedelta or nanoseconds. A unit is "ticks" (the default), "bps" or "pct" of the price the distance is counted from, or "price".

S.setups(table) reads a pandas or polars DataFrame, a pyarrow table or a dict of arrays: ts_ns (the rows in time order) and, when the table has them, side, level (the reference price of the row; missing, the last print) and size. The signals of a plan arrive this way: computed by you, on your own data, before the run.

The time zone of S.sessions is read by Python's zoneinfo; on Windows, pip install tzdata brings the time zones.

How a Plan Runs

  • Decisions are made inside the entry windows, or over the whole market without sessions. An entry reaches the venue after the entry latency. The plan sends nothing else: expiries and exits are held by the venue.
  • One position at a time. With an exit, a decision is skipped while the strategy knows of a live order or an open position. Without one, the plan is a calendar and every decision sends its order.
  • Levels are set from the setup's level, or from the last print the strategy has seen at the decision; a decision without one is skipped. A buy stop sits at the reference plus offset, a sell stop at it minus offset, on the tick grid away from the market. A notional is read against the entry's own level.
  • Execution goes through the venue's own rules, exactly as an order sent by sim.post: see Stop Orders and Brackets.
  • Sessions. A position crosses the sessions: its exits stay armed overnight, and S.closes(n) exits at the n-th session close after the entry filled. A position still open at the end of the data is marked at the last mid.

The Grid

python
res = S.sweep(plan, {"every": [...], "qty": [100, 200]}, config, markets=[market],
              execution_grid={"latency.entry": [0, Interval.millis(5)],
                              "prints_update_book": [None, "both_sides"]},
              max_parallelism=None, keep="metrics")
  • param_grid is {name: [values]} for the mbt.param the plan names; a parameter left out takes its default. An axis that no field of the plan reads is refused by name, so a flat surface always means a parameter that does not matter.
  • execution_grid is {path: [values]} over the keys of mbt.sim.Config, dotted: "latency.entry", "latency.feed", "latency.response", "prints_update_book", "taker_fee_bps".
  • The parameters vary in name order, the last the fastest, the execution axes slower than all of them.
  • The market is loaded once, by you (markets=[market]) or from a store (store=, symbol=, start=, end=, by_day=), and every variant runs on it in the engine, in parallel. max_parallelism bounds the threads; the results do not depend on it. A market held day by day is swept a day at a time for every variant together.
  • keep="metrics" (the default) keeps one row of metrics per variant; keep="fills" keeps the fills of every variant as well.
  • engine="auto" (the default), "direct" or "sim" say how the variants run, to the same results. Simulated fills take nothing out of the stored book, so "direct" reads the market only at the instants each variant acts, once for every variant; "sim" plays every event of the market for each variant. "auto" takes "direct" for a plan whose orders never rest in the book, and says which and why in res.engine and res.engine_reason.

Diagnostics

The manifoldbt.diagnostics module provides automated checks to catch common strategy bugs before you trust your results.

detect_lookahead() Pro

detect_lookahead(
    strategy: Strategy,
    config: BacktestConfig,
    store: DataStore,
    *,
    mode: str = "all",
    tolerance: float = 1e-9,
) -> DiagnosticsResult

Detect look-ahead bias -- both global and rolling. Automatically splits the config's time range and compares trades from shorter runs against the full run. Data is loaded once and sliced for each sub-test.

ArgumentTypeDefaultDescription
strategyStrategy--Strategy definition
configBacktestConfig--Backtest configuration
storeDataStore--Data store
modestr"all""all", "extension", or "truncation"
tolerancefloat1e-9Float comparison tolerance for quantity/price/fees

Returns DiagnosticsResult with .passed (bool), .assert_clean() (raises on failure), and .reports (list of sub-test results).

python
from manifoldbt.diagnostics import detect_lookahead

report = detect_lookahead(strategy, config, store)
print(report)          # PASS or FAIL with details
report.assert_clean()   # raises AssertionError if bias detected

check_exposure_stability()

check_exposure_stability(
    strategy: Strategy,
    config: BacktestConfig,
    store: DataStore,
    *,
    mode: str = "all",
    tolerance: float = 1e-6,
) -> ExposureDiagnosticsResult

Check that exposure/utilization is identical across time windows. Runs the strategy on different sub-periods and compares utilization, exposure, and per-symbol positions at overlapping timestamps.

ArgumentTypeDefaultDescription
strategyStrategy--Strategy definition
configBacktestConfig--Backtest configuration
storeDataStore--Data store
modestr"all""all", "extension", or "truncation"
tolerancefloat1e-6Absolute tolerance for float comparisons

Returns ExposureDiagnosticsResult with .passed, .assert_clean().

python
from manifoldbt.diagnostics import check_exposure_stability

report = check_exposure_stability(strategy, config, store)
print(report)
report.assert_clean()

risk_check()

risk_check(
    result: Result,
    *,
    max_utilization: float = 0.95,
    min_free_margin: float = 0.05,
    max_exposure_ratio: float = 3.0,
    max_utilization_trend: float = 1e-4,
    max_concentration: float = 0.95,
) -> RiskReport

Systematic risk checks on a backtest result. Analyzes free margin, utilization, leverage, and concentration over the full period.

ArgumentTypeDefaultDescription
resultResult--BacktestResult from mbt.run()
max_utilizationfloat0.95Fail if peak utilization exceeds this
min_free_marginfloat0.05Fail if free margin ratio drops below this
max_exposure_ratiofloat3.0Fail if exposure / initial_capital exceeds this
max_utilization_trendfloat1e-4Warn if utilization slope per bar exceeds this
max_concentrationfloat0.95Warn if peak Herfindahl index exceeds this

Returns RiskReport with .passed, .clean, .assert_clean(), .checks (list), and time-series arrays: .timestamps, .utilization, .free_margin_ratio, .concentration.

python
from manifoldbt.diagnostics import risk_check

report = risk_check(result, max_utilization=0.95, min_free_margin=0.05)
print(report)
report.assert_clean()

Profiling

Every result includes a profile dict with microsecond-resolution timing for each execution phase:

KeyDescription
data_load_usTime to load and deserialize bar data from disk
align_usTime to align multi-symbol timestamps
signal_eval_usTime to evaluate the expression graph
runtime_prep_usTime to prepare simulation runtime
simulation_usTime to run the core simulation loop
output_build_usTime to build Arrow output arrays
total_usTotal wall-clock time
python
print(result.profile_summary())
# Profile (total: 142.3ms)
# --------------------------------------------
#   Data loading     48.2ms   33.9%  #############
#   Signal eval      12.1ms    8.5%  ###
#   Simulation       78.4ms   55.1%  ######################

Result

The Result class wraps the Rust BacktestResult with DataFrame conversion, summaries, plotting shortcuts, and Jupyter rich display. SweepResult (see Run Functions) and the dict returned by run_walk_forward() get the same Jupyter rich display -- no .to_df() or manual formatting needed to inspect them in a notebook.

DataFrame Methods

result.equity_df(backend: str = "auto") -> DataFrame

Equity curve as a DataFrame with timestamp and equity columns.

ArgumentTypeDefaultDescription
backendstr"auto""pandas", "polars", or "auto" (detects installed)
result.trades_df(backend: str = "auto") -> DataFrame

The trade log, one row per fill. Columns, codes and export are in Trade Log below.

result.positions_df(backend: str = "auto") -> DataFrame

Position trace with per-bar position, equity, capital, close price, symbol_id.

result.daily_returns_series(backend: str = "auto") -> Series

Daily returns as a Series.

Trade Log

The same table is reachable two ways: result.trades is the Arrow RecordBatch the engine built, result.trades_df() converts it to pandas or polars. Either way it holds one row per fill: an entry and its exit are two rows, and a partial fill that completes over several bars is one row carrying the average fill price. That is also what result.trade_count and trade_stats["total_trades"] count. The round-trip pairing behind win_rate and expectancy is computed from this table, and result.round_trips_df() returns it, one row per entry-to-flat cycle, with the same count, win rate and expectancy as trade_stats.

ColumnTypeDescription
signal_timestampdatetime (ns, UTC)Bar on which the order was decided. For a stop, take-profit or trailing exit this equals execution_timestamp: brackets fire inside the bar, the signal delay does not apply
execution_timestampdatetime (ns, UTC)Bar on which the fill happened, after signal_delay and any partial-fill wait
symbol_iduint32The integer id from your universe. Names are not stored: join on the universe mapping you gave BacktestConfig
sideuint81 = buy, 2 = sell
quantityfloat64Units filled, always positive; the direction is in side
intended_pricefloat64Price the order was aimed at: the trigger level for a bracket exit, the reference price otherwise
fill_pricefloat64Average price actually paid
feesfloat64Fees charged on this fill, in the account currency
slippagefloat64Slippage paid per unit, in price units, not a cost: multiply by quantity. Exactly 0.0 on a take-profit, where the fill is the trigger. Read this column rather than fill_price - intended_price: depending on the execution path a signal fill may store its average fill in both price columns
is_gap_barboolTrue when the fill landed on a bar the aligner synthesised to plug a hole in the source data, so the price is a carried value rather than an observed trade. The run also carries a warning for each such fill
exit_reasonuint8Why the position changed, coded as below. 0 on every entry
exit_reasonMeaning
0Signal: the sizing expression moved the target position. Entries and plain exits alike
1Stop loss
2Take profit
3Trailing stop
4Limit entry expired unfilled. Defined for completeness: an unfilled order produces no fill, so no row carries it today
5Option contract reached expiry and was cash-settled at intrinsic value
6Margin call: a short option position force-closed because maintenance margin exceeded equity
7Liquidation: equity reached the maintenance margin of a leveraged position (cfg.liquidation, see Liquidation)

The lite paths (run_sweep_lite(), run_batch_lite(), the GPU sweep) skip the log by design and keep only trade_count; run(), run_batch() and the full run_sweep() always build it. Export is whatever your DataFrame library offers, or Arrow directly:

python
trades = result.trades_df()          # pandas, or backend="polars"

# Decode the codes
SIDE = {1: "buy", 2: "sell"}
REASON = {0: "signal", 1: "stop_loss", 2: "take_profit", 3: "trailing_stop",
          4: "limit_expiry", 5: "option_expiry", 6: "margin_call", 7: "liquidation"}
trades["side"] = trades["side"].map(SIDE)
trades["exit_reason"] = trades["exit_reason"].map(REASON)

# Export
trades.to_csv("trades.csv", index=False)

# Or straight from Arrow, without a DataFrame in the loop
import pyarrow as pa, pyarrow.parquet as pq
pq.write_table(pa.Table.from_batches([result.trades]), "trades.parquet")

Summary & Profiling

result.summary() -> str

Pretty-printed performance summary with returns, ratios, and trade statistics.

result.profile_summary() -> str

Timing breakdown of each execution phase (data load, signal eval, simulation, etc.).

Plotting

result.plot(kind: str = "tearsheet", **kwargs) -> Any

Plot backtest results. Available kinds: "tearsheet", "equity", "drawdown", "monthly_returns", "summary", "annual_returns", "rolling_sharpe", "rolling_volatility", "returns_histogram".

result.plot_equity(**kwargs) -> Any
result.plot_drawdown(**kwargs) -> Any
result.plot_monthly_returns(**kwargs) -> Any

Shortcut methods for common chart types.

Comparison

result.compare(*others: Result, backend: str = "auto") -> DataFrame

Compare metrics across multiple results. Returns a DataFrame with one row per result and all metrics as columns.

python
result = mbt.run(strategy, config, store)

# Summary
print(result.summary())
print(result.profile_summary())

# DataFrames
eq_df = result.equity_df()
trades = result.trades_df()
positions = result.positions_df()

# Plot
result.plot("tearsheet")
result.plot_equity(show=True)

# Compare multiple strategies
r1 = mbt.run(strat_a, config, store)
r2 = mbt.run(strat_b, config, store)
df = r1.compare(r2)

# Raw attributes (delegated to Rust)
result.metrics        # dict of performance metrics
result.trade_count    # number of trades
result.equity_curve   # raw equity array
result.trades         # Arrow table of trades
result.positions      # Arrow table of positions
result.manifest       # run manifest (config + strategy)
result.profile        # timing dict
result.warnings       # list of run warnings
result.brackets_not_armed  # positions opened with no bracket (see Orders)

result.brackets_not_armed counts the positions that opened without the bracket the strategy asked for, because the series behind a signal= distance held no usable percentage on the entry's signal bar. It reads 0 on a healthy run; anything else is a position you believed protected. The matching entry in result.warnings names the order. See Orders.

Strategy Builder

The Strategy class supports both direct construction and a fluent builder pattern.

Direct Construction

Strategy(
    name: str,
    signals: Optional[dict[str, Expr]] = None,
    position_sizing: Optional[Expr] = None,
    parameters: Optional[dict[str, Expr]] = None,
    constraints: Optional[list] = None,
    description: Optional[str] = None,
)

Fluent Builder

MethodSignatureDescription
Strategy.create(name)create(name: str) -> StrategyCreate an empty strategy for chaining
.signal(name, expr)signal(name: str, expr: Expr) -> StrategyAdd a named signal expression
.size(expr)size(expr: Expr) -> StrategySet the position sizing expression
.param(name, ...)param(name, default, range, description) -> StrategyRegister a sweep parameter
.stop_loss(pct)stop_loss(pct: float = None, side="both", *, signal: str = None) -> StrategyAttach a stop-loss order. side is "both", "long" or "short". Exactly one of pct or signal, see Orders
.take_profit(pct)take_profit(pct: float = None, side="both", *, signal: str = None) -> StrategyAttach a take-profit order, on one side or both
.trailing_stop(pct)trailing_stop(pct: float = None, use_high=True, side="both", *, signal: str = None) -> StrategyAttach a trailing stop, on one side or both
.describe(text)describe(text: str) -> StrategySet strategy description
python
strategy = (
    mbt.Strategy.create("ema_crossover")
    .signal("fast", ema(close, 10))
    .signal("slow", ema(close, 25))
    .signal("trend", mbt.col("fast") > mbt.col("slow"))
    .size(mbt.when(mbt.col("trend"), 0.5, 0.0))
    .stop_loss(pct=2.0)
    .describe("Simple EMA crossover with stop-loss")
)

Helpers

Convenience functions from manifoldbt.helpers for configuration.

time_range(start: str, end: str) -> Tuple[int, int]

Convert two date strings, read as UTC, to a (start_ns, end_ns) tuple of nanoseconds since the Unix epoch. Accepts "YYYY-MM-DD", "YYYY-MM-DD HH:MM:SS" and "YYYY-MM-DDTHH:MM:SS". A trailing Z, a UTC offset or fractional seconds are rejected: "2024-01-01T00:00:00" works, "2024-01-01T00:00:00Z" raises.

date_to_ns(date_str: str) -> int

Convert a single date string, in the same formats, to nanoseconds since the Unix epoch (UTC).

python
from manifoldbt.helpers import time_range, Slippage, Interval

start, end = time_range("2022-01-01", "2024-01-01")

config = mbt.BacktestConfig(
    time_range_start=start,
    time_range_end=end,
    slippage=Slippage.fixed_bps(1.0),
    bar_interval=Interval.minutes(1),
)

Metrics

The result.metrics dict holds 23 top-level fields plus a nested trade_stats block, itself nesting a signal_quality block. Access them via result.metrics["sharpe"]. result.summary() prints a curated subset for reading, not the whole dict, so use json.dumps(result.metrics, indent=2) when you want to see everything the engine computed.

Performance Metrics

MetricDescription
total_returnCumulative return over the period
cagrCompound annual growth rate
volatilityAnnualized standard deviation of returns
sharpeAnnualized Sharpe ratio (excess return / volatility). Excess is over risk_free_rate (default 0.0 = raw Sharpe; set it on BacktestConfig for an excess-return Sharpe)
sortinoSortino ratio. The denominator is the annualised sample standard deviation (n-1) of the negative periodic returns about their own mean, not the root-mean-square of downside returns. With fewer than two negative periods it is undefined and reported as 0.0. Net of risk_free_rate
calmarCAGR / max drawdown
max_drawdownLargest peak-to-trough decline
tstat_sharpeT-statistic of the Sharpe ratio
alphaJensen's alpha (vs buy-and-hold)
betaBeta to the underlying asset
tstat_alphaT-statistic of alpha
skewnessReturn distribution skewness
kurtosisReturn distribution excess kurtosis (Fisher, 0 = normal)
tail_ratio|95th percentile| / |5th percentile| of returns. Above 1 = fatter right tail. Returns 0.0 below 20 observations
omega_ratioSum of returns above zero / sum of returns below zero
ulcer_indexRoot mean square of the drawdown series on daily equity. Prices depth and duration, where max_drawdown only sees the single worst point
best_dayBest single-day return
worst_dayWorst single-day return
avg_daily_returnMean daily return over the period
pct_positive_daysFraction of days with positive returns
max_drawdown_duration_daysLongest stretch, in days, from an equity peak to the recovery of that peak. Measured to the last bar when the curve never recovers
final_equityEquity at the end of the run, in money
pnlfinal_equity minus initial_capital, in money: what the run made or lost

Trade Statistics

Nested under result.metrics["trade_stats"]. The key is absent from the dict (not set to None) in two cases: the backtest produced no fill, or it ran on a lite path (run_sweep_lite(), run_batch_lite(), and the GPU sweep), which skips the trade log by design. run(), run_batch() and the full run_sweep() always populate it.

MetricDescription
total_tradesNumber of fills, entries and exits alike. Not the number of round trips
round_tripsNumber of completed round trips: an entry through the return to flat
win_rateFraction of profitable round trips
profit_factorGross profit / gross loss
expectancyAverage P&L per round trip
avg_winAverage winning trade P&L
avg_lossAverage losing trade P&L
max_consecutive_winsLongest streak of winning round trips
max_consecutive_lossesLongest streak of losing round trips
avg_holding_secondsMean time in position per round trip, in seconds
median_holding_secondsMedian time in position, in seconds
min_holding_secondsShortest time in position, in seconds
max_holding_secondsLongest time in position, in seconds
total_feesCumulative fees paid
sl_exitsExits triggered by the stop loss
tp_exitsExits triggered by the take profit
trailing_exitsExits triggered by the trailing stop

Signal Quality

One level deeper, under result.metrics["trade_stats"]["signal_quality"]. These come from the MAE/MFE excursions the engine tracks bar by bar while a position is open, so they answer a question the aggregate ratios cannot: whether the edge sits in the entry, in the exit, or nowhere. Present wherever trade_stats is.

MetricDescription
avg_maeAverage Maximum Adverse Excursion, as a fraction of entry price: how far the average trade went against you before it closed
avg_mfeAverage Maximum Favorable Excursion, as a fraction of entry price: how far it went in your favor
avg_entry_efficiencyMean of MFE / (MFE + MAE). Near 1.0 = entries land close to the best price the trade ever offered
avg_exit_efficiencyMean of realized move / MFE. Near 1.0 = exits capture the favorable move instead of giving it back
edge_ratioavg_mfe / avg_mae. Above 1 means trades run further in your favor than against you

Visualization

The mbt.plot module provides publication-quality charts. Install with pip install manifoldbt[plot].

Composite Layouts

tearsheet(
    result,
    *,
    benchmark=None,
    title: Optional[str] = None,
    show: Optional[bool] = None,
    save: Optional[str | Path] = None,
    dpi: int = 150,
    plotlyjs: str = "cdn",
) -> str

Self-contained HTML tearsheet with all charts and metrics. Returns the HTML string. show defaults to None (auto, opens a browser tab when running interactively, silent in a test run); pass show=True/False to force it. Writes to disk when save is given. Available on all tiers.

ArgumentTypeDefaultDescription
plotlyjsstr"cdn"How the Plotly JS runtime is embedded in the report: "cdn" (small file, requires internet to render) or "inline" (fully offline HTML, larger file). "inline" was fixed in 0.13.1 to actually produce an offline-renderable report
dpiint150Matplotlib-era argument, accepted for backward compatibility but ignored since the move to Plotly
research_report(
    sweep_result: Optional[dict] = None,
    wf_result: Optional[dict] = None,
    stability_result: Optional[dict] = None,
    *,
    title: str = "Research Report",
    figsize: tuple = (14, 6),
    show: Optional[bool] = None,
    save: Optional[str | Path] = None,
    dpi: int = 150,
) -> List[Figure]

Research report -- one interactive Plotly Figure per analysis (sweep, walk-forward, stability). At least one result required.

Backtest Result Charts

summary(result, *, figsize=(14, 8), show=None, save=None) -> Figure

Compact summary panel: TWR equity + vol-adjusted buy-and-hold benchmark, daily trade count, used margin %.

equity(result, *, ax=None, color="#5b7ff5", title="Equity Curve", figsize=(14, 5), show=None, save=None) -> Figure

Portfolio equity curve over time with filled area.

benchmark_equity(
    result, benchmark: ndarray,
    *, ax=None, strategy_color="#5b7ff5", benchmark_color="#3a3a40",
    normalize=True, labels=("Strategy", "Buy & Hold"),
    title="Strategy vs Benchmark", figsize=(14, 5), show=None, save=None,
) -> Figure

Strategy equity overlayed with a benchmark array, both normalized to 100.

drawdown(result, *, ax=None, color="#e85d75", title="Drawdown", figsize=(14, 3), show=None, save=None) -> Figure

Drawdown as a filled area chart (peak-to-trough decline).

monthly_returns(result, *, ax=None, annotate=True, title="Monthly Returns (%)", figsize=(12, 5), show=None, save=None) -> Figure

Heatmap of monthly returns (year rows x month columns + annual total).

annual_returns(result, *, ax=None, title="Annual Returns", figsize=(10, 4), show=None, save=None) -> Figure

Annual returns bar chart with green/red conditional coloring.

returns_histogram(result, *, ax=None, bins=100, title="Returns Distribution", figsize=(12, 5), show=None, save=None) -> Figure

Histogram of daily returns with normal fit overlay.

var_chart(result, *, ax=None, confidence=0.05, bins=120, title="Value at Risk", figsize=(12, 5), show=None, save=None) -> Figure

Returns histogram with VaR and CVaR lines at 5% and 1% levels.

rolling_sharpe(result, *, windows=None, ax=None, title="Rolling Sharpe", trading_days_per_year=365.25, figsize=(14, 4), show=None, save=None) -> Figure

Rolling annualized Sharpe ratio. Default windows: [126, 252].

rolling_volatility(result, *, windows=None, ax=None, title="Rolling Volatility", trading_days_per_year=365.25, figsize=(14, 4), show=None, save=None) -> Figure

Rolling annualized volatility. Default windows: [126, 252].

All backtest chart functions return an interactive Plotly Figure (crosshair, hover, zoom). save writes a responsive, self-contained HTML page by default; save="chart.png" (or .svg/.pdf) needs pip install manifoldbt[png], which pulls a headless Chromium via kaleido. show defaults to None (auto): the chart opens in a native window (pip install manifoldbt[plot]), falling back to a browser tab, when running interactively (a script, a REPL, a Jupyter cell), and stays silent when neither is detected, e.g. in a test run under pytest or CI. Pass show=True to force it open and show=False to force it silent. The matplotlib-era ax, figsize and dpi arguments are accepted for backward compatibility but ignored.

Research Charts

heatmap_2d(
    sweep_result: dict,
    *, ax=None, annotate=True, fmt=".3f", highlight_best=True,
    zones=None, drift=2,
    title=None, figsize=(10, 8), show=None, save=None,
) -> Figure

2D parameter sweep heatmap from run_sweep_2d() result. Uses Gaussian smoothing to highlight the plateau-optimal best region (overfit-resistant). Annotations auto-disabled when grid exceeds 100 cells.

ArgumentTypeDefaultDescription
sweep_resultdict--Output of run_sweep_2d()
annotateboolTrueShow values in cells (if grid <= 100 cells)
fmtstr".3f"Number format for annotations
highlight_bestboolTrueHighlight the plateau-optimal cell (Gaussian-smoothed)
zonesbool | list[float] | NoneNoneColour cells by robustness zone instead of by the metric. A zone is what the area still guarantees when the parameters move by drift cells, so a lucky spike lands in a low zone despite scoring well on its own cell. True picks the bands (0 / 0.5 / 1.0 / 1.5 for risk-adjusted ratios, an even split of the observed range otherwise); a list of thresholds sets them. The metric value stays on hover and in the cell labels
driftint2Neighbourhood radius, in grid cells, of the worst case zones reads. Cells, not parameter units: with an x step of 1 and a y step of 5, drift=2 means ±2 on x and ±10 on y
surface_3d(
    sweep_result: dict,
    *, highlight_best=True, zones=None, drift=2,
    title=None, figsize=(12, 8),
    elev=30, azim=-45, show=None, save=None,
) -> Figure

3D surface plot from a 2D parameter sweep result. Same input format as heatmap_2d.

ArgumentTypeDefaultDescription
sweep_resultdict--Output of run_sweep_2d()
elevfloat30Camera elevation angle
azimfloat-45Camera azimuth angle
highlight_bestboolTrueMark the best point with a white dot
zonesbool | list[float] | NoneNoneRobustness zones, same semantics as heatmap_2d
driftint2Neighbourhood radius in grid cells, same semantics as heatmap_2d
monte_carlo(
    result,
    *, n_simulations=1000, method="bootstrap",
    percentiles=None, n_sample_paths=50,
    ax=None, median_color="#5b7ff5", band_color="#5b7ff5",
    title=None, figsize=(12, 5), seed=None, show=None, save=None,
) -> Figure

Monte Carlo fan chart with percentile bands, sample paths, and risk stats.

ArgumentTypeDefaultDescription
resultResult--BacktestResult from mbt.run()
n_simulationsint1000Number of simulated paths (capped at 1000 for Community)
methodstr"bootstrap""bootstrap" (tail risk) or "permutation" (path-dependency)
percentileslist[int][5,25,50,75,95]Percentile levels for bands
n_sample_pathsint50Number of individual paths to draw (faded). 0 to disable
seedint | NoneNoneRandom seed for reproducibility
walk_forward(
    wf_result: dict,
    *, mode="auto", full_result=None,
    ax=None, is_color="#5b7ff5", oos_color="#f5a623",
    title=None, figsize=(10, 5), show=None, save=None,
) -> Figure

Walk-forward analysis chart with multiple display modes.

ArgumentTypeDefaultDescription
wf_resultdict--Output of run_walk_forward()
modestr"auto""auto", "equity", "bars", or "stitched"
full_resultResult | NoneNoneFull backtest result for "stitched" mode baseline
stability(
    stability_result: dict,
    *, ax=None, line_color="#5b7ff5", band_color="#5b7ff5",
    band_alpha=0.15, title=None, figsize=(10, 5), show=None, save=None,
) -> Figure

Parameter stability chart with mean +/- std shaded bands. Shows stability score.

stochastic_paths(
    result: dict,
    *, percentiles=None, n_sample_paths=50,
    ax=None, median_color="#5b7ff5", band_color="#5b7ff5",
    title=None, figsize=(12, 5), show=None, save=None,
) -> Figure

Fan chart for stochastic simulation paths with percentile bands and risk stats. Requires store_paths=True in run_stochastic().

ArgumentTypeDefaultDescription
resultdict--Output of run_stochastic(..., store_paths=True)
percentileslist[int][5,25,50,75,95]Percentile levels for bands
n_sample_pathsint50Number of individual paths to draw (faded). 0 to disable
correlation_matrix(
    symbols: list[str], matrix: list[list[float]],
    *, ax=None, annotate=True, title="Correlation Matrix",
    figsize=(8, 7), show=None, save=None,
) -> Figure

Symbol correlation matrix heatmap.

chart(
    result, store: DataStore, symbol_id: int,
    *, emas=None, smas=None, n_bars=120,
    interactive=True, figsize=(14, 7), show=None, save=None,
)

Candlestick chart with trade markers and indicator overlays. Renders an interactive Plotly chart (vectorized go.Candlestick). Since 0.26.0 the bars are looked up the way the engine looks them up, so any store filled by mbt.ingest(), mbt.import_csv() or mbt.import_dataframe() charts directly; before that only the legacy bars_1m/ layout was searched and everything else answered “No bar data found”.

ArgumentTypeDefaultDescription
resultResult--BacktestResult
storeDataStore--Data store (to load OHLC bars)
symbol_idint--Which symbol to chart
emaslist[int] | NoneNoneEMA periods to overlay (e.g. [10, 25])
smaslist[int] | NoneNoneSMA periods to overlay
n_barsint120Number of bars to display (last N)
interactiveboolTrueAccepted for compatibility; charts are always interactive Plotly now
python
# Full tearsheet (HTML)
mbt.plot.tearsheet(result, show=True)

# Individual charts
mbt.plot.equity(result, show=True)
mbt.plot.drawdown(result, show=True)
mbt.plot.monthly_returns(result, show=True)

# Save to file
mbt.plot.tearsheet(result, save="report.html")
mbt.plot.equity(result, save="equity.png")

# Monte Carlo with custom params
mbt.plot.monte_carlo(result, n_simulations=5000, method="bootstrap", show=True)

# Via Result methods
result.plot("tearsheet")
result.plot_equity()
result.plot_drawdown()

Window & Theme

mbt.plot.show() -> None

Opens every chart queued by show=True, each in its own frameless native window, and blocks until they are all closed, like matplotlib.pyplot.show(). Charts queue rather than open one by one so you can put the equity in one window and the return distribution in another, side by side. A no-op when nothing is queued, and callable again after queuing more charts. Each window runs in its own child process with a dedicated WebView2 profile, so several heavy charts do not freeze one another. Without pywebview (pip install manifoldbt[window], or the [plot] extra which includes it) each queued chart opens in a browser tab instead. You rarely need to call it: a script that ends with charts still queued shows them on exit automatically. Under pytest or CI nothing is queued in the first place, so the exit hook has nothing to block on.

python
# Queue two charts, then open both windows side by side
mbt.plot.equity(result, show=True)
mbt.plot.returns_histogram(result, show=True)
mbt.plot.show()                       # blocks until both windows are closed

# Force a browser tab instead of a window
mbt.plot.drawdown(result, show="browser")
mbt.plot.apply_theme() -> None

Registers the manifoldbt Plotly template (dark background, neutral blue accent, green and red reserved for sign, dotted crosshair spikes, monospace hover labels) under plotly.io.templates["manifoldbt"] and sets it as Plotly's default template for the whole process. Every chart function above calls it on first use, so manifoldbt charts never need it. Call it yourself when you build figures with plotly.graph_objects directly and want them to match: after the call a plain go.Figure() picks up the same look. The side effect is global, which is the point: any Plotly figure created afterwards, from any library, uses the template until you set plotly.io.templates.default to something else.

mbt.plot.THEME is the layout dict behind the template (backgrounds, font, margins, hovermode="x", colorway, hover and legend styling), exposed for reuse in your own fig.update_layout(**THEME).

python
import plotly.graph_objects as go

mbt.plot.apply_theme()

# Your own figure, same look as the manifoldbt charts
fig = go.Figure(go.Scatter(x=eq_df["timestamp"], y=eq_df["equity"], name="equity"))
fig.show()

# Back to Plotly's stock look
import plotly.io as pio
pio.templates.default = "plotly"

DataStore

The DataStore connects the engine to your local bar data and metadata database.

DataStore(
    data_root: str,
    metadata_db: str = "metadata/metadata.sqlite",
    dataset: str = "bars_1m",
    mega: Optional[str] = None,
    arrow_dir: Optional[str] = None,
)
ArgumentTypeDefaultDescription
data_rootstr--Root directory of the store. A store written by ingest, import_csv or import_dataframe lives under <data_root>/mega and is opened as Arrow IPC on its own; without that directory, data_root is read as Parquet
metadata_dbstr"metadata/metadata.sqlite"Path to the SQLite metadata database, as given: a relative path is read from the current directory, not from data_root
datasetstr"bars_1m"Dataset table name. Auto-resolved based on bar_interval in most cases
megaOptional[str]NonePath to a consolidated "mega" store. When set, it is used instead of data_root (Parquet)
arrow_dirOptional[str]NonePath to an Arrow IPC store. When set, the store reads Arrow IPC files (multi-resolution handled internally) instead of Parquet. Precedence: arrow_dir > mega > data_root (Parquet)
python
store = mbt.DataStore(
    data_root="data",
    metadata_db="metadata/metadata.sqlite",
)

# A Parquet store with a dataset of its own. Naming one turns off the
# Arrow IPC detection above: a store written by ingest needs no dataset.
store = mbt.DataStore(
    data_root="data",
    dataset="bars_15m",
)

Portfolio

The Portfolio builder combines multiple strategies with allocation weights. Run with mbt.run_portfolio().

Portfolio Builder

MethodSignatureDescription
.strategy(s, w)strategy(strategy: Strategy, weight: float) -> PortfolioAdd a strategy, running on initial_capital * weight. Weights must sum to at most 1.0 and are not normalized
.max_drawdown(pct)max_drawdown(pct: float) -> PortfolioRecord a risk event when the combined drawdown exceeds this percentage. Reported on the finished curve; it closes nothing
.max_gross_exposure(pct)max_gross_exposure(pct: float) -> PortfolioNo effect in a portfolio run: the legs are independent backtests, each capped by its own max_position_pct
.rebalance_periodic(n)rebalance_periodic(every_n_bars: int) -> PortfolioNo effect in a portfolio run: allocations are set once, at the start
.rebalance_threshold(pct)rebalance_threshold(drift_pct: float) -> PortfolioNo effect in a portfolio run: allocations are set once, at the start
.no_rebalance()no_rebalance() -> PortfolioThe current behaviour: allocations are set once, at the start

run_portfolio()

run_portfolio(
    portfolio: Portfolio,
    config: BacktestConfig,
    store: DataStore,
) -> Result

Run a portfolio backtest. Returns a single Result with the combined equity curve, trades, and metrics.

python
# Build a portfolio of two strategies
portfolio = (
    mbt.Portfolio()
    .strategy(momentum_strat, weight=0.6)
    .strategy(mean_rev_strat, weight=0.4)
    .max_drawdown(15.0)  # reported on the combined curve
)

result = mbt.run_portfolio(portfolio, config, store)
print(result.summary())
mbt.plot.tearsheet(result, show=True)

Best Practices

01
signal_delay=0 is only sound at the close. With execution_price="AtClose" (the default) or NextBarClose, delay 0 means decide and fill on the same close, a valid convention. With any other execution price it would fill at a price that precedes the close that produced the signal, so since 0.27 the run raises a ConfigError: use signal_delay=1.
02
Set warmup_bars.Set it to at least the longest indicator window (e.g. 60 for zscore(60)) to avoid NaN-dominated early signals.
03
Use mbt.when() for sizing.It auto-coerces numbers and supports NaN (hold) as the default false branch. Cleaner than manual if/else arithmetic.
04
Run diagnostics before trusting results.Call detect_lookahead() and check_exposure_stability() on every new strategy. Call risk_check() on the result.
05
Start with hours(12) for fast iteration.Larger bar intervals load less data and simulate faster. Switch to minutes(1) only for final validation.
06
Use accuracy=True for final validation.When your strategy uses stop-loss or take-profit, accuracy mode runs the simulation on the finest stored bars for precise fill detection.
07
Use run_sweep_lite for large grids.It skips trade logging and Arrow output construction. For 100k+ parameter combos, it's an order of magnitude faster than run_sweep.
08
Be conservative with fees.Use taker fees by default. Maker fees assume passive limit orders. Use FeeConfig.binance_perps() as a baseline.

Licences & Activation

Manifold-BT works out of the box as Community edition. Pro unlocks the research tools, and Researcher adds the GPU and the tick layer to everything Pro has:

FeatureCommunityProResearcher
Simulation step1 minuteDown to 1 secondDown to 1 second, plus the trade clock (one row per print) and, for a strategy that quotes, a wake-up clock in milliseconds
Output resolutionDailyDown to 1 secondDown to 1 second, or per print on the trade clock
Sweeps, stability256 runs per session, all sweeps of the Python process togetherUnlimitedUnlimited
Monte Carlo1,000 paths, stochastic paths includedUnlimitedUnlimited
Walk-forwardNoAnchored, blocked, Pardo and custom geometriesThe same, on the GPU too
GPUNoNodevice="cuda" on sweeps, walk-forward, Monte Carlo and stochastic paths
Crypto connectorsYesYesYes
Databento, MassiveNoYesYes
Cross-exchangeNoYesYes
detect_lookaheadNoYesYes
Tick layerNoNoThe tape, the order book, order-by-order data, quotes and mbt.sim: see Tick Data

check_exposure_stability(), risk_check() and the tearsheets have no gate. Firm, for teams, includes everything in Researcher: see pricing.

Activate

After buying Pro or Researcher at manifoldbt.com (or receiving a trial), you get a short activation code by email, a UUID like 3f9c2a17-8b4e-4c6a-9f21-7d0e5a1b2c3d. Activate with that code, no key pasted in your source. The CLI is the recommended way:

bash
manifoldbt activate "3f9c2a17-8b4e-4c6a-9f21-7d0e5a1b2c3d"

Or from Python:

python
import manifoldbt as mbt

mbt.activate("3f9c2a17-8b4e-4c6a-9f21-7d0e5a1b2c3d")
print(mbt.license_info())
# ("Pro", "you@email.com")

The first activation needs an internet connection: the code is exchanged once for your signed license, which is then saved locally together with a signed lease from the licensing server. You only activate once per machine. After that the license loads automatically on every import, and the check runs locally: the engine verifies the signatures of the license and of the lease and reads the lease's expiry, without waiting for the network. While a valid lease is on disk, the import also starts a renewal in the background, which sends the license ID, its email and a hash of the machine and replaces the lease when the server answers.

Offline, that renewal simply fails and the license keeps working until the stored lease expires. Once it has expired, or if none is stored, the import waits up to 5 seconds for the server; without an answer, the paid features refuse with an error asking for a connection and the engine runs as Community. The license file stays in place, and the next import that reaches the server restores them. Without a license installed, the engine makes no licensing request.

Trial licenses

A trial code activates exactly like a full license, but carries an expiry date signed into the license. Until it expires it unlocks everything its plan does, offline included. Once the expiry passes, the engine reverts to Community automatically, enforced offline by the engine and confirmed by the licensing server. To extend or convert a trial, activate a new code.

License file

The key is stored at:

OSPath
Windows%LOCALAPPDATA%\manifoldbt\license\license.key
macOS~/Library/Application Support/manifoldbt/license/license.key
Linux~/.local/share/manifoldbt/license/license.key

To deactivate, delete the file:

bash
# macOS / Linux
rm ~/.local/share/manifoldbt/license/license.key

# Windows (PowerShell)
Remove-Item "$env:LOCALAPPDATA\manifoldbt\license\license.key"

Verify

python
tier, email = mbt.license_info()
print(tier)   # "Researcher", "Pro" or "Community"
print(email)  # your email or None

Features are gated at runtime. A Pro feature called on Community (e.g. run_walk_forward()), or the GPU called without Researcher, raises a LicenseError with a clear message. The tick layer called without Researcher raises a PermissionError that names the plan, before anything is downloaded. license_info() reports the actual tier: a Researcher licence reports "Researcher" and includes everything Pro has, so a check for a paid tier should accept both "Pro" and "Researcher".

See also

The rest of the site, where it goes further than this reference. Each link opens in the full window.