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.34.0
("Community", None)
On import, manifoldbt prints one line with its version and tier. It goes to stderr, so it never mixes with what your script prints, and it is coloured only on a terminal, never when NO_COLOR is set. MANIFOLDBT_QUIET=1 silences it, along with the update notice:
MANIFOLDBT_QUIET=1 python my_backtest.py
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 the data already stored: a bar at a timestamp already present replaces the stored one, so a second ingest refreshes a bar that was still forming the first time.
dYdX 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.
Prediction-market prices from Polymarket. Free, no API key and no account. Each outcome of a market is its own symbol, "<market slug>/<outcome>", where the slug is the last part of the market's address on Polymarket. Prices run from 0 to 1, in dollars per share, and intervals from 1m to 1d.
store = mbt.ingest(
provider="polymarket",
symbol="will-x-happen/Yes", # "<market slug>/<outcome>"
symbol_id=1,
start="2026-01-01T00:00:00Z",
end="2026-03-01T00:00:00Z",
interval="1h",
)
A full run, from the slug to a sweep over 400 resolved markets: backtest Polymarket strategies in Python. What each Polymarket API returns, and the datasets and archives beyond it: Polymarket historical data, tested.
mbt.event_contract(resolved_at, payout=1.0) describes a share that pays a fixed amount when an event resolves, whatever its last price: 1.0 for the side that won, 0.0 for the side that lost, what the venue paid when it voided the market. A Polymarket ingest records it on its own; for bars you imported yourself (Kalshi, your own data), pass it in option_contracts:
config = mbt.BacktestConfig(
universe=[1],
...,
option_contracts={1: mbt.event_contract("2026-03-01T14:00:00Z", payout=1.0)},
)
resolved_at is RFC 3339 text or nanoseconds since the epoch. The position settles at the first bar at or after it, or on the last bar when trading stopped earlier, as long as the run's range covers resolved_at; a run that ends sooner keeps its last price. Nothing here is visible to a strategy before that. To bet against an outcome, buy the other side's share: selling one short is not refused, but no collateral is reserved for it.
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. Its trades and quotes also reach the tick layer, as do Databento's.
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. A backtest's time_range_start and time_range_end are nanoseconds, not milliseconds: take them from time_range(), not from the CSV's column.
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. vwap, trade_count, bid, ask, spread, buy_volume and sell_volume are kept when present; without them the vwap is the typical price (O+H+L+C)/4 and the trade count 0. A bar at a timestamp already stored replaces it, so a corrected frame can be imported again.
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", "polymarket", "lse" (your own LSE_API_KEY), "databento" Pro, "massive" Pro | |
symbol | * | Ticker symbol (e.g. "BTCUSDT"). Use with symbol_id | |
symbol_id | * | Unique integer ID for the symbol. Use with symbol | |
symbols | * | List of (ticker, id) tuples for multi-symbol ingest | |
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. On Binance and Bybit, crypto_perp stores the venue's spot bars, and ingest warns that the prices are the spot market's |
All indicators live in manifoldbt.indicators and return Expr objects that compose into the expression graph. Pre-built column references: open, high, low, close, volume, timestamp. vwap is not one of them: in this module it names the vwap() indicator, a function to call. The vwap column that bars rebuilt from a tape carry is read with mbt.col("vwap").
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) -> Expr | Double Exponential Moving Average |
| .tema(p) | tema(period: int) -> Expr | Triple Exponential Moving Average |
| .wma(p) | wma(period: int) -> Expr | Weighted Moving Average |
| .hma(p) | hma(period: int) -> Expr | Hull Moving Average |
| .kama(p) | kama(period: int) -> Expr | Kaufman Adaptive Moving Average |
| .roc(p) | roc(period: int) -> 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.
A plain run reads each parameter's default. run(), run_batch(), run_batch_lite() and detect_lookahead() raise on a param() without one: only a sweep sets it. An indicator window counts bars, so its period is a whole number (5 or 5.0; 5.4 raises), written directly, as a param() or in a grid. The span of ema() can stay fractional.
# 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, or be stop_loss, take_profit or trailing_stop, which sweep the strategy's own exit orders -- any other key raises StrategyError before any backtest runs |
| 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(), or be one of the exit orders (stop_loss, take_profit, trailing_stop). Since 0.17.0 signals and position sizing compile as a single graph evaluated in cache-resident blocks, and higher-timeframe columns are resampled once per sweep rather than once per combination, so a strategy carrying real logic sweeps several times faster than it did in 0.16 for identical results.
| 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, Researcher). 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 or be one of the exit orders, same validation as run_sweep() |
| device | str | "auto" (default), "cpu" or "cuda". The per-fold selection is a sweep, so with a Researcher licence it runs on the card; on Pro, "auto" stays on the CPU and "cuda" raises a LicenseError. "auto" applies the same ~1,000-combo crossover as run_sweep_lite, which bites harder here: one fold carries the whole grid, so a small grid leaves the GPU under-occupied and the protocol relaunches it once per fold |
| max_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. Runs on Community within the session's sweep budget, like run_sweep() (see Licences & Activation).
| 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. Runs on Community within the session's sweep budget, like run_sweep() (see Licences & Activation).
| 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 + Researcher license are present, else the CPU.
# Force it either way with device="cuda" (Researcher) or device="cpu". A "cuda"
# call that hits an unsupported config warns and runs on the CPU.
results = mbt.run_sweep_lite(strategy, param_grid, config, store, device="cuda")
# Pull whole metric columns out as numpy arrays (~20x faster than looping
# .metrics on a large sweep), then map argmax straight back onto the batch.
sharpe = mbt.sweep_columns(results, "sharpe")
best = results[int(sharpe.argmax())]sweep_columns(
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, Researcher; 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 (Researcher; past 1,000 paths needs Pro)
result = mbt.run_stochastic(
"gbm", s0=100, n_paths=10_000_000, n_steps=252, dt=1/252,
params={"mu": 0.05, "sigma": 0.2}, seed=42, device="cuda",
)
print(result["final_price"]["mean"]) # ~105.12
# Custom GARCH(1,1) Jump Diffusion
model = mbt.StochasticModel(
drift="mu",
diffusion="sqrt(h)",
jump_intensity="lambda",
jump_size="normal(mu_j, sigma_j)",
state_vars={"h": 1e-4},
state_update={"h": "omega + alpha * (ret - mu) ** 2 + beta * h"},
params={"mu": 0.08, "omega": 1e-6, "alpha": 0.1, "beta": 0.85,
"lambda": 5.0, "mu_j": -0.02, "sigma_j": 0.04},
)
result = mbt.run_stochastic(model, s0=100, n_paths=10_000_000, device="cuda")
# Custom mean-reverting model
mean_rev = mbt.StochasticModel(
drift="kappa * (log(100.0) - log(S))",
diffusion="sigma",
params={"kappa": 2.0, "sigma": 0.25},
)
result = mbt.run_stochastic(mean_rev, s0=80, n_paths=1000, store_paths=True)
mbt.plot.stochastic_paths(result, show=True)| 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, in nanoseconds since the Unix epoch (UTC), not milliseconds. Use time_range("2021-01-01", "2026-01-01"), which returns both bounds |
| time_range_end | int (ns) | End, in nanoseconds, like the start |
| 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), Researcher adds the trade clock (Interval.trades()) and, for a strategy that quotes, a wake-up clock in milliseconds |
| 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 the finest bars the store holds, 1s or 1m (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)) |
| book_levels | Optional[int] | Levels a side a run reads from a stored book: every stored level when left out. Bounds the book columns, the queue, the fill marks and the book a quote reads. See Order Book Columns |
| liquidation | Optional[Liquidation] | Liquidate a leveraged position at the maintenance margin, during the run. Off when left out. See Liquidation |
Interval.seconds(1) # {"Seconds": 1}
Interval.minutes(1) # {"Minutes": 1}
Interval.hours(12) # {"Hours": 12}
Interval.days(1) # {"Days": 1}
Interval.millis(100) # {"Millis": 100}, the wake-up clock of a quoting strategy
Interval.trades() # "Trades", the trade clock: one row per print (Researcher)FeeConfig(
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). A NaN in it counts as no funding on that bar, and the run warns with their count |
| 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,
fill_resolution: Optional[str] = None,
fill_marks: bool = False,
latency: Optional[dict] = None,
orders: Optional[OrderConfig] = None,
tick_size: Optional[float] = None,
)
| Field | Type | Default | Description |
|---|---|---|---|
| signal_delay | int | 0 | Bars between the signal bar and the execution bar. 0 is accepted where it is sound: AtClose, NextBarClose, or custom on a signal, which decide and fill on the same close. With any other execution price (AtOpen, AtVwap, MidPrice, NextBarOpen, NextBarVwap, custom on a bar column) a delay of 0 would fill at a price that precedes the close that produced the signal, and since 0.27 the run raises a ConfigError: use 1. A resting entry order (.limit_entry(), .stop_entry(), .market_if_touched(), .stop_limit_entry()) needs 1 whatever the execution price, AtClose included: at 0 its level would come from the very bar whose high and low it is tested against, and the run is refused |
| execution_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); passive_fill ("touch" or "traverse"), and on the tick layer queue, adverse and custom (see Queue Model) |
| fill_resolution | str | None | None | "bar" (the bar's high and low) or "ticks": level orders settled on the prints inside each bar. Needs a stored tape. See Fills on the Tape |
| fill_marks | bool | False | Mark every fill at +100 ms, +1 s and +10 s into result.fill_marks. Needs the tape. See Fill Marks |
| latency | dict | None | None | {"order": ..., "cancel": ...}, plus "response" and "feed" for a strategy that quotes. Needs the tape. See Latency |
| orders | OrderConfig | None | None | Order management: SL, TP, trailing stops |
| tick_size | float | None | None | The venue's tick, e.g. 0.1 for BTCUSDT: every level an order rests at is put on that grid before it is posted, a bid down and an ask up. Aggressive legs are not touched |
| 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. On a bar column it needs signal_delay=1 |
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
Without it, a leveraged position survives every wick that would have closed it on the venue, and the equity curve shows a recovery the account never traded. With cfg.liquidation set, the account is cross-margined: one balance carries every position, and the whole book is closed the moment equity falls to the maintenance margin, rate × |position| × price summed over the symbols held. Since 0.29.0, opt-in: a config without the field runs exactly as before.
import manifoldbt as mbt
cfg.execution.max_position_pct = 3.0 # 3x
cfg.liquidation = mbt.Liquidation(
maintenance_margin_rate=0.005, # 0.5 % of notional
per_symbol={"DOGEUSDT": 0.01}, # optional overrides, by name or id
fee_bps=50, # liquidation fee
)| Field | Type | Default | Description |
|---|---|---|---|
| maintenance_margin_rate | float | 0.005 | Maintenance margin as a fraction of notional, in [0, 1) |
| per_symbol | Dict[str|int, float] | {} | Per-symbol overrides of the rate, keyed by symbol name or integer id |
| fee_bps | float | 0.0 | Liquidation fee in basis points of the notional closed. It replaces the trading fee on a liquidation, as venues charge it |
Inside the bar. The rule walks every position from the bar's open toward its adverse extreme (the low for a long, the high for a short) and closes the book where equity meets the margin: on one symbol, at the venue's liquidation price exactly; on several, as if their adverse extremes coincided. A bar that opens past that point fills at the open. A stop placed before the liquidation price fires first; one placed beyond it never does. A gap through the bankruptcy price leaves the account at zero rather than in debt, as a venue's insurance fund does.
What a run reports. A 3x long from 100 on 1,000 of capital, through one bar that wicks to 60: without liquidation the position survives and the run ends at 850; with Liquidation(maintenance_margin_rate=0.005) it is closed at 67.00, the price where equity meets the margin. The liquidation is a row of the trade log with exit_reason == 7, and result.warnings says how many positions were liquidated, and whether a gap past the bankruptcy price was absorbed.
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. A resting entry needs ExecutionConfig(signal_delay=1) or more: with the default 0 the run is refused (see signal_delay under Configuration).
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)
)
# config built with execution=mbt.ExecutionConfig(signal_delay=1, ...): a resting entry needs it
result = mbt.run(strategy, config, store)
for w in result.warnings:
print(w) # "N entry order(s) expired unfilled and M were still resting ..."The store holds bars at four resolutions: 1s, 1m, 1h and 1d. For a given bar_interval, the engine loads the coarsest of them that is not coarser than the interval and has data, and resamples it up to the interval:
| bar_interval | Dataset loaded | Notes |
|---|---|---|
| < 1m | bars_1s or provider/1s/ | Pro (Community stops at 1m) |
| 1m to < 1h (5m, 15m, ...) | bars_1m or provider/1m/ | Read as is at 1m, resampled above |
| 1h to < 1d (4h, 12h, ...) | bars_1h or provider/1h/ | Read as is at 1h, resampled above |
| 1d and above | bars_1d or provider/1d/ | Read as is at 1d, resampled above |
A resolution with no data is skipped for the next finer one: 4h bars on a store that holds only 1-minute bars are resampled from those. The engine tries the provider layout first (binance/1h/BTCUSDT.arrow), then falls back to legacy (bars_1h/201.arrow).
When accuracy=True, the engine loads the finest bars the store holds: 1-second bars when it has them, 1-minute bars otherwise. Signals are evaluated at bar_interval resolution, but the simulation loop runs on those finer bars for precise intrabar SL/TP fills and drawdown tracking.
config = mbt.BacktestConfig(
bar_interval=Interval.hours(12),
accuracy=True, # hybrid: signals on 12h, sim on the finest stored bars
...
)A prop firm does not judge a finished equity curve: an account that breaches a limit is closed. So the rule runs inside the simulation. The moment the account breaches, it liquidates, cancels its orders and stops trading, and its equity stays flat to the end of the data — because that is what an account the firm took away is worth.
This is the whole difference. A verdict applied afterwards gets the first breach right and everything after it wrong: on a series that dips below the floor and then recovers well above the start, a post-hoc reading sees the recovery. A closed account never traded it.
config.account_rules = mbt.AccountRules(
phases=[
mbt.AccountPhase(name="Challenge", profit_target=0.10,
max_daily_loss=0.05, max_total_loss=0.10,
min_trading_days=4),
mbt.AccountPhase(name="Verification", profit_target=0.05,
max_daily_loss=0.05, max_total_loss=0.10,
min_trading_days=4),
],
sessions=mbt.account_sessions(start, end, tz="Europe/Prague"),
consistency=0.50,
)
result = mbt.run(strategy, config, store)
if result.account is None:
print("account still alive at the end of the data")
else:
a = result.account
print(f"{a['outcome']} in {a['phase']} on day {a['trading_days']}")
print(a["reason"])
# FAILED_MAX_LOSS in Challenge on day 15
# overall loss floor 90000.00 breached (equity 89984.55)Every limit is a fraction of the capital that phase starts with.
| Field | Meaning |
|---|---|
profit_target | Judged at a session close, never on an intraday touch. None = surviving is enough. |
max_daily_loss | Floor at session_open - X%, reset every session. None = the firm sets no daily limit, which a whole family of futures evaluations does. |
max_total_loss | The floor that closes the account. Always present. |
drawdown_type | "Static", "TrailingEod" (the floor follows the close) or "TrailingEquity" (it follows the peak equity mark by mark inside the session). |
trailing_lock_at | Level at which a trailing floor freezes for good. 0.0 = at the balance the phase started with. |
min_trading_days | A trading day is a session that opened a position. Holding does not count, and neither does closing. |
max_calendar_days | None = unlimited, which is what the mainstream programs offer. |
Both are sold as “your drawdown follows your profit”. TrailingEod moves the floor only at the close, so profit made and handed back inside one session costs nothing. TrailingEquity follows the peak equity as it happens, unrealised profit included: make +4k at 10:30, give it back by 11:00, and the floor has already moved up. An account can be closed on a day that ends in profit.
A firm's day is not UTC midnight. An FX firm resets at 00:00 CE(S)T; a futures evaluation follows the exchange session, which opens at 17:00 in Chicago. Naming a zone and an hour would cover those two and nothing else, because real calendars also have holidays and early closes.
So the boundary travels as the list of instants each session begins at, and account_sessions builds it with zoneinfo from the standard library. Windows Python ships no time-zone database, so pip install tzdata there first, even for the default tz="UTC". A holiday is simply a start that is not in the list, and the session then runs to the next one.
# daily sessions on the firm's clock; DST is handled by zoneinfo
sessions = mbt.account_sessions(start, end, tz="Europe/Prague")
# a futures evaluation: the CME session opens at 17:00 local
sessions = mbt.account_sessions(start, end, tz="America/Chicago", hour=17)A 2-Step is two phases, and the firm hands out a new account between them. A trader who finishes the Challenge at +10% does not begin the Verification already past its +5% target: each phase measures its limits from the equity it started at. Reading the running curve instead turns a 2-Step into a 1-Step and inflates every pass rate computed from it.
consistency=0.50 caps the share of the winning sessions' profit a single session may hold. Breaking it is not a breach: the account stays alive and can still pass. What it loses is the payout, so the verdict reports it as consistency_ok alongside the outcome.
result.account is None while the account is alive, otherwise a dict with outcome (PASSED, FAILED_DAILY_LOSS, FAILED_MAX_LOSS, RAN_OUT_OF_TIME), phase, phase_index, consistency_ok, timestamp, equity, floor, trading_days and a plain-English reason.
Three things worth knowing before you read anything else:
equity and floor are on the phase's own balance, which restarts at the initial capital for every phase, while equity_df() keeps compounding. On a two-phase program the two numbers are deliberately different.metrics is computed over a mostly flat curve and can read as a success on an account the firm took away. The engine says so in warnings. Read account first.RAN_OUT_OF_TIME only appears when a phase sets max_calendar_days. Without one, an account that never reaches its target simply stays open and account is None — which means “still alive”, not “failed”.Account rules run on the full simulation loop and on the lite path used by sweeps, so a sweep gives one outcome per combination and a pass rate is computable. The fast kernels and the GPU sweep do not apply them and refuse the configuration by name rather than running it as if the rule were not there.
One limit worth stating: the account can only close at a bar boundary, so a coarse bar_interval gives a coarse verdict. Finer bars make it finer. The firm's own liquidation costs are not modelled.
Inject external data series (hashrate, funding rates, sentiment, on-chain metrics) into your strategy expressions. The engine ASOF-joins exogenous data onto bar timestamps at the requested resolution: a value holds until the next one, so the last value stamped before the run is in force from its first bar, and nothing stamped after a bar is read on it.
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") |
Everything above runs on bars. The tick layer adds what a bar cannot hold: every print, the order book level by level, and on Nasdaq every order. It is one layer with two doors: the store holds what the venue published, and the config says what the engine does with it. The DSL, mbt.run() and the result object stay the same.
It needs a Researcher licence. A Pro licence does not unlock it, and each ingest says so before touching the network. This page is the reference; the full semantics, with what every setting refuses and why, are in the strategy authoring guide on GitHub.
| Call | What it stores | From |
|---|---|---|
ingest_trades() | The tape: one row per print, with its time, price, size and aggressor side | Bybit (spot, USDT and coin-margined perpetuals), Binance spot aggTrades, Massive (the US consolidated tape), Databento, Polymarket |
ingest_book() | The book by price level: the first levels prices and sizes a side, at every instant one of them changed | Bybit (spot and perpetuals) |
ingest_mbo() | Every order: its entry, each execution, cancellation, deletion and replacement | A Nasdaq TotalView-ITCH 5.0 file |
bars_from_trades() | Bars rebuilt from the tape, with buy_volume, sell_volume, vwap and trade_count | The stored tape |
attach_quotes() | The bid, ask and spread columns of 1-minute bars already stored | Bybit's book archive, Massive (NBBO), Databento (BBO) |
# One day of BTCUSDT perpetual trades, from Bybit's public archive
store = mbt.ingest_trades("bybit", "BTCUSDT", symbol_id=1,
start="2025-06-02", end="2025-06-02",
category="linear")category names the market: "spot" (the default, on either provider), "linear" for USDT perpetuals such as BTCUSDT, "inverse" for coin-margined ones such as BTCUSD, whose sizes are in contracts. Bybit's derivatives archive starts the day a contract listed: 2020-03-25 for BTCUSDT, 2019-10-01 for BTCUSD. start and end are YYYY-MM-DD, end inclusive, and a day the venue did not publish raises a named error. cache_dir keeps Bybit's raw daily archives for reuse.
"polymarket", free and without a key, serves the trades of one outcome, symbol="will-x-happen/Yes", stamped to the second. Both outcomes share one book, so a trade on the other one is restated in the terms of the one asked for: buying No at 0.38 is stored as selling Yes at 0.62. Polymarket serves a market's trades newest first, so reaching a day means reading everything after it: ask for the end of a market's life first.
Bybit stamps a derivative trade to a tenth of a millisecond, except from 2021-12-07 to 2022-12-20, when it stamped the whole second; spot trades are stamped to the millisecond. Prints that share a timestamp keep the order the venue matched them in, and nothing shorter than the stamp (a latency, a time_in_force, a window in time) can tell them apart.
One symbol is one market. The tape and the book of a symbol share its symbol_id and its category: a store that holds the spot tape of a symbol refuses its linear one, and a second id for the same ticker, before anything is downloaded.
# Every print of the day on the US consolidated tape (MASSIVE_API_KEY)
store = mbt.ingest_trades("massive", "AAPL", symbol_id=3,
start="2026-10-26", end="2026-10-27")
# A CME future from Databento's trades schema (DATABENTO_API_KEY)
mbt.ingest_trades("databento", "ESZ6", symbol_id=4,
start="2026-10-26", end="2026-10-27",
dataset="GLBX.MDP3", asset_class="future")The data vendors fill the same tape, with your own key read from the environment as for ingest(). Massive pages through every print of the UTC day and stamps it with the SIP's nanosecond; asset_class defaults to "equity", and trades come with Massive's Stocks Developer plan and above. Databento reads the trades schema of dataset, stamped by the matching engine; asset_class is required, since a dataset can hold stocks, futures or options.
The consolidated tape does not say which side was the aggressor, and Databento marks some prints N. Those are classified by the tick rule: above the previous price bought, below it sold, at the same price the side of the last move. A side the venue gives is kept. Every print the vendor serves is stored, odd lots and off-exchange prints included, and a day nobody traded (a weekend, a holiday) stores nothing without stopping the range.
# The book of the same market, same symbol_id, same category
mbt.ingest_book("bybit", "BTCUSDT", symbol_id=1,
start="2025-06-02", end="2025-06-02",
category="linear") # 200 levels for a new symbol
# A day Bybit published 500 levels deep, all of it
mbt.ingest_book("bybit", "BTCUSDT", symbol_id=1,
start="2025-08-20", end="2025-08-20",
category="linear", levels=500)| category | Depth | History |
|---|---|---|
"linear" | 500 levels up to 2025-08-20, 200 from 2025-08-21 | BTCUSDT from 2023-01-18 |
"inverse" | 500 levels up to 2025-08-20, 200 from 2025-08-21 | Sizes in contracts |
"spot" | 200 levels | From the end of April 2025 |
levels=None keeps the depth the symbol is already stored at, 200 for a new symbol. levels=500 holds only on a range Bybit published that deep: a range with a shallower day in it is refused before anything is downloaded, and the error names the day to end it before. A day is a download of 100 to 400 MB, streamed into the store and never held whole.
Books are stored as changes by default (book_format="deltas"): a day of BTCUSDT 200 levels deep takes about 44 MB instead of 2.7 GB as full ladders, and loads in a fraction of a second at any depth. book_format="ladder" stores the whole ladder on every row. A symbol keeps the shape of its first stored day, and every reader sees the same states either way.
# One-second bars rebuilt from the stored tape (end is exclusive here)
mbt.bars_from_trades(store, "BTCUSDT", "2025-06-02", "2025-06-03", interval="1s")
flow = mbt.col("buy_volume") - mbt.col("sell_volume")A venue's own bars carry one total volume. Bars rebuilt from the tape split it by the aggressor side, and add vwap and trade_count; the DSL reads them by name. They are ordinary bars for everything downstream: run() reads them at interval or resamples them coarser. Seconds with no trade produce no bar, and one second is the finest step the store holds. attach_quotes() fills 1-minute bars only: to put quotes on a tape, rebuild it at interval="1m" as well.
store = mbt.ingest(provider="bybit", symbol="BTCUSDT", symbol_id=1,
start="2026-07-01T00:00:00Z", end="2026-08-01T00:00:00Z")
mbt.attach_quotes(store, 1, "2026-07-01", "2026-07-31")
# The bars now carry real bid, ask and spread: MidPrice execution and
# Slippage.spread_based() read actual quotes instead of nulls.The bar schema has always had bid, ask and spread columns; attach_quotes() fills them on the symbol's 1-minute bars, and only on those, with the best bid and ask standing at each bar close, streamed from Bybit's book archive. Only those few kilobytes reach the store. On another venue's bars, Bybit's spread is a reasonable proxy for the majors and an approximation for thin altcoins.
# A stock's NBBO on its own bars
store = mbt.ingest(provider="massive", symbol="AAPL", symbol_id=3,
start="2026-10-01T00:00:00Z", end="2026-10-28T00:00:00Z",
asset_class="equity")
mbt.attach_quotes(store, "AAPL", "2026-10-01", "2026-10-27", provider="massive")
# The NBBO on a tape: its 1-minute bars first, then the quotes
tape = mbt.ingest_trades("massive", "AAPL", symbol_id=3,
start="2026-10-26", end="2026-10-27")
mbt.bars_from_trades(tape, "AAPL", "2026-10-26", "2026-10-28", interval="1m")
mbt.attach_quotes(tape, "AAPL", "2026-10-26", "2026-10-27", provider="massive")
# A future's BBO on its Databento bars
es = mbt.ingest(provider="databento", symbol="ESZ6", symbol_id=4,
start="2026-10-01T00:00:00Z", end="2026-10-28T00:00:00Z",
dataset="GLBX.MDP3", asset_class="future")
mbt.attach_quotes(es, "ESZ6", "2026-10-01", "2026-10-27",
provider="databento", dataset="GLBX.MDP3")With provider="massive" the quotes are the consolidated national best bid and offer, every update of the day paged through and reduced as it comes; a liquid stock quotes tens of millions of times a day, so a day is many requests, and quotes come with Massive's Stocks Advanced plan and above. With provider="databento" they are the venue's best bid and offer at each minute close. Either way the quote standing at a bar close is carried across bars with no update, and a quote with a side missing or crossed leaves the bars it stands over without one rather than with a stale one. provider_symbol defaults to the store ticker as is.
# A Nasdaq TotalView-ITCH 5.0 day, read in one pass for every symbol asked
store = mbt.ingest_mbo("S120925-v50.txt.gz", {"AAPL": 1, "QQQ": 2},
date="2025-12-09")Where ingest_book() stores how much rests at each price, ingest_mbo() stores every order. The file is a Nasdaq TotalView-ITCH 5.0 day, either the .gz Nasdaq publishes or a stream of its messages (plain or ZSTD). symbols maps each ticker, as ITCH names it, to its id, and date is the session's date in New York. Each day is checked as it is written: an event about an order that is not in the book, or one taking out more than is left of it, refuses the day. A market loaded from these days serves a resting order in its exact place in the queue (see Queue Model).
mbt.convert_book_to_deltas("BTCUSDT", data_root="data",
metadata_db="metadata/metadata.sqlite")Versions before 0.27 stored books as ladders. 0.27 still reads them; convert_book_to_deltas() rewrites one as changes, day by day, and replaces a day only once its changes replay into exactly the stored ladders. An interrupted conversion is finished by calling it again. A converted book is not read by earlier versions.
A stored book is readable by the strategy itself, like any bar column, on bars as well as on the trade clock. mbt.book builds the column names:
imb = mbt.book.imbalance(5) # col("book_imbalance_5")
thin_ask = mbt.book.ask_depth(3) < 2.0 # col("ask_depth_3") < 2.0
strategy = (
mbt.Strategy.create("imbalance")
.signal("long", mbt.when((imb > 0.3) & thin_ask, 1.0, 0.0))
.size(mbt.col("long"))
)| Function | Column | Reads |
|---|---|---|
book.bid_price(k) | bid_price_<k> | The price of bid level k, 1 = best; ask_price(k) for the asks |
book.bid_size(k) | bid_size_<k> | The size resting at bid level k; ask_size(k) for the asks |
book.bid_depth(k) | bid_depth_<k> | The size resting over bid levels 1 to k; ask_depth(k) for the asks |
book.imbalance(k) | book_imbalance_<k> | (bid_depth - ask_depth) / (bid_depth + ask_depth) over k levels, in [-1, 1] |
Nothing in the config names them. The engine sees which columns the strategy reads and loads the book once, only as deep as the deepest k asked; a strategy that names none loads no book. A store with no book for a symbol, or one shallower than the level asked, is refused by name. BacktestConfig.book_levels bounds how deep a run reads, for these columns and for everything else that reads the book.
close describe the same instant.execution.latency["feed"] earlier.A value is null where the book says nothing: before its first update, on a day with no stored book, at a level that does not exist at that instant, and for the imbalance of an empty book. bid_depth and ask_depth are zero on an empty side. A null flows through an expression like a NaN close: a null at the top of the sizing holds the position.
Two settings put the stored tape to work. Turn them on one at a time and keep the run before as the reference: each one moves the numbers.
config = mbt.BacktestConfig(
universe=[1],
time_range_start=start, time_range_end=end,
bar_interval=Interval.minutes(1),
execution=mbt.ExecutionConfig(signal_delay=1, fill_resolution="ticks"),
)
result = mbt.run(strategy, config, store)
result.tape_resolution # how many bars the prints decidedfill_resolution="ticks" keeps the bar grid and settles every level order (stop-loss, take-profit, trailing stop, limit and stop entries) on the prints inside the bar, in the order they printed, instead of on the bar's high and low. When a stop and a target sit in the same bar, the tape says which came first. A bar with no trade in its window falls back to the bar rule; result.tape_resolution counts both cases. It can also be set as fill_model={"fill_resolution": "ticks"}.
config = mbt.BacktestConfig(
universe=[1],
time_range_start=start, time_range_end=end,
bar_interval=Interval.trades(), # one simulation row per print
execution=mbt.ExecutionConfig(signal_delay=1),
)Interval.trades() replaces the time grid with the tape: one simulation row per print, in the order the venue published them.
| Column | On the trade clock |
|---|---|
timestamp | The trade's exchange time, in nanoseconds |
close | The trade's price, as are open, high, low and vwap |
volume | The trade's quantity |
side | +1.0 when the aggressor bought, -1.0 when it sold |
buy_volume | The quantity when the aggressor bought, 0.0 otherwise; sell_volume the other way round |
bid | The best bid of the stored book as of the print, null when no book is stored; ask and spread the same way |
depth_at_best_bid | The size resting at the best bid, same rule; depth_at_best_ask for the ask |
Events, not bars. Every count the engine expresses in bars counts prints on this clock: signal_delay, warmup_bars, time_in_force={"GTB": n} and any window given as an integer. A window given as a duration still counts time: rolling_sum(Interval.seconds(34)) reads thirty-four seconds of prints, rolling_sum(34) thirty-four prints. When you mean time, write time, including time_in_force=Interval.seconds(1).
How an order fills. A market order decided on print i fills at the price of print i + signal_delay. A maker level (a limit entry, a take-profit) obeys passive_fill, or the queue when fill_model["queue"] is set. An aggressive level (a stop-loss, a trailing stop, a stop entry) fills on the first print at or beyond it, at that print's price: a stop at 98 whose first print below it is 96.5 fills at 96.5. When one print hits both the stop and the take-profit, the stop wins, as on bars.
What comes out. A liquid perpetual prints two to three million events a day, so output_resolution defaults to one second on this clock. output_resolution=Interval.trades() returns one row per event, with the memory that takes. Size a study in days, not months.
A resting order is not filled because the market reached its price. It is filled because everything resting ahead of it at that price went first. fill_model["queue"] replaces the price convention (passive_fill) with that rule, for the orders that really rest: a limit_entry, an armed stop-limit and a take_profit.
config.execution.fill_model = {
"fill_resolution": "ticks", # a queue is served print by print
"queue": {
"model": "risk_adverse", # the default; or "power" (with "n") or "log"
"depth_source": "book", # the depth stored at the order's level
"assumed_queue": 2.0, # the fallback, in MULTIPLES OF THE ORDER SIZE
"cancel_ahead_rate": 0.0, # share of the queue ahead cancelling per second
},
}| Key | Values | What it does |
|---|---|---|
model | "risk_adverse" (default), "power", "log", "fifo" | How the volume ahead moves when the level shrinks with no print. risk_adverse puts every unseen cancellation behind the order; power (with n) and log take a share of it from in front, by a probability. fifo is the exact queue of a market stored order by order |
n | a number above 0 | The exponent of power |
depth_source | "book" (default), "assumed" | Join behind the depth stored at the order's own level at the instant it is posted, or behind assumed_queue times its own size |
assumed_queue | a multiple of the order size | The queue under "assumed", and the declared fallback under "book" when the book cannot answer. Without it, such an instant is refused, never read as an empty queue |
cancel_ahead_rate | a fraction per second | Retires that share of the queue ahead, as exp(-rate * dt). 0, the default, assumes nobody ahead ever cancels |
A print at the level from the side that consumes the order burns the volume ahead first, and only the rest fills the order: partial fills are the normal case. A print strictly through the level, or the best opposite price crossing it, fills the rest of the order whatever the queue. Requoting is cancelling and posting again, so a replacement starts at the back of a fresh queue. A level off the venue's tick grid finds no depth in the book: set execution.tick_size or snap the level with .floor_to() / .ceil_to().
The queue needs the prints: fill_resolution="ticks", or the trade clock. result.fill_fragility["queue"] says how each fill was served: queue_decided_fills, traverse_fills, partial_fills, book_unknown_at_post and fills_from_book_cross, beside would_fill_touch and would_fill_traverse, the fills the two price conventions would have given the same orders.
The three book-by-price models reproduce hftbacktest's RiskAdverseQueueModel, PowerProbQueueModel and LogProbQueueModel, and agree with it on 99.955% to 100% of quotes on the same BTCUSDT days: see Manifold-BT vs hftbacktest. On a market stored by ingest_mbo(), the order-level simulation (Strategy.quote, mbt.sim) serves the exact queue by default; a run on bars or on the trade clock refuses "fifo" by name.
config.execution.latency = {
"order": Interval.millis(20), # a decision reaches the market
"cancel": Interval.millis(20), # a cancellation takes effect
}order: a decision taken on event t reaches the market at t + order, which is where the order joins the queue and where a market order fills, at the first print at or after it. cancel: a cancellation takes effect at t + cancel; until then the stale quote still stands and can still be filled, and the engine books that fill. Both keys are optional and zero when left out, and a config without latency is the run it always was, byte for byte. Two more keys, response and feed, are read only by a strategy that quotes. Latency needs the tape; without one the run is refused by name.
Under latency, result.order_activity gains stale_fills, the fills taken by a quote after its cancellation was decided, and overlapping_live_orders.
config.execution.fill_marks = True
result = mbt.run(strategy, config, store)
result.fill_marks # per horizon: mean_bps, median_bps, stderr_bps, marked_fills
result.fill_marks_df() # one row per fillFees and a fill count say what a strategy paid to trade, not what it paid to be quoted. Each fill is marked against the mid of the stored book (or the last print when no book answers) at the fill and at +100 ms, +1 s and +10 s, in basis points of the fill price, signed in the direction the fill takes the position: negative is adverse selection. The anchor is the close of the bar the fill was booked on, the next print on the trade clock. half_spread_captured_bps and book_half_spread_bps put the result beside what a quote at the touch could have earned. It needs the tape.
A market maker keeps orders at the venue rather than asking for a target position. In the DSL it is written as quotes: per order, three expressions saying where to stand, how much, and whether to stand at all. The engine compiles them once and runs the whole span on the order-level venue without calling back into Python, and a strategy written this way sweeps like any other.
import manifoldbt as mbt
from manifoldbt import book
from manifoldbt.helpers import Interval, time_range
CLIP, MAX_INVENTORY = 0.01, 0.05
inventory = mbt.position()
clips = mbt.round(inventory / CLIP) # one level back per clip held
bids, asks = book.bid_levels(), book.ask_levels()
book_ok = (bids > 0) & (asks > 0)
bid = book.bid_price_at(mbt.clip(clips, 0, bids - 1) + 1)
ask = book.ask_price_at(mbt.clip(-clips, 0, asks - 1) + 1)
strategy = (
mbt.Strategy.create("maker")
.quote("buy", mbt.when(book_ok, bid), CLIP, tif="GTX",
enabled=inventory < MAX_INVENTORY - 1e-12)
.quote("sell", mbt.when(book_ok, ask), CLIP, tif="GTX",
enabled=inventory > -MAX_INVENTORY + 1e-12)
)
start, end = time_range("2025-08-13", "2025-08-14")
config = mbt.BacktestConfig(
universe=[1], time_range_start=start, time_range_end=end,
bar_interval=Interval.millis(100), # the wake-up clock
initial_capital=100_000.0,
fees=mbt.FeeConfig(maker_fee_bps=0.0, taker_fee_bps=4.0),
execution=mbt.ExecutionConfig(
latency={"order": Interval.millis(5), "cancel": Interval.millis(5),
"response": Interval.millis(5), "feed": Interval.millis(2)},
fill_model={"queue": {"depth_source": "book", "assumed_queue": 2.0}},
),
)
res = mbt.run(strategy, config, store) # a QuoteResult
res.fills_df()The store needs the book and the tape of the symbol over the range. This is the maker of examples/31_dsl_market_maker.py in the repository, and on the same days it gives the same order journal, fill for fill, as the same maker written for mbt.sim.
Strategy.quote(side, price, size, tif="GTX", enabled=None, cooldown=None)
| Argument | Description |
|---|---|
side | "buy" or "sell" |
price | The limit, an expression or a number. Put on the symbol's tick grid before it is compared or posted: between two ticks a buy goes down and a sell up |
size | The quantity in units, an expression or a number |
tif | "GTX" (post-only, the default: refused if it would cross), "GTC", "IOC" or "FOK" |
enabled | A condition; the quote stands while it holds. Always, when left out |
cooldown | How long the quote posts nothing after it sent a cancellation, e.g. Interval.millis(500) |
At each wake-up (every bar_interval), every quote is evaluated, then acts, in the order the quotes were declared:
enabled null, or price or size not a number: the quote holds whatever it has.enabled false, or size zero or less: its live order is cancelled.price: kept, with its place in the queue.price, at the back of the queue.A strategy that quotes takes no .size(), no bracket and no entry order: each quote carries its own size. run_sweep, run_sweep_lite, run_batch and run_batch_lite take it as they take any strategy, each combination being the run mbt.run() makes alone.
Computed before the run, on the grid of wake-ups: the book columns, the strategy's own signals, parameters and mbt.col("timestamp"). Read at each wake-up, as the strategy knows it (fills and answers latency["response"] after the venue booked them, the book latency["feed"] earlier):
| Reader | Value |
|---|---|
mbt.position() | The position, as the fills that have reached the strategy say |
mbt.cash() | The cash: initial_capital, minus what the buys cost, plus what the sells brought, fees included |
mbt.live_qty(side) | What is left to fill of the live orders on "bid" or "ask"; 0 when there is none |
mbt.order_price(side) | The price of the newest live order on that side |
mbt.order_age(side) | Seconds since that order was sent |
mbt.queue_ahead(side) | The size queued ahead of it, as the venue last answered; on a market stored order by order, exactly |
mbt.last_fill_px(side) | The price of the last fill on that side; NaN before the first |
mbt.last_fill_age(side) | Seconds since that fill reached the strategy (either side when side is left out) |
mbt.position_age() | Seconds since the position left zero or changed sign |
mbt.last_cancel_age(side) | Seconds since the strategy sent its last cancellation on that side |
book.bid_price_at(level) | The price of a bid level at the wake-up, at a level the expression may compute; ask_price_at() for the asks |
book.bid_levels() | How many bid levels the book holds at the wake-up; ask_levels() for the asks |
They combine with arithmetic, comparisons, when, round, clip, min_val / max_val, abs_val and round_to / floor_to / ceil_to. Each reads NaN before its event (no live order, no fill yet), and a comparison with NaN is false.
mbt.run() returns a QuoteResult for a strategy that quotes: everything a Result has, read the same way (equity and positions at output_resolution, one second when left out; one row of trades per fill; metrics), plus the venue's own record: orders_df(), fills_df(), events_df(), fill_marks_df() and the whole of it as .sim. order_activity counts orders posted, requotes and orders cancelled unfilled, and fill_fragility what the queue decided.
mbt.run() turns a strategy into orders itself. mbt.sim does the opposite: it hands a Python function a simulated venue, and the function sends its own orders as it would to an exchange. It moves the clock, reads the book and the tape as they reach it, posts, cancels and replaces, and learns what happened to each order when the venue's answer comes back. Every resting order is served by the same queue model as fill_model["queue"], so a strategy that rests one order at a time gets the same fills through either door.
import manifoldbt as mbt
from manifoldbt.helpers import Interval
def maker(sim):
while sim.elapse(Interval.millis(100)): # False once the data is over
book = sim.book("BTCUSDT") # as it reached the strategy
if book.best_bid is None:
continue
if not sim.orders("BTCUSDT", side="buy", status="live"):
sim.post("BTCUSDT", "buy", book.best_bid, 0.01, tif="GTX")
config = mbt.sim.Config(
latency={"entry": Interval.millis(5), "response": Interval.millis(5)},
queue={"depth_source": "book", "assumed_queue": 2.0},
)
res = mbt.sim.run(maker, config, store, symbols=["BTCUSDT"],
start="2025-08-13", end="2025-08-14")
res.fills_df()The store needs the book and the tape of every symbol over the range. A complete maker is examples/30_order_api_market_maker.py in the repository.
| Call | What it does |
|---|---|
sim.now() | The strategy's local time, in nanoseconds |
sim.elapse(dt) | Move the clock dt forward; False once it reaches the end of the data |
sim.wait(...) | wait(trade=, book=, order=, timeout=): move the clock to the next event of the kinds named (any kind when none is), or timeout from now; returns a Wakeup whose kind says why: "trade", "book", "order", "timeout" or "end". It is false at the end of the data, so while sim.wait(trade=True): ends with it |
sim.book(symbol) | The book as the strategy sees it: best_bid, best_ask, best_bid_qty, best_ask_qty, mid, spread, qty_at(price), levels(n), depth_through(price), tick |
sim.trades(symbol) | The prints that reached the strategy since the last call |
sim.post(...) | post(symbol, side, price, qty, tif="GTC", expire_at=None, trigger=None, trigger_on="last", after_trigger="rest"): send an order, which reaches the venue after the entry latency; price=None is a market order, trigger= makes it a stop (see Stop Orders). Returns its id |
sim.post_bracket(...) | post_bracket(symbol, side, qty, *, price=None, trigger=None, trigger_on="last", tif="GTC", expire_at=None, stop=None, target=None, legs_on="last", exit_after=None, exit_at=None): send an order and the exits the venue holds for the position it opens (see Brackets). Returns the id of the entry |
sim.cancel(id) | Cancel an order; it can fill until the cancellation reaches the venue |
sim.replace(id, ...) | replace(id, price=None, qty=None): change its price, its size, or both. A smaller size at the same price keeps its place in the queue |
sim.order(id) | One order as the strategy knows it: status, filled, remaining, avg_price, queue_ahead, ... |
sim.orders(...) | Its orders in id order, filtered by symbol=, side= and status= |
sim.position(symbol) | The position, as the fills that reached the strategy say |
sim.cash() | The cash, the same way |
The clock starts on the first event of the data, already applied: the first print is in sim.trades() and the first book in sim.book() before the first wait or elapse, and wait wakes on the events after it. A strategy that acts on every print reads the state once before its loop:
def strategy(sim):
on_prints(sim) # the first event of the data
while sim.wait(trade=True): # false once the data has ended
on_prints(sim)mbt.sim.Config(latency=None, queue=None, maker_fee_bps=0.0, taker_fee_bps=0.0,
initial_cash=0.0, seed=0, prints_update_book=None, stop_in_market="trigger")
mbt.sim.run(strategy, config=None, store=None, symbols=None, start=None, end=None,
*, markets=None, levels=None, by_day="auto", device="cpu")
latency takes four delays: "entry" for a post or a replacement to reach the venue, "cancel" for a cancellation ("entry" when left out), "response" for an answer or a fill to reach the strategy, "feed" for a market event to. Note the difference with ExecutionConfig.latency, which names the first one "order" and leaves "cancel" at zero when it is left out. queue takes what fill_model["queue"] takes; left out, it is the exact queue on a market stored order by order and risk_adverse on the others. seed is accepted and passed to the venue, but no queue model draws from it today: every queue model is deterministic, so two seeds give the same run to the bit. prints_update_book and stop_in_market are described below.
strategy is a function of the venue, or a plan. levels bounds how many levels a side the venue and the strategy read. by_day="auto" holds a run longer than a day one UTC day at a time, with the same result bit for bit. The run returns a SimResult: fills_df(), orders_df(), events_df(), equity_df(), fill_marks, fill_fragility, metrics and summary(). The *_df methods return a pandas DataFrame when pandas is installed, a polars one when only polars is (backend="pandas" or "polars" picks one), and a dict of column name to list with neither, or with backend="arrow".
A stored book is a series of states, and between two of them the tape goes on: a seller can sell through the bid the last state shows before the next state says the bid moved. By default an order that executes on arrival meets the last stored state, so a market sell sent on that print fills at the old bid. A venue that matches on trades fills it at the print instead, and so does the simulated venue with prints_update_book:
config = mbt.sim.Config(prints_update_book="aggressed_side") # or "both_sides""aggressed_side": from each print to the next stored state, the side the print traded against is updated. A print where the seller crossed, at p for q, leaves no bid above p, and q at p (or the stored size there, when it is larger). The other side is the stored side."both_sides": both sides, whoever crossed, as a top-of-book venue that executes on trades has them. Each side is the print alone, p for q, no stored level behind it; a market order larger than q fills its rest one tick worse than p. This follows the convention of trade-driven execution in other event-driven engines such as Nautilus Trader.The update lives at the venue's clock. With zero latency, a strategy that wakes on a print (sim.wait(trade=True)) and sends a market order meets that print; an order that arrives after the next stored state meets that state. None, the default, is the stored book alone.
A market of prints alone. With no stored state at all, each side keeps its last print to the end of the run, so orders execute against the prints. sim.book() still reads the stored book, empty there: best_bid and best_ask stay None, and a strategy that waits for a price in the book never sends an order. Read prices off sim.trades() instead. mbt.sim.run warns of such a market, and says when no order can fill there at all, without prints_update_book.
A stop order waits at the venue, out of the book, until the market reaches its trigger, and then executes:
# A stop-market: buys 100 at market once a print reaches 190.50.
sim.post("AAPL", "buy", None, 100, trigger=190.50)
# A stop-limit on the bid, until the close: sells at 189.80 or better once the
# stored best bid is at or below 189.90.
sim.post("AAPL", "sell", 189.80, 100, trigger=189.90, trigger_on="bid_ask",
tif="GTD", expire_at=close_ns)
mbt.sim.Config(stop_in_market="reject") # "trigger" by defaulttrigger_on="last", the default: a print at or through the trigger, whichever side crossed. trigger_on="bid_ask": a stored book state whose best ask (a buy stop) or best bid (a sell stop) is at or through it.stop_in_market="reject". Otherwise it is armed: live, out of the book, listed by sim.orders(status="armed"). Its tif is its life while it waits, "GTC" or "GTD" with expire_at.after_trigger="rest", the default) or expires ("ioc").sim.order(id) carries trigger_price, trigger_on, after_trigger and triggered_at; the journal shows armed and triggered.A run without a waiting stop costs nothing more: the venue reads its stops only when one is armed.
A bracket is an order that opens a position, and the exits the venue holds for it: a stop, a target, an exit at a time. The venue arms them once the order has filled, takes the others off when the first one goes, and closes the position whole.
# Buys 100 at market; once filled, a stop at 189.50, a target at 192.00 and an
# exit 30 minutes later, whichever comes first.
parent = sim.post_bracket("AAPL", "buy", 100, stop=189.50, target=192.00,
exit_after=Interval.minutes(30))
# A limit entry until t_ns, its legs on the bid and the ask, out at the third
# session close after the entry at the latest.
sim.post_bracket("AAPL", "sell", 100, price=191.20, tif="GTD", expire_at=t_ns,
stop=192.40, target=189.00, legs_on="bid_ask", exit_at=(closes_ns, 3))sim.post sends it: market (price=None), limit at price, or a stop with trigger=."stop" leg, a "target" leg (market-if-touched) and a "time" leg, set off by the last print (legs_on="last", the default) or the stored best bid or ask ("bid_ask"). exit_at takes an instant, a list of instants (the first after the entry), or (instants, n), the n-th of them.carried in the journal).parent and role set in sim.orders() and res.orders_df(). Cancelling one takes that leg alone off.Market.from_arrays() builds a market from columns: a day of your own quotes, top of book or deeper, and prints, without one Python object per event. A trading day of quotes and prints builds in milliseconds, and the market is then reused by every run and every sweep you give it to.
market = mbt.sim.Market.from_arrays(
"AAPL", tick_size=0.01,
quotes=nbbo, # ts_ns, bid_px, bid_qty, ask_px, ask_qty
trades=prints, # ts_ns, price, qty, and aggressor when the tape has one
)
res = mbt.sim.run(strategy, markets=[market])quotes and trades are each a pandas or polars DataFrame, a pyarrow Table or RecordBatch, or a dict of numpy arrays. Columns it does not read are ignored, and either may be left out.ts_ns is integer nanoseconds since the epoch, UTC, or a timestamp column of any unit (timestamp is read when there is no ts_ns). Rows come in time order, several at one instant allowed: of several quotes at one instant the last one stands; prints at one instant keep their order, and come before the book at an instant holding both.bid_px_0, bid_px_1, ... or bid_px_00, ..., read when there is no bid_px. A side ends at its first level without a price, and the book keeps as many levels as the deepest side.from_arrays() counts the book states left empty in a warning.aggressor is "buy" / "sell", or 1 for a buy and -1 or 2 for a sell. A print without one takes the tick rule. A 0 is refused rather than guessed.bid_px included on a market with no bids.Market.from_ladders() below builds the same book from Python lists, and a market can also come from the store.
market = mbt.sim.Market.from_ladders(
"BTCUSDT", tick_size=0.1,
states=[(1_000_000, [(100.0, 2.0), (99.9, 9.0)], [(100.1, 5.0)])],
trades=[(3_000_000, 100.0, 3.0, "sell")],
)
res = mbt.sim.run(maker, markets=[market])Market.from_ladders() takes book states as (ts_ns, bids, asks), each side best first as (price, qty) pairs, and prints as (ts_ns, price, qty, aggressor). It is the way to test a strategy function on a situation you wrote, before running it on a stored day. Market.from_store(store, symbol, start_ns, end_ns) builds the same object from the store.
Market.from_store() refuses a symbol whose store holds prints and no book. A venue that publishes no book history, such as a Polymarket market ingested with mbt.ingest_trades(), is replayed from its tape on request, with tape_only=True, and the prints stand for the book:
store = mbt.ingest_trades("polymarket", "will-x-happen/Yes", symbol_id=1,
start="2026-11-01", end="2026-11-08")
market = mbt.sim.Market.from_store(store, "will-x-happen/Yes", start_ns, end_ns,
tape_only=True)
sym = market.name # "WILL-X-HAPPEN/YES", as the store files it
def strategy(sim):
bought = False
while sim.wait(trade=True): # false once the data has ended
prices = [p for _, p, _, _ in sim.trades(sym)]
if not bought and min(prices) < 0.30:
sim.post(sym, "buy", None, 10.0, tif="IOC")
bought = True
res = mbt.sim.run(strategy, mbt.sim.Config(prints_update_book="both_sides"),
markets=[market])A market read so is held whole and takes neither levels, by_day nor device; a range without a print is refused by name. Several outcomes of one event are several markets in the same run. Three things the simulation does not do on such a market, and how to do them from what it returns:
sim.book() stays empty: read prices off sim.trades(), as above. Orders execute against the last print of each side, its size included. Quotes of your own, with the tape, through Market.from_arrays(quotes=, trades=), replace that assumption.taker_fee_bps is a rate on the notional, and Polymarket charges a rate on p * (1 - p) per share, set market by market. Leave the fees at zero and charge them on the fills.mbt.run, which settles a resolved market ingested with mbt.ingest(provider="polymarket") on its own.fills = res.fills_df(backend="arrow")
fee_rate = 0.02 # the market's taker fee rate, read off Polymarket
fees = sum(fee_rate * q * p * (1 - p) for p, q in zip(fills["price"], fills["qty"]))
sign = [1 if s == "buy" else -1 for s in fills["side"]]
cost = sum(k * p * q for k, p, q in zip(sign, fills["price"], fills["qty"]))
shares = sum(k * q for k, q in zip(sign, fills["qty"]))
payout = 1.0 # what one share of the outcome paid at resolution
pnl = shares * payout - cost - feesWhen what a strategy function does is a schedule (decide at set instants or at the rows of a table, send one order, hold the position with exits the venue keeps), it can be written down as a plan: mbt.sim.Plan. The engine runs it on the venue of Orders, One by One without calling back into Python, and mbt.sim.sweep runs every variant of a grid of its parameters, in parallel. Each variant is the run mbt.sim.run makes of the same plan, with the same parameters and the same config, to the bit.
import manifoldbt as mbt
from manifoldbt.helpers import Interval
S = mbt.sim
# A market order at the first print at or after each deadline (the open + k x
# the interval), sides in turn.
plan = S.Plan(
when=S.every(mbt.param("every", default=Interval.minutes(5)), on="print",
anchor="session"),
entry=S.market(),
side=S.alternate("buy"),
size=mbt.param("qty", default=100),
sessions=S.sessions(open="09:30", close="16:00", tz="America/New_York"),
)
res = S.sweep(plan, {"every": [Interval.minutes(m) for m in (1, 2, 3, 5, 10)],
"qty": [100, 200, 500]},
markets=[market], config=S.Config(prints_update_book="both_sides"))
res.to_df() # one row per variant: param_*, exec_*, the metrics
res.fills_df(3) # the fills of variant 3, computed again on demand
res.run(3) # variant 3 run again: the whole SimResult
# Stop entries that expire, a bracket, positions held over several sessions.
plan = S.Plan(
when=S.setups(signals), # ts_ns, side, level
entry=S.stop(offset=mbt.param("off", default=1), unit="ticks", on="last",
expire_after=mbt.param("expiry", default=Interval.minutes(30))),
side="from_setups",
size=S.notional(10_000, lot=1),
exit=S.bracket(stop=mbt.param("sl", default=1.5), target=mbt.param("tp", default=3.0),
unit="pct", on="last", hold=S.closes(3)),
sessions=S.sessions(open="09:30", close="16:00", tz="America/New_York",
entries=("09:45", "15:30")),
)
res = S.sweep(plan, {"off": [0, 1, 2, 3], "sl": [1.0, 1.5, 2.0], "tp": [2.0, 3.0]},
markets=[market])
S.run(plan, config, markets=[market]) # one plan, its defaults: a SimResultOnce the market is built, a sweep of a hundred stop-and-bracket variants over a trading day takes a few milliseconds.
| Part | What it takes |
|---|---|
when | S.every(period, on=, anchor=): at anchor + k * period, the anchor the market's first instant ("start", the default) or each session's open ("session"); on the clock (on="clock", the default) or at the first print at or after it ("print"). Or S.setups(table): one decision per row, at its instant |
entry | S.market(); S.limit(offset, unit=, tif=), a limit through the market that executes at its arrival ("IOC", the default, or "FOK"); S.stop(offset, unit=, on=, expire_after=); S.stop_limit(offset, limit_offset, unit=, on=, expire_after=, after_trigger=) |
side | "buy", "sell", S.alternate(first) (then the other side, entry after entry), or "from_setups" |
size | A size; S.notional(amount, lot=1), floor(amount / price / lot) * lot; or "from_setups" |
exit | S.bracket(stop=, target=, unit=, on=, hold=): a stop and a target at a distance from the price the entry filled at, and a hold, a duration or S.closes(n) |
sessions | S.sessions(open=, close=, tz=, entries=): the weekday sessions, in a time zone, and the window of each in which entries are decided |
sample_every | The step of the equity samples the metrics read; a minute when left out |
Every number and every duration of a plan takes a value or mbt.param("name", default=...); any other expression is refused by name. Durations are an Interval, a datetime.timedelta or nanoseconds. A unit is "ticks" (the default), "bps" or "pct" of the price the distance is counted from, or "price".
S.setups(table) reads a pandas or polars DataFrame, a pyarrow table or a dict of arrays: ts_ns (the rows in time order) and, when the table has them, side, level (the reference price of the row; missing, the last print) and size. The signals of a plan arrive this way: computed by you, on your own data, before the run.
The time zone of S.sessions is read by Python's zoneinfo; on Windows, pip install tzdata brings the time zones.
exit, a decision is skipped while the strategy knows of a live order or an open position. Without one, the plan is a calendar and every decision sends its order.level, or from the last print the strategy has seen at the decision; a decision without one is skipped. A buy stop sits at the reference plus offset, a sell stop at it minus offset, on the tick grid away from the market. A notional is read against the entry's own level.sim.post: see Stop Orders and Brackets.S.closes(n) exits at the n-th session close after the entry filled. A position still open at the end of the data is marked at the last mid.res = S.sweep(plan, {"every": [...], "qty": [100, 200]}, config, markets=[market],
execution_grid={"latency.entry": [0, Interval.millis(5)],
"prints_update_book": [None, "both_sides"]},
max_parallelism=None, keep="metrics")param_grid is {name: [values]} for the mbt.param the plan names; a parameter left out takes its default. An axis that no field of the plan reads is refused by name, so a flat surface always means a parameter that does not matter.execution_grid is {path: [values]} over the keys of mbt.sim.Config, dotted: "latency.entry", "latency.feed", "latency.response", "prints_update_book", "taker_fee_bps".markets=[market]) or from a store (store=, symbol=, start=, end=, by_day=), and every variant runs on it in the engine, in parallel. max_parallelism bounds the threads; the results do not depend on it. A market held day by day is swept a day at a time for every variant together.keep="metrics" (the default) keeps one row of metrics per variant; keep="fills" keeps the fills of every variant as well.engine="auto" (the default), "direct" or "sim" say how the variants run, to the same results. Simulated fills take nothing out of the stored book, so "direct" reads the market only at the instants each variant acts, once for every variant; "sim" plays every event of the market for each variant. "auto" takes "direct" for a plan whose orders never rest in the book, and says which and why in res.engine and res.engine_reason.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 mbt.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, and result.round_trips_df() returns it, one row per entry-to-flat cycle, with the same count, win rate and expectancy as trade_stats.
| 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 |
| 7 | Liquidation: equity reached the maintenance margin of a leveraged position (cfg.liquidation, see Liquidation) |
The lite paths (run_sweep_lite(), run_batch_lite(), the GPU sweep) skip the log by design and keep only trade_count; run(), run_batch() and the full run_sweep() always build it. Export is whatever your DataFrame library offers, or Arrow directly:
trades = result.trades_df() # pandas, or backend="polars"
# Decode the codes
SIDE = {1: "buy", 2: "sell"}
REASON = {0: "signal", 1: "stop_loss", 2: "take_profit", 3: "trailing_stop",
4: "limit_expiry", 5: "option_expiry", 6: "margin_call", 7: "liquidation"}
trades["side"] = trades["side"].map(SIDE)
trades["exit_reason"] = trades["exit_reason"].map(REASON)
# Export
trades.to_csv("trades.csv", index=False)
# Or straight from Arrow, without a DataFrame in the loop
import pyarrow as pa, pyarrow.parquet as pq
pq.write_table(pa.Table.from_batches([result.trades]), "trades.parquet")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, read as UTC, to a (start_ns, end_ns) tuple of nanoseconds since the Unix epoch. Accepts "YYYY-MM-DD", "YYYY-MM-DD HH:MM:SS" and "YYYY-MM-DDTHH:MM:SS". A trailing Z, a UTC offset or fractional seconds are rejected: "2024-01-01T00:00:00" works, "2024-01-01T00:00:00Z" raises.
date_to_ns(date_str: str) -> int
Convert a single date string, in the same formats, to nanoseconds since the Unix epoch (UTC).
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 23 top-level fields plus a nested trade_stats block, itself nesting a signal_quality block. Access them via result.metrics["sharpe"]. result.summary() prints a curated subset for reading, not the whole dict, so use json.dumps(result.metrics, indent=2) when you want to see everything the engine computed.
| 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 |
| final_equity | Equity at the end of the run, in money |
| pnl | final_equity minus initial_capital, in money: what the run made or lost |
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(
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=None, drift=2,
title=None, figsize=(10, 8), show=None, save=None,
) -> Figure
2D parameter sweep heatmap from run_sweep_2d() result. Uses Gaussian smoothing to highlight the plateau-optimal best region (overfit-resistant). Annotations auto-disabled when grid exceeds 100 cells.
| 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 | list[float] | None | None | Colour cells by robustness zone instead of by the metric. A zone is what the area still guarantees when the parameters move by drift cells, so a lucky spike lands in a low zone despite scoring well on its own cell. True picks the bands (0 / 0.5 / 1.0 / 1.5 for risk-adjusted ratios, an even split of the observed range otherwise); a list of thresholds sets them. The metric value stays on hover and in the cell labels |
| drift | int | 2 | Neighbourhood radius, in grid cells, of the worst case zones reads. Cells, not parameter units: with an x step of 1 and a y step of 5, drift=2 means ±2 on x and ±10 on y |
surface_3d(
sweep_result: dict,
*, highlight_best=True, zones=None, drift=2,
title=None, figsize=(12, 8),
elev=30, azim=-45, show=None, save=None,
) -> Figure
3D surface plot from a 2D parameter sweep result. Same input format as heatmap_2d.
| 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 | list[float] | None | None | Robustness zones, same semantics as heatmap_2d |
| drift | int | 2 | Neighbourhood radius in grid cells, same semantics as heatmap_2d |
monte_carlo(
result,
*, n_simulations=1000, method="bootstrap",
percentiles=None, n_sample_paths=50,
ax=None, median_color="#5b7ff5", band_color="#5b7ff5",
title=None, figsize=(12, 5), seed=None, show=None, save=None,
) -> Figure
Monte Carlo fan chart with percentile bands, sample paths, and risk stats.
| Argument | Type | Default | Description |
|---|---|---|---|
| result | Result | -- | BacktestResult from mbt.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 of the store. A store written by ingest, import_csv or import_dataframe lives under <data_root>/mega and is opened as Arrow IPC on its own; without that directory, data_root is read as Parquet |
| metadata_db | str | "metadata/metadata.sqlite" | Path to the SQLite metadata database, as given: a relative path is read from the current directory, not from 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",
)
# A Parquet store with a dataset of its own. Naming one turns off the
# Arrow IPC detection above: a store written by ingest needs no dataset.
store = mbt.DataStore(
data_root="data",
dataset="bars_15m",
)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 | No effect in a portfolio run: the legs are independent backtests, each capped by its own max_position_pct |
| .rebalance_periodic(n) | rebalance_periodic(every_n_bars: int) -> Portfolio | No effect in a portfolio run: allocations are set once, at the start |
| .rebalance_threshold(pct) | rebalance_threshold(drift_pct: float) -> Portfolio | No effect in a portfolio run: allocations are set once, at the start |
| .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 would fill at a price that precedes the close that produced the signal, so since 0.27 the run raises a ConfigError: use signal_delay=1.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 the research tools, and Researcher adds the GPU and the tick layer to everything Pro has:
| Feature | Community | Pro | Researcher |
|---|---|---|---|
| Simulation step | 1 minute | Down to 1 second | Down to 1 second, plus the trade clock (one row per print) and, for a strategy that quotes, a wake-up clock in milliseconds |
| Output resolution | Daily | Down to 1 second | Down to 1 second, or per print on the trade clock |
| Sweeps, stability | 256 runs per session, all sweeps of the Python process together | Unlimited | Unlimited |
| Monte Carlo | 1,000 paths, stochastic paths included | Unlimited | Unlimited |
| Walk-forward | No | Anchored, blocked, Pardo and custom geometries | The same, on the GPU too |
| GPU | No | No | device="cuda" on sweeps, walk-forward, Monte Carlo and stochastic paths |
| Crypto connectors | Yes | Yes | Yes |
| Databento, Massive | No | Yes | Yes |
| Cross-exchange | No | Yes | Yes |
| detect_lookahead | No | Yes | Yes |
| Tick layer | No | No | The tape, the order book, order-by-order data, quotes and mbt.sim: see Tick Data |
check_exposure_stability(), risk_check() and the tearsheets have no gate. Firm, for teams, includes everything in Researcher: see pricing.
After buying Pro or Researcher at manifoldbt.com (or receiving a trial), you get a short activation code by email, a UUID like 3f9c2a17-8b4e-4c6a-9f21-7d0e5a1b2c3d. Activate with that code, no key pasted in your source. The CLI is the recommended way:
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 together with a signed lease from the licensing server. You only activate once per machine. After that the license loads automatically on every import, and the check runs locally: the engine verifies the signatures of the license and of the lease and reads the lease's expiry, without waiting for the network. While a valid lease is on disk, the import also starts a renewal in the background, which sends the license ID, its email and a hash of the machine and replaces the lease when the server answers.
Offline, that renewal simply fails and the license keeps working until the stored lease expires. Once it has expired, or if none is stored, the import waits up to 5 seconds for the server; without an answer, the paid features refuse with an error asking for a connection and the engine runs as Community. The license file stays in place, and the next import that reaches the server restores them. Without a license installed, the engine makes no licensing request.
A trial code activates exactly like a full license, but carries an expiry date signed into the license. Until it expires it unlocks everything its plan does, offline included. Once the expiry passes, the engine reverts to Community automatically, enforced offline by the engine and confirmed by the licensing server. To extend or convert a trial, activate a new code.
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) # "Researcher", "Pro" or "Community"
print(email) # your email or None
Features are gated at runtime. A Pro feature called on Community (e.g. run_walk_forward()), or the GPU called without Researcher, raises a LicenseError with a clear message. The tick layer called without Researcher raises a PermissionError that names the plan, before anything is downloaded. license_info() reports the actual tier: a Researcher licence reports "Researcher" and includes everything Pro has, so a check for a paid tier should accept both "Pro" and "Researcher".
The rest of the site, where it goes further than this reference. Each link opens in the full window.