Documentation
Define trading strategies using the manifoldbt Python DSL. Strategies compile into vectorized expression graphs executed by the Rust engine.
Python 3.9+ on Linux, macOS, or Windows.
The engine on its own, enough to run backtests and read metrics:
pip install manifoldbt
Add interactive charts (Plotly) and native chart windows, needed for anything in Visualization:
pip install manifoldbt[plot]
For everything (charts, windows, static PNG/SVG export, pandas/polars, rich progress bars):
pip install manifoldbt[all]
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)
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:
store = mbt.DataStore(
data_root="data",
metadata_db="metadata/metadata.sqlite",
arrow_dir="data/mega",
)
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().
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)Use mbt.ingest() to download bar data from supported providers directly into your local store. Returns a DataStore ready for backtesting.
# 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 v4 perpetual futures via the Indexer API. No API key required. Supports candles (1m–1d), trades, and funding rates.
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 spot market data via the public REST API v2. No API key required. Supports OHLC candles (1m–1d) and recent trades.
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
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.
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 class | Tickers | History starts |
|---|---|---|
| FX majors, gold and silver | EUR/USD, USD/JPY, XAU/USD, XAG/USD | 2003-05-04 |
| Indices (CFD) | USA500.IDX/USD, USATECH.IDX/USD, DEU.IDX/EUR | 2012 |
| Commodities (CFD) | BRENT.CMD/USD, LIGHT.CMD/USD | 2013 |
| US stocks (CFD) and crypto | AAPL.US/USD, NVDA.US/USD, BTC/USD | 2017 |
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.
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.
# 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",
)
Requires a Pro license and a MASSIVE_API_KEY environment variable. Covers stocks, ETFs, futures, options, forex, indices, and crypto.
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",
)
Data can also be ingested from the command line:
manifoldbt ingest --provider binance --symbol BTCUSDT --symbol-id 1 \
--start 2025-01-01T00:00:00Z --end 2025-03-01T00:00:00Z
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.
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)
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.
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)
| Parameter | Required | Default | Description |
|---|---|---|---|
data | yes | pandas or Polars DataFrame with OHLCV columns | |
symbol | yes | Ticker symbol (e.g. "BTCUSDT") | |
symbol_id | yes | Unique 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 |
| Parameter | Required | Default | Description |
|---|---|---|---|
provider | yes | "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 | |
start | yes | RFC3339 start timestamp | |
end | yes | RFC3339 end timestamp | |
interval | "1m" | Bar interval: 1s, 1m, 5m, 15m, 30m, 1h, 4h, 1d. Simulating below 1m needs Pro | |
dataset | Provider-specific: the Databento dataset (e.g. "GLBX.MDP3"), or "ask" / "both" on Dukascopy | ||
data_root | "data" | Output directory for Arrow IPC files | |
progress | True | Show rich progress bar (requires rich) | |
exchange | provider name | Exchange name for metadata | |
asset_class | "crypto_spot" | crypto_spot, crypto_perp, equity, future, forex |
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.
sma(source: Expr, period: int | param) -> Expr
Simple Moving Average. Period can be a literal int or a param() for sweeps.
| Argument | Type | Default | Description |
|---|---|---|---|
| source | Expr | -- | Input series (e.g. close) |
| period | int | param | -- | Lookback window |
ema(source: Expr, span: float | int | param) -> Expr
Exponential Moving Average (span-based, alpha = 2/(span+1)).
| Argument | Type | Default | Description |
|---|---|---|---|
| source | Expr | -- | Input series |
| span | float | 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.
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 14rsi(source: Expr, period: int = 14) -> Expr
Relative Strength Index (Wilder's smoothing, single-pass O(n)). Returns values in [0, 100].
| Argument | Type | Default | Description |
|---|---|---|---|
| source | Expr | -- | Input price series |
| period | int | param | 14 | Lookback 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).
| Argument | Type | Default | Description |
|---|---|---|---|
| source | Expr | -- | Input price series |
| fast_period | int | 12 | Fast EMA span |
| slow_period | int | 26 | Slow EMA span |
| signal_period | int | 9 | Signal 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).
from manifoldbt.indicators import close, rsi, macd, adx
my_rsi = rsi(close, 14)
macd_line, signal_line, histogram = macd(close)
trend_strength = adx(14)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).
| Argument | Type | Default | Description |
|---|---|---|---|
| source | Expr | -- | Input price series |
| period | int | 20 | SMA lookback window |
| num_std | float | 2.0 | Number 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).
| Argument | Type | Default | Description |
|---|---|---|---|
| period | int | 20 | EMA and ATR lookback window |
| multiplier | float | 1.5 | ATR multiplier for channel width |
supertrend(period: int = 10, multiplier: float = 3.0) -> Expr
SuperTrend indicator (native Rust, uses high/low/close).
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)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(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.
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)).
parabolic_sar(af_start: float = 0.02, af_max: float = 0.2) -> Expr
Parabolic SAR (native Rust, uses high/low).
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.
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.
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() < 5kalman(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.
| Argument | Type | Default | Description |
|---|---|---|---|
| source | Expr | close | Input price series |
| q | float | 1e-5 | Process noise covariance |
| r | float | 1e-2 | Measurement 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.
| Argument | Type | Default | Description |
|---|---|---|---|
| source | Expr | close.pct_change(1) | Return series |
| omega | float | 1e-6 | Long-run variance weight |
| alpha | float | 0.1 | Weight on lagged squared return (ARCH term) |
| beta | float | 0.85 | Weight on lagged conditional variance (GARCH term) |
rolling_median(source: Expr, window: int) -> Expr
Rolling median (native Rust).
Every Expr exposes chainable methods for rolling computations. All methods return a new Expr.
| Method | Signature | Description |
|---|---|---|
| .lag(n) | lag(n: int) -> Expr | Value at t - n |
| .lead(n) | lead(n: int) -> Expr | Value at t + n |
| .diff(n) | diff(n: int = 1) -> Expr | x[t] - x[t-n] |
| .pct_change(n) | pct_change(n: int = 1) -> Expr | Fractional change |
| .rolling_mean(w) | rolling_mean(window: int) -> Expr | Rolling mean (SMA) |
| .rolling_std(w) | rolling_std(window: int) -> Expr | Rolling standard deviation |
| .rolling_sum(w) | rolling_sum(window: int) -> Expr | Rolling sum |
| .rolling_min(w) | rolling_min(window: int) -> Expr | Rolling minimum |
| .rolling_max(w) | rolling_max(window: int) -> Expr | Rolling maximum |
| .rolling_median(w) | rolling_median(window: int) -> Expr | Rolling median |
| .ewm_mean(s) | ewm_mean(span: float) -> Expr | Exponentially weighted mean |
| .zscore(w) | zscore(window: int) -> Expr | (x - mean) / std over window |
| .rsi(p) | rsi(period: int = 14) -> Expr | Relative Strength Index |
| .cumsum() | cumsum() -> Expr | Cumulative sum |
| .cumprod() | cumprod() -> Expr | Cumulative product |
| .rank() | rank() -> Expr | Rank 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() -> Expr | Cross-sectional mean (multi-asset) |
| .cs_rank() | cs_rank() -> Expr | Cross-sectional rank (multi-asset) |
| .cross_above(other) | cross_above(other: Expr) -> Expr | True when self crosses above other |
| .cross_below(other) | cross_below(other: Expr) -> Expr | True when self crosses below other |
| .of_symbol(sym) | of_symbol(symbol: str) -> Expr | Reference column from another symbol |
| .hour() | hour() -> Expr | Extract hour (0-23 UTC) |
| .minute() | minute() -> Expr | Extract minute (0-59) |
| .day_of_week() | day_of_week() -> Expr | Extract day of week (0=Mon) |
| .month() | month() -> Expr | Extract month (1-12) |
| .day_of_month() | day_of_month() -> Expr | Extract day of month (1-31) |
| .dema(p) | dema(period: int = 14) -> Expr | Double Exponential Moving Average |
| .tema(p) | tema(period: int = 14) -> Expr | Triple Exponential Moving Average |
| .wma(p) | wma(period: int = 14) -> Expr | Weighted Moving Average |
| .hma(p) | hma(period: int = 14) -> Expr | Hull Moving Average |
| .kama(p) | kama(period: int = 10) -> Expr | Kaufman Adaptive Moving Average |
| .roc(p) | roc(period: int = 1) -> Expr | Rate of Change |
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))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:
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 holdsThe 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.
| Operator | Bar count | Duration |
|---|---|---|
rolling_mean, rolling_sum, rolling_std, rolling_min, rolling_max | yes | yes |
zscore, rolling_var | yes | yes |
rolling_corr, rolling_cov, rolling_beta | yes | yes |
count_over | yes | yes |
ewm_mean | span= | halflife= |
everything else (rsi, atr, rolling_median, lag, pivots, ...) | yes | refused 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.
Strategies use the fluent builder pattern. Add named signals with .signal() and set the position sizing expression with .size().
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.
| Argument | Type | Default | Description |
|---|---|---|---|
| name | str | -- | Parameter name (must be unique) |
| default | Any | None | Default value |
| range | (min, max) | None | Bounds for sweeps |
| description | str | "" | 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.
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).
| Argument | Type | Default | Description |
|---|---|---|---|
| condition | Expr | -- | Boolean expression |
| true_value | Any | 1.0 | Value when condition is true |
| false_value | Any | NaN | Value when condition is false (NaN = hold) |
# 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)For expressions that start with a Python number, use mbt.lit() to create an explicit literal:
# This works -- Expr on left side
weighted = zscore * 0.5
# This needs lit() -- number on left side
inverse = mbt.lit(1.0) - zscore| Value | Meaning |
|---|---|
| 1.0 | Full long position (clamped by max_position_pct) |
| 0.5 | Half position |
| 0.0 | Go flat -- exit all positions |
| -0.5 | Half short (requires allow_short=True) |
| NaN | Hold current position unchanged |
| Mode | Description |
|---|---|
| FractionOfEquity | Target 1.0 = 100% of current equity (compounds) |
| FractionOfInitialCapital | Target 1.0 = 100% of initial capital (no compounding) |
| Units | Target 1.0 = 1 unit (share/contract/coin) |
execution = mbt.ExecutionConfig(
position_sizing_mode="FractionOfEquity", # default
max_position_pct=1.0,
)Use mbt.param() in indicator periods. Parameters are auto-collected from all signal expressions -- no .param() call on Strategy needed.
# 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(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().
| Argument | Type | Default | Description |
|---|---|---|---|
| strategy | Strategy | -- | Strategy definition |
| param_grid | dict | -- | 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 |
| config | BacktestConfig | -- | Backtest configuration |
| store | DataStore | -- | Data store |
| max_parallelism | int | 0 | Max threads. 0 = all cores |
The SweepResult returned by run_sweep() is an iterable, indexable collection of results with convenience methods for analysis.
| Method / Attr | Signature | Description |
|---|---|---|
| .best(metric) | best(metric: str) -> Result | Return the result with the highest value for the given metric (e.g. "sharpe") |
| .worst(metric) | worst(metric: str) -> Result | Return the result with the lowest value for the given metric |
| .to_df() | to_df(backend: str = "auto") -> DataFrame | All results as a DataFrame with params + metrics columns |
| len(sweep) | __len__() -> int | Number of results in the sweep |
| sweep[i] | __getitem__(index: int) -> Result | Access 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.
| Argument | Type | Default | Description |
|---|---|---|---|
| max_parallelism | int | 0 | Max worker threads for the CPU path (0 = all cores) |
| device | str | "auto" | "auto" (dispatch by grid size), "cpu" (Rayon), or "cuda" (GPU sweep, Pro). See below |
| precision | str | "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.
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 key | Type | Description |
|---|---|---|
| geometry | str | "anchored" (default), "blocked", "pardo" or "custom" |
| n_splits | int | Number of folds. anchored/blocked only, derived otherwise |
| train_ratio | float | Training fraction in (0, 1). anchored/blocked only |
| train | dict | pardo: {"length": Interval.days(365)}, a fixed window that slides. custom: {"mode": "anchored", "min_length": ...} or {"mode": "rolling", "length": ...} |
| test | dict | {"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_metric | str | e.g. "sharpe", "sortino" |
| param_grid | dict | Parameter grid for optimization. Every key must be declared with mbt.param() in the strategy, same validation as run_sweep() |
| device | str | "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_parallelism | int | Max threads |
Returned dict:
| Key | Description |
|---|---|
| folds | One 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_fold | The winning parameter set of each fold, in order |
| n_folds | Number of folds produced |
| folds_overlap | True when test windows overlap (step < length): the same date is then tested by several folds |
| effective_folds | Independent 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_efficiency | Pardo'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 key | Type | Description |
|---|---|---|
| x_param | str | First parameter name |
| x_values | list | Values for x_param |
| y_param | str | Second parameter name |
| y_values | list | Values for y_param |
| metric | str | Metric to collect (e.g. "sharpe") |
| max_parallelism | int | Max 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 key | Type | Description |
|---|---|---|
| param_name | str | Parameter to vary |
| values | list | Values to test |
| metric | str | Metric to evaluate |
| max_parallelism | int | Max threads |
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(
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.
| Argument | Type | Description |
|---|---|---|
| batch | list[BatchResultLite] | The list returned by run_sweep_lite() |
| names | str | 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) |
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).
| Argument | Type | Default | Description |
|---|---|---|---|
| model | str | StochasticModel | -- | Preset name ("gbm", "heston", "merton", "garch_jd") or a StochasticModel instance |
| s0 | float | 100.0 | Initial price |
| n_paths | int | 1000 | Number of simulation paths (Community: max 1000) |
| n_steps | int | 252 | Time steps per path |
| dt | float | 1/252 | Time step in years (1/252 = daily, 1/252/390 = minute) |
| params | dict | None | None | Parameter overrides (merged with model defaults) |
| seed | int | None | None | RNG seed for reproducibility |
| store_paths | bool | False | Store full price paths (memory-intensive, CPU only) |
| device | str | "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]) |
| Preset | SDE | Default Params |
|---|---|---|
"gbm" | dS = μSdt + σSdW | mu=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−μ)²+βh | mu=0.08, omega=1e-6, alpha=0.1, beta=0.85, lambda=5, mu_j=-0.02, sigma_j=0.04 |
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)
| Argument | Type | Description |
|---|---|---|
| drift | str | Drift expression (μ). E.g. "mu" or "mu - 0.5 * h" |
| diffusion | str | Diffusion expression (σ). E.g. "sigma" or "sqrt(h)" |
| jump_intensity | str | Jump rate (λ). E.g. "lambda" |
| jump_size | str | Jump magnitude. E.g. "normal(mu_j, sigma_j)" |
| state_vars | dict | Extra state variables with initial values. E.g. {"h": 1e-4} |
| state_update | dict | Update expressions for state vars after each step |
| params | dict | Model 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.
# 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)| Field | Type | Description |
|---|---|---|
| universe | List[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_start | int (ns) | Start timestamp. Use time_range("2021-01-01", "2026-01-01") |
| time_range_end | int (ns) | End timestamp |
| bar_interval | dict | Bar resolution. Use Interval.minutes(1), Interval.hours(12), etc. Community stops at Interval.minutes(1), Pro goes down to Interval.seconds(1) |
| initial_capital | float | Starting capital (default 1000.0) |
| warmup_bars | int | Bars to skip before trading (let indicators stabilize) |
| accuracy | bool | When True, simulation runs on 1m bars (hybrid mode) |
| output_resolution | dict | Downsample output timeseries. Community: daily. Pro: down to 1 second |
| trading_days_per_year | float | Annualization factor (365.25 crypto, 252 equities) |
| risk_free_rate | float | Annual 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_names | dict | Name-to-ID mapping for cross-asset references |
| currency | str | Quote currency for the portfolio (default "USD") |
| execution | ExecutionConfig | Execution settings (signal delay, fill model, sizing mode, etc.) |
| fees | FeeConfig | Fee model. Use FeeConfig.binance_perps() or construct manually |
| slippage | Any | Slippage model. Use Slippage.fixed_bps(), .volume_impact(), etc. |
| exo_data | List[str] | Exogenous data series names to inject (e.g. ["hashrate", "fear_greed"]). See Exogenous Data |
| extra_timeframes | Dict[str, dict] | Additional timeframes for multi-TF strategies (e.g. {"4h": Interval.hours(4)}) |
| rng_seed | Optional[int] | Random seed for reproducible Monte Carlo and stochastic fills |
| resample_to | Optional[dict] | Resample loaded bars before simulation (e.g. Interval.hours(4)) |
Interval.seconds(1) # {"Seconds": 1}
Interval.minutes(1) # {"Minutes": 1}
Interval.hours(12) # {"Hours": 12}
Interval.days(1) # {"Days": 1}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",
)
| Field | Type | Default | Description |
|---|---|---|---|
| maker_fee_bps | float | 0.0 | Maker fee in basis points |
| taker_fee_bps | float | 0.0 | Taker fee in basis points |
| funding_rate_column | str | None | None | Column name for funding rate (perps only) |
| borrow_rate_annual_bps | float | 0.0 | Annual borrow rate in bps (for shorts) |
| min_fee | float | 0.0 | Minimum fee per trade |
| default_fill_type | str | "Taker" | "Maker" or "Taker" (conservative default) |
| Preset | Maker | Taker | Funding |
|---|---|---|---|
| FeeConfig.binance_perps() | 2 bps | 5 bps | funding_rate column |
| FeeConfig.binance_spot() | 10 bps | 10 bps | None |
| FeeConfig.zero() | 0 | 0 | None |
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.
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).
| Argument | Type | Description |
|---|---|---|
| bps | float | Slippage 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.
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.
| Argument | Type | Default | Description |
|---|---|---|---|
| impact_coeff | float | -- | Impact coefficient. Higher = more slippage. Typical: 0.05-0.3 |
| exponent | float | 1.5 | Power 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.
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.
| Argument | Type | Default | Description |
|---|---|---|---|
| spread_fraction | float | 1.0 | Fraction 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.
Slippage.none() -> dict
Zero slippage. Use only for debugging or when slippage is already embedded in the fill price model.
# 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(
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,
)
| Field | Type | Default | Description |
|---|---|---|---|
| signal_delay | int | 0 | Bars 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_price | str | "AtClose" | Fill price: AtClose, AtOpen, AtVwap, NextBarOpen, NextBarClose, NextBarVwap, MidPrice |
| max_position_pct | float | 1.0 | Maximum position as fraction of equity |
| allow_short | bool | True | Allow negative positions |
| allow_fractional | bool | True | Allow fractional units |
| skip_gap_bars | bool | False | Skip bars with gaps in data |
| position_sizing_mode | str | "FractionOfEquity" | FractionOfEquity, FractionOfInitialCapital, or Units |
| pyramiding | bool | False | When True, signal is a delta to ADD to current position each bar |
| fill_model | dict | None | None | Fill model config. Use FillModel.participation(0.1) |
| orders | OrderConfig | None | None | Order management: SL, TP, trailing stops |
| Constant | Value | Description |
|---|---|---|
| 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.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
Attach stop-loss, take-profit, and trailing stop orders via the fluent builder:
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
)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.
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:
from manifoldbt.config import OrderConfig
execution = mbt.ExecutionConfig(
orders=OrderConfig.bracket(stop_pct=3.0, profit_pct=5.0),
)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.
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.
result = mbt.run(strategy, config, store)
print(result.brackets_not_armed) # 0 when every trade got its bracketA 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.
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.
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)| Builder | Behaviour |
|---|---|
| .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.
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 ..."The engine automatically selects the optimal dataset based on bar_interval:
| bar_interval | Dataset | Notes |
|---|---|---|
| < 1m | bars_1s or provider/1s/ | Finest resolution, Pro (Community stops at 1m) |
| 1m | bars_1m or provider/1m/ | Finest resolution on Community |
| > 1m, <= 1h | bars_1h or provider/1h/ | Resampled from 1h bars |
| > 1h | bars_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.
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.
config = mbt.BacktestConfig(
bar_interval=Interval.hours(12),
accuracy=True, # hybrid: signals on 12h, sim on 1m
...
)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.
Use mbt.register_exo() to write a DataFrame to the local store. Requires a timestamp column and one or more value columns.
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.arrowList the exo series names in exo_data:
config = mbt.BacktestConfig(
exo_data=["hashrate", "fear_greed"],
...
)Access exo columns with the exo() helper or directly via col():
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)| Argument | Type | Default | Description |
|---|---|---|---|
| name | str | -- | Series identifier (e.g. "hashrate") |
| data | DataFrame | dict | -- | Must have a timestamp column. Value columns cast to Float64 |
| store | DataStore | None | Target store. If None, uses default data root |
| provider | str | None | Provider for unified layout (e.g. "binance"). Omit for global exo |
| timeframe | str | "1d" | Timeframe label for unified layout |
Access columns from other symbols using mbt.symbol_ref() or the .of_symbol() method. This enables stat-arb, relative-value, and basket strategies.
# 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))
)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.
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:
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).
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)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.
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.
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)
)Declare the extra timeframes in BacktestConfig. The label (e.g. "4h") must match what you pass to mbt.tf().
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
)mbt.tf(label) returns a TimeframeRef with shortcuts for OHLCV columns:
| Property | Equivalent |
|---|---|
tf("4h").open | col("4h.open") |
tf("4h").high | col("4h.high") |
tf("4h").low | col("4h.low") |
tf("4h").close | col("4h.close") |
tf("4h").volume | col("4h.volume") |
tf("4h").col("x") | col("4h.x") |
The manifoldbt.diagnostics module provides automated checks to catch common strategy bugs before you trust your results.
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.
| Argument | Type | Default | Description |
|---|---|---|---|
| strategy | Strategy | -- | Strategy definition |
| config | BacktestConfig | -- | Backtest configuration |
| store | DataStore | -- | Data store |
| mode | str | "all" | "all", "extension", or "truncation" |
| tolerance | float | 1e-9 | Float comparison tolerance for quantity/price/fees |
Returns DiagnosticsResult with .passed (bool), .assert_clean() (raises on failure), and .reports (list of sub-test results).
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 detectedcheck_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.
| Argument | Type | Default | Description |
|---|---|---|---|
| strategy | Strategy | -- | Strategy definition |
| config | BacktestConfig | -- | Backtest configuration |
| store | DataStore | -- | Data store |
| mode | str | "all" | "all", "extension", or "truncation" |
| tolerance | float | 1e-6 | Absolute tolerance for float comparisons |
Returns ExposureDiagnosticsResult with .passed, .assert_clean().
from manifoldbt.diagnostics import check_exposure_stability
report = check_exposure_stability(strategy, config, store)
print(report)
report.assert_clean()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.
| Argument | Type | Default | Description |
|---|---|---|---|
| result | Result | -- | BacktestResult from bt.run() |
| max_utilization | float | 0.95 | Fail if peak utilization exceeds this |
| min_free_margin | float | 0.05 | Fail if free margin ratio drops below this |
| max_exposure_ratio | float | 3.0 | Fail if exposure / initial_capital exceeds this |
| max_utilization_trend | float | 1e-4 | Warn if utilization slope per bar exceeds this |
| max_concentration | float | 0.95 | Warn 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.
from manifoldbt.diagnostics import risk_check
report = risk_check(result, max_utilization=0.95, min_free_margin=0.05)
print(report)
report.assert_clean()Every result includes a profile dict with microsecond-resolution timing for each execution phase:
| Key | Description |
|---|---|
| data_load_us | Time to load and deserialize bar data from disk |
| align_us | Time to align multi-symbol timestamps |
| signal_eval_us | Time to evaluate the expression graph |
| runtime_prep_us | Time to prepare simulation runtime |
| simulation_us | Time to run the core simulation loop |
| output_build_us | Time to build Arrow output arrays |
| total_us | Total wall-clock time |
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% ######################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.
result.equity_df(backend: str = "auto") -> DataFrame
Equity curve as a DataFrame with timestamp and equity columns.
| Argument | Type | Default | Description |
|---|---|---|---|
| backend | str | "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.
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.
| Column | Type | Description |
|---|---|---|
| signal_timestamp | datetime (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_timestamp | datetime (ns, UTC) | Bar on which the fill happened, after signal_delay and any partial-fill wait |
| symbol_id | uint32 | The integer id from your universe. Names are not stored: join on the universe mapping you gave BacktestConfig |
| side | uint8 | 1 = buy, 2 = sell |
| quantity | float64 | Units filled, always positive; the direction is in side |
| intended_price | float64 | Price the order was aimed at: the trigger level for a bracket exit, the reference price otherwise |
| fill_price | float64 | Average price actually paid |
| fees | float64 | Fees charged on this fill, in the account currency |
| slippage | float64 | Slippage 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_bar | bool | True 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_reason | uint8 | Why the position changed, coded as below. 0 on every entry |
| exit_reason | Meaning |
|---|---|
| 0 | Signal: the sizing expression moved the target position. Entries and plain exits alike |
| 1 | Stop loss |
| 2 | Take profit |
| 3 | Trailing stop |
| 4 | Limit entry expired unfilled. Defined for completeness: an unfilled order produces no fill, so no row carries it today |
| 5 | Option contract reached expiry and was cash-settled at intrinsic value |
| 6 | Margin 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:
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")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.).
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.
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.
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.
The Strategy class supports both direct construction and a fluent builder pattern.
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,
)
| Method | Signature | Description |
|---|---|---|
| Strategy.create(name) | create(name: str) -> Strategy | Create an empty strategy for chaining |
| .signal(name, expr) | signal(name: str, expr: Expr) -> Strategy | Add a named signal expression |
| .size(expr) | size(expr: Expr) -> Strategy | Set the position sizing expression |
| .param(name, ...) | param(name, default, range, description) -> Strategy | Register a sweep parameter |
| .stop_loss(pct) | stop_loss(pct: float = None, side="both", *, signal: str = None) -> Strategy | Attach 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) -> Strategy | Attach 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) -> Strategy | Attach a trailing stop, on one side or both |
| .describe(text) | describe(text: str) -> Strategy | Set strategy description |
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")
)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).
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),
)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.
| Metric | Description |
|---|---|
| total_return | Cumulative return over the period |
| cagr | Compound annual growth rate |
| volatility | Annualized standard deviation of returns |
| sharpe | Annualized 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) |
| sortino | Sortino 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 |
| calmar | CAGR / max drawdown |
| max_drawdown | Largest peak-to-trough decline |
| tstat_sharpe | T-statistic of the Sharpe ratio |
| alpha | Jensen's alpha (vs buy-and-hold) |
| beta | Beta to the underlying asset |
| tstat_alpha | T-statistic of alpha |
| skewness | Return distribution skewness |
| kurtosis | Return 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_ratio | Sum of returns above zero / sum of returns below zero |
| ulcer_index | Root mean square of the drawdown series on daily equity. Prices depth and duration, where max_drawdown only sees the single worst point |
| best_day | Best single-day return |
| worst_day | Worst single-day return |
| avg_daily_return | Mean daily return over the period |
| pct_positive_days | Fraction of days with positive returns |
| max_drawdown_duration_days | Longest stretch, in days, from an equity peak to the recovery of that peak. Measured to the last bar when the curve never recovers |
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.
| Metric | Description |
|---|---|
| total_trades | Number of fills, entries and exits alike. Not the number of round trips |
| round_trips | Number of completed round trips: an entry through the return to flat |
| win_rate | Fraction of profitable round trips |
| profit_factor | Gross profit / gross loss |
| expectancy | Average P&L per round trip |
| avg_win | Average winning trade P&L |
| avg_loss | Average losing trade P&L |
| max_consecutive_wins | Longest streak of winning round trips |
| max_consecutive_losses | Longest streak of losing round trips |
| avg_holding_seconds | Mean time in position per round trip, in seconds |
| median_holding_seconds | Median time in position, in seconds |
| min_holding_seconds | Shortest time in position, in seconds |
| max_holding_seconds | Longest time in position, in seconds |
| total_fees | Cumulative fees paid |
| sl_exits | Exits triggered by the stop loss |
| tp_exits | Exits triggered by the take profit |
| trailing_exits | Exits triggered by the trailing stop |
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.
| Metric | Description |
|---|---|
| avg_mae | Average Maximum Adverse Excursion, as a fraction of entry price: how far the average trade went against you before it closed |
| avg_mfe | Average Maximum Favorable Excursion, as a fraction of entry price: how far it went in your favor |
| avg_entry_efficiency | Mean of MFE / (MFE + MAE). Near 1.0 = entries land close to the best price the trade ever offered |
| avg_exit_efficiency | Mean of realized move / MFE. Near 1.0 = exits capture the favorable move instead of giving it back |
| edge_ratio | avg_mfe / avg_mae. Above 1 means trades run further in your favor than against you |
The mbt.plot module provides publication-quality charts. Install with pip install manifoldbt[plot].
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.
| Argument | Type | Default | Description |
|---|---|---|---|
| plotlyjs | str | "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 |
| dpi | int | 150 | Matplotlib-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.
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.
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.
| Argument | Type | Default | Description |
|---|---|---|---|
| sweep_result | dict | -- | Output of run_sweep_2d() |
| annotate | bool | True | Show values in cells (if grid <= 100 cells) |
| fmt | str | ".3f" | Number format for annotations |
| highlight_best | bool | True | Highlight the plateau-optimal cell (Gaussian-smoothed) |
| zones | bool | False | Overlay robustness zones -- contiguous regions where the metric stays stable across neighboring parameter combinations, distinct from the plateau-optimal highlight above |
| drift | bool | False | Annotate 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.
| Argument | Type | Default | Description |
|---|---|---|---|
| sweep_result | dict | -- | Output of run_sweep_2d() |
| elev | float | 30 | Camera elevation angle |
| azim | float | -45 | Camera azimuth angle |
| highlight_best | bool | True | Mark the best point with a white dot |
| zones | bool | False | Overlay robustness zones, same semantics as heatmap_2d |
| drift | bool | False | Annotate 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.
| Argument | Type | Default | Description |
|---|---|---|---|
| result | Result | -- | BacktestResult from bt.run() |
| n_simulations | int | 1000 | Number of simulated paths (capped at 1000 for Community) |
| method | str | "bootstrap" | "bootstrap" (tail risk) or "permutation" (path-dependency) |
| percentiles | list[int] | [5,25,50,75,95] | Percentile levels for bands |
| n_sample_paths | int | 50 | Number of individual paths to draw (faded). 0 to disable |
| seed | int | None | None | Random 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.
| Argument | Type | Default | Description |
|---|---|---|---|
| wf_result | dict | -- | Output of run_walk_forward() |
| mode | str | "auto" | "auto", "equity", "bars", or "stitched" |
| full_result | Result | None | None | Full 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().
| Argument | Type | Default | Description |
|---|---|---|---|
| result | dict | -- | Output of run_stochastic(..., store_paths=True) |
| percentiles | list[int] | [5,25,50,75,95] | Percentile levels for bands |
| n_sample_paths | int | 50 | Number 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”.
| Argument | Type | Default | Description |
|---|---|---|---|
| result | Result | -- | BacktestResult |
| store | DataStore | -- | Data store (to load OHLC bars) |
| symbol_id | int | -- | Which symbol to chart |
| emas | list[int] | None | None | EMA periods to overlay (e.g. [10, 25]) |
| smas | list[int] | None | None | SMA periods to overlay |
| n_bars | int | 120 | Number of bars to display (last N) |
| interactive | bool | True | Accepted for compatibility; charts are always interactive Plotly now |
# 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()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.
# 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).
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"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,
)
| Argument | Type | Default | Description |
|---|---|---|---|
| data_root | str | -- | Root directory containing bar data. Read as Parquet by default; pass arrow_dir for an Arrow IPC store instead |
| metadata_db | str | "metadata/metadata.sqlite" | Path to the SQLite metadata database (relative to data_root) |
| dataset | str | "bars_1m" | Dataset table name. Auto-resolved based on bar_interval in most cases |
| mega | Optional[str] | None | Path to a consolidated "mega" store. When set, it is used instead of data_root (Parquet) |
| arrow_dir | Optional[str] | None | Path 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) |
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",
)The Portfolio builder combines multiple strategies with allocation weights. Run with mbt.run_portfolio().
| Method | Signature | Description |
|---|---|---|
| .strategy(s, w) | strategy(strategy: Strategy, weight: float) -> Portfolio | Add 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) -> Portfolio | Record 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) -> Portfolio | Accepted, not yet applied |
| .rebalance_periodic(n) | rebalance_periodic(every_n_bars: int) -> Portfolio | Accepted, not yet applied |
| .rebalance_threshold(pct) | rebalance_threshold(drift_pct: float) -> Portfolio | Accepted, not yet applied |
| .no_rebalance() | no_rebalance() -> Portfolio | The current behaviour: allocations are set once, at the start |
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.
# 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)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.detect_lookahead() and check_exposure_stability() on every new strategy. Call risk_check() on the result.FeeConfig.binance_perps() as a baseline.Manifold-BT works out of the box as Community edition. Pro unlocks additional features:
| Feature | Community | Pro |
|---|---|---|
| Simulation resolution | 1 minute | Down to 1 second |
| Output resolution | Daily | Down to 1 second |
| Monte Carlo simulations | 1,000 | Unlimited |
| Walk-forward analysis | — | Anchored, blocked, Pardo and custom geometries |
| Parameter stability | — | Full |
| Crypto connectors (Binance, Hyperliquid) | Yes | Yes |
| Databento & Massive connectors | — | Yes |
| Safety checks (lookahead, exposure) | — | Yes |
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:
manifoldbt activate "3f9c2a17-8b4e-4c6a-9f21-7d0e5a1b2c3d"
Or from 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.
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.
The key is stored at:
| OS | Path |
|---|---|
| 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:
# macOS / Linux
rm ~/.local/share/manifoldbt/license/license.key
# Windows (PowerShell)
Remove-Item "$env:LOCALAPPDATA\manifoldbt\license\license.key"
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.
The rest of the site, where it goes further than this reference. Each link opens in the full window.