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.26.0
("Community", None)

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 existing data automatically (dedup by timestamp).

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.

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.

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.

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.

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", "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

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, vwap, timestamp.

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 = 14) -> ExprDouble Exponential Moving Average
.tema(p)tema(period: int = 14) -> ExprTriple Exponential Moving Average
.wma(p)wma(period: int = 14) -> ExprWeighted Moving Average
.hma(p)hma(period: int = 14) -> ExprHull Moving Average
.kama(p)kama(period: int = 10) -> ExprKaufman Adaptive Moving Average
.roc(p)roc(period: int = 1) -> 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.

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 -- an undeclared 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(). 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, Pro). 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 Pro

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, same validation as run_sweep()
devicestr"auto" (default), "cpu" or "cuda". The per-fold selection is a sweep, so it runs on the card. "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.

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.

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 + Pro license are present, else the CPU.
# Force it either way with device="cuda" (Pro) 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; 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
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 timestamp. Use time_range("2021-01-01", "2026-01-01")
time_range_endint (ns)End timestamp
bar_intervaldictBar resolution. Use Interval.minutes(1), Interval.hours(12), etc. Community stops at Interval.minutes(1), Pro goes down to Interval.seconds(1)
initial_capitalfloatStarting capital (default 1000.0)
warmup_barsintBars to skip before trading (let indicators stabilize)
accuracyboolWhen True, simulation runs on 1m bars (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))

Interval Helpers

python
Interval.seconds(1)    # {"Seconds": 1}
Interval.minutes(1)    # {"Minutes": 1}
Interval.hours(12)     # {"Hours": 12}
Interval.days(1)       # {"Days": 1}

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)
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,
    orders: Optional[OrderConfig] = None,
)
FieldTypeDefaultDescription
signal_delayint0Bars between the signal bar and the execution bar. 0 is sound with AtClose or NextBarClose: decide and fill on the same close. With any other execution price (AtOpen, AtVwap, MidPrice, NextBarOpen, custom) a delay of 0 fills at a price that precedes the close that produced the signal: use 1
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)
ordersOrderConfig | NoneNoneOrder management: SL, TP, trailing stops

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

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

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.

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)
)

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 engine automatically selects the optimal dataset based on bar_interval:

bar_intervalDatasetNotes
< 1mbars_1s or provider/1s/Finest resolution, Pro (Community stops at 1m)
1mbars_1m or provider/1m/Finest resolution on Community
> 1m, <= 1hbars_1h or provider/1h/Resampled from 1h bars
> 1hbars_1h or provider/1h/Resampled from coarsest available

The engine tries the provider layout first (binance/1h/BTCUSDT.arrow), then falls back to legacy (bars_1h/201.arrow). Non-exact intervals (e.g. 4h, 1d) are resampled from the finest available resolution.

Accuracy Mode

When accuracy=True, the engine always loads bars_1m. Signals are evaluated at bar_interval resolution, but the simulation loop runs on 1-minute 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 1m
    ...
)

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.

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")

Diagnostics Pro

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

detect_lookahead()

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 bt.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 but not exposed as one.

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

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"}
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 to a (start_ns, end_ns) tuple. Accepts "YYYY-MM-DD" and "YYYY-MM-DD HH:MM:SS" formats.

date_to_ns(date_str: str) -> int

Convert a single date string to nanoseconds since 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 21 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

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(Pro
    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=False, drift=False,
    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)
zonesboolFalseOverlay robustness zones -- contiguous regions where the metric stays stable across neighboring parameter combinations, distinct from the plateau-optimal highlight above
driftboolFalseAnnotate how far each robustness zone's metric drifts from its cell's raw value
surface_3d(
    sweep_result: dict,
    *, highlight_best=True, zones=False, drift=False,
    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
zonesboolFalseOverlay robustness zones, same semantics as heatmap_2d
driftboolFalseAnnotate metric drift within each robustness zone, 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 bt.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 containing bar data. Read as Parquet by default; pass arrow_dir for an Arrow IPC store instead
metadata_dbstr"metadata/metadata.sqlite"Path to the SQLite metadata database (relative to 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",
)

# Or with explicit dataset override
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) -> PortfolioAccepted, not yet applied
.rebalance_periodic(n)rebalance_periodic(every_n_bars: int) -> PortfolioAccepted, not yet applied
.rebalance_threshold(pct)rebalance_threshold(drift_pct: float) -> PortfolioAccepted, not yet applied
.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 fills at a price that precedes the close that produced the signal: use signal_delay=1. The engine does not refuse the combination.
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 1-minute 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.

Pro Activation

Manifold-BT works out of the box as Community edition. Pro unlocks additional features:

FeatureCommunityPro
Simulation resolution1 minuteDown to 1 second
Output resolutionDailyDown to 1 second
Monte Carlo simulations1,000Unlimited
Walk-forward analysis—Anchored, blocked, Pardo and custom geometries
Parameter stability—Full
Crypto connectors (Binance, Hyperliquid)YesYes
Databento & Massive connectors—Yes
Safety checks (lookahead, exposure)—Yes

Activate

After buying Pro 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. After that it loads automatically on every import, fully offline. You only activate once per machine.

Trial licenses

A trial code activates exactly like a full license, but carries an expiry date signed into the license. Until it expires it is full Pro, 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)   # "Pro" or "Community"
print(email)  # your email or None

Pro features are gated at runtime. If a Community user calls a Pro-only function (e.g. run_walk_forward()), a LicenseError is raised with a clear message.

See also

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