20 KiB
Strategy Authoring Guide
manifoldbt — Python DSL for Declarative Strategy Definition
This guide describes how to define trading strategies using the manifoldbt Python DSL. Strategies are compiled into an optimized expression graph and executed by the Rust vectorized engine.
Table of Contents
- Quick Start
- Indicators
- Signals & Sizing
- Parameters & Sweeps
- Backtest Configuration
- Execution Model
- Fee & Slippage Models
- Orders (SL/TP/Trailing)
- Entry Orders
- Cross-Asset References
- Dataset Auto-Resolution
- Diagnostics
- Profiling
- Complete Examples
- Indicator Reference
Quick Start
import manifoldbt as mbt
from manifoldbt.indicators import close, ema
from manifoldbt.helpers import time_range, Slippage, Interval
# -- Indicators
fast = ema(close, 12)
slow = ema(close, 50)
# -- Strategy
strategy = (
mbt.Strategy.create("ema_cross")
.signal("fast", fast)
.signal("slow", slow)
.size(mbt.when(fast > slow, 0.5, 0.0))
.stop_loss(pct=3.0)
)
# -- Config
start, end = time_range("2022-01-01", "2025-01-01")
config = mbt.BacktestConfig(
universe=[1],
time_range_start=start,
time_range_end=end,
bar_interval=Interval.hours(12),
initial_capital=10_000,
fees=mbt.FeeConfig.binance_perps(),
slippage=Slippage.fixed_bps(2),
warmup_bars=60,
)
# -- Run
store = mbt.DataStore(data_root="data", metadata_db="metadata/metadata.sqlite")
result = mbt.run(strategy, config, store)
print(result.summary())
Indicators
All indicators are available from manifoldbt.indicators. They return Expr objects that compose into the expression graph — no data is touched at definition time.
from manifoldbt.indicators import (
close, open, high, low, volume, # price columns
ema, sma, dema, tema, wma, hma, kama, # moving averages
rsi, roc, momentum, macd, # momentum
bollinger_bands, atr, natr, keltner_channels, # volatility
stoch_k, williams_r, cci, adx, # oscillators
obv, vwap, mfi, # volume
kalman, garch, # filters
)
Usage
fast = ema(close, 12) # EMA with span 12
slow = sma(close, 50) # SMA with window 50
strength = rsi(close, 14) # RSI with period 14
upper, mid, lower = bollinger_bands(close, period=20, num_std=2.0)
Method chaining
Column expressions (close, high, etc.) support method chaining:
zscore = close.zscore(60) # rolling z-score
slope = close.linreg_slope(20) # linear regression slope
smoothed = close.ewm_mean(12) # EMA
lagged = close.lag(5) # 5-bar lag
ret = close.pct_change(1) # 1-bar return
Signals & Sizing
Strategy builder
strategy = (
mbt.Strategy.create("my_strategy")
.signal("fast", fast) # named signal
.signal("slow", slow) # signals form a DAG
.size(signal_expr) # position sizing expression
.describe("Strategy description")
)
mbt.when() — conditional logic
# Long when fast > slow, flat otherwise
signal = mbt.when(fast > slow, 0.5, 0.0)
# Nested: long / short / flat
signal = mbt.when(fast > slow, 0.25,
mbt.when(fast < slow, -0.25, 0.0))
# Hold current position (omit 3rd arg or use NaN)
signal = mbt.when(rsi < 30, 1.0) # buy oversold, hold otherwise
Arithmetic on expressions
trend = fast - slow
spread = close / (pair_close + mbt.lit(1e-12)) # mbt.lit() for constants in arithmetic
signal = -spread_z * mbt.lit(0.05) # negation + scaling
Note:
mbt.lit()is needed for constants in arithmetic (close + mbt.lit(1e-12)). Numbers auto-coerce insidembt.when().
Sizing modes
| Mode | Meaning |
|---|---|
FractionOfEquity (default) |
1.0 = allocate 100% of current equity |
FractionOfInitialCapital |
1.0 = allocate 100% of initial capital (no compounding) |
Units |
1.0 = hold exactly 1 unit (share/contract/coin) |
execution=mbt.ExecutionConfig(position_sizing_mode="FractionOfInitialCapital")
Special values
| Value | Behavior |
|---|---|
1.0 |
Full long position |
0.0 |
Flat (close position) |
-0.5 |
Short 50% (requires allow_short=True) |
NaN |
Hold current position unchanged |
Parameters & Sweeps
Use mbt.param() to define sweepable parameters in indicator periods:
fast = ema(close, mbt.param("fast", default=12))
slow = ema(close, mbt.param("slow", default=50))
strategy = (
mbt.Strategy.create("ema_cross")
.signal("fast", fast)
.signal("slow", slow)
.size(mbt.when(fast > slow, 0.25, -0.25))
)
Parameters are auto-collected from expressions — no .param() needed on the Strategy.
Sweep execution
# Full sweep (returns Result per combo)
sweep = mbt.run_sweep(strategy, {"fast": [5, 12, 20], "slow": [50, 100]}, config, store)
best = sweep.best("sharpe")
# Lite sweep (metrics only, much faster for large grids)
batch = mbt.run_sweep_lite(strategy, {"fast": range(5, 100), "slow": range(10, 500)}, config, store)
Grids this size need Pro. Community is capped at 256 backtests cumulatively per
Python session across all sweep/batch calls, and each sweep call waits 5 s
before starting; single bt.run() calls are never gated. See
docs/sweep-combo-limit-plan.md.
run_sweep_lite is optimized for large parameter grids (100k+ combos):
- Cartesian product expansion in Rust (no Python loop)
- Shared indicator cache (EMA(12) computed once, reused across combos)
- Pre-resampled bars (no per-combo resample overhead)
- Metrics only — no Arrow output
Backtest Configuration
config = mbt.BacktestConfig(
universe=[1, 2], # symbol IDs
time_range_start=start,
time_range_end=end,
bar_interval=Interval.hours(4), # signal evaluation resolution
initial_capital=10_000,
execution=mbt.ExecutionConfig(...),
fees=mbt.FeeConfig.binance_perps(),
slippage=Slippage.fixed_bps(2),
warmup_bars=60, # bars to skip for indicator warmup
accuracy=False, # True = simulate on 1-min bars
)
Bar intervals
Interval.minutes(1) # 1-min
Interval.minutes(15) # 15-min
Interval.hours(1) # 1-hour
Interval.hours(4) # 4-hour
Interval.hours(12) # 12-hour
Interval.days(1) # daily
Accuracy mode
config = mbt.BacktestConfig(
bar_interval=Interval.hours(4), # signals on 4h
accuracy=True, # simulation on 1-min bars
...
)
When accuracy=True, the engine loads bars_1m and runs in hybrid mode: signals evaluated on bar_interval, simulation tick-by-tick on 1-min bars. Use for precise SL/TP fill detection. ~60x slower than normal mode.
Execution Model
mbt.ExecutionConfig(
signal_delay=1, # bars between signal and execution
execution_price="AtClose", # fill price: AtClose, AtOpen, AtVwap, MidPrice
max_position_pct=0.5, # max position as fraction of equity
allow_short=True, # allow short positions
allow_fractional=True, # allow fractional units
position_sizing_mode="FractionOfEquity",
pyramiding=False, # True = signal is delta, not target
)
Signal delay
| Value | Behavior |
|---|---|
0 |
Execute same bar (look-ahead bias risk) |
1 |
Default. Execute next bar (t+1) |
2+ |
Execute N bars after signal |
Fee & Slippage Models
Fees
mbt.FeeConfig.binance_perps() # maker=2bps, taker=5bps, funding
mbt.FeeConfig.binance_spot() # maker=10bps, taker=10bps
mbt.FeeConfig.zero() # no fees (for development)
# Custom
mbt.FeeConfig(
maker_fee_bps=2.0,
taker_fee_bps=5.0,
funding_rate_column="funding_rate",
default_fill_type="Taker",
)
Slippage
Slippage.fixed_bps(2) # 2 bps per trade (simplest)
Slippage.volume_impact(0.1, exponent=0.5) # qty/volume model
Slippage.spread_based(0.5) # spread-based
Orders (SL/TP/Trailing)
strategy = (
mbt.Strategy.create("my_strat")
.signal(...)
.size(...)
.stop_loss(pct=3.0) # 3% stop-loss
.take_profit(pct=5.0) # 5% take-profit
.trailing_stop(pct=2.0) # 2% trailing stop
)
Entry Orders
By default an entry takes a market fill on the execution bar (see Execution Model). Four order types let the entry rest at a price instead:
| Builder method | Fills when | Fill price | Costs |
|---|---|---|---|
.limit_entry(...) |
price comes to the level | the level exactly | maker, no slippage |
.stop_entry(...) |
price breaks through the level | the level, or the open if the bar gapped through it | taker + slippage |
.market_if_touched(...) |
price comes to the level | the level | taker + slippage |
.stop_limit_entry(...) |
breaks through stop, then rests at limit |
the limit | maker, no slippage |
Where the level comes from
Every method takes exactly one of three price forms:
.limit_entry(offset_bps=25) # 25 bps below the signal close (above, for a sell)
.limit_entry(price=60_000) # a fixed level
.limit_entry(signal="entry_px") # a level this strategy computes
signal= is the general form: name any signal the strategy defines and the
order rests on that series, read on the signal bar.
from manifoldbt.indicators import atr, close, ema
trend = ema(close, 50)
entry_px = close - atr(14) # rest one ATR below the close
strategy = (
mbt.Strategy.create("pullback_entry")
.signal("trend", trend)
.signal("entry_px", entry_px) # named so the order can reference it
.size(mbt.when(close > trend, 1.0, 0.0))
.limit_entry(signal="entry_px", time_in_force={"GTB": 5})
.stop_loss(pct=3.0)
)
Time in force
"GTC" (default, rests until filled or the signal changes), {"GTB": n}
(cancel after n bars), "IOC" (fill on the arrival bar or cancel).
Two things to watch
A resting entry can simply never fill. A strategy whose entries never trigger produces a flat equity curve with no drawdown, which reads as a clean backtest. The engine counts unfilled entries and reports them:
result = mbt.run_backtest(strategy, config)
for w in result.warnings:
print(w) # "N entry order(s) expired unfilled and M were still resting ..."
Sizing uses the close, not the level. In FractionOfEquity mode a target of
1.0 is converted to units at the signal-bar close, so an entry resting 2% away
buys ~2% too much notional. size_at_fill_price=True sizes off the order's own
level instead. It is off by default because turning it on changes the results of
strategies written against the old behaviour.
Cost
A conditional entry runs on the general simulation loop rather than the fast
kernel, so parameter sweeps over one are slower than sweeps over a market entry
and cannot use the GPU. run_sweep reports which setting took you off the fast
path.
Cross-Asset References
Use mbt.symbol_ref() to reference another symbol's data in multi-asset strategies:
pair_close = mbt.symbol_ref("ETHUSDT", "close")
ratio = close / (pair_close + mbt.lit(1e-12))
Important: Expressions using
symbol_ref()must be registered as named signals (.signal("name", expr)), not passed directly to.size(). The multi-pass evaluator needs named signals to route cross-asset data correctly.
# Required: symbol_names mapping
config = mbt.BacktestConfig(
universe=[1, 2, 5],
symbol_names={"BTCUSDT": 1, "ETHUSDT": 2, "BNBUSDT": 5},
...
)
Dataset Auto-Resolution
The engine automatically selects the best dataset based on bar_interval:
| bar_interval | Dataset loaded | Bars (5 years) |
|---|---|---|
| 1 min | bars_1m |
~2.6M |
| 15 min | bars_15m |
~175k |
| 1h - 23h | bars_1h |
~44k |
| >= 24h | bars_1d |
~1.8k |
When bar_interval doesn't exactly match a dataset (e.g. 4h), the engine loads the closest smaller dataset (bars_1h) and pre-resamples to 4h before simulation.
Override with accuracy=True to always load bars_1m (precise SL/TP fills).
Override manually with dataset=:
store = mbt.DataStore(data_root="data", metadata_db="...", dataset="bars_1m")
Diagnostics
# Look-ahead bias detection
lookahead = mbt.diagnostics.detect_lookahead(strategy, config, store)
print(lookahead) # PASS or FAIL with details
# Exposure stability (position consistency across time windows)
stability = mbt.diagnostics.check_exposure_stability(strategy, config, store)
# Post-run risk check
result = mbt.run(strategy, config, store)
risk = mbt.diagnostics.risk_check(result)
Profiling
Every result includes microsecond-precision timing:
result = mbt.run(strategy, config, store)
print(result.profile)
# {'data_load_us': 45000, 'align_us': 1000, 'signal_eval_us': 28000,
# 'runtime_prep_us': 500, 'simulation_us': 16000, 'output_build_us': 8000,
# 'total_us': 110000}
print(result.profile_summary())
# Profile (total: 110.0ms)
# ----------------------------------------
# Data loading 45.0ms 40.9% ################
# Signal eval 28.0ms 25.5% ##########
# Simulation 16.0ms 14.5% #####
# ...
Complete Examples
Trend Following — EMA Crossover
import manifoldbt as mbt
from manifoldbt.indicators import close, ema
from manifoldbt.helpers import time_range, Slippage, Interval
fast = ema(close, 12)
slow = ema(close, 50)
strategy = (
mbt.Strategy.create("trend_following")
.signal("fast", fast)
.signal("slow", slow)
.size(mbt.when(fast > slow, 0.5, 0.0))
.stop_loss(pct=3.0)
)
start, end = time_range("2022-01-01", "2025-01-01")
config = mbt.BacktestConfig(
universe=[1], time_range_start=start, time_range_end=end,
bar_interval=Interval.hours(12), initial_capital=10_000,
fees=mbt.FeeConfig.binance_perps(), slippage=Slippage.fixed_bps(2),
warmup_bars=60,
)
store = mbt.DataStore(data_root="data", metadata_db="metadata/metadata.sqlite")
result = mbt.run(strategy, config, store)
print(result.summary())
Parameter Sweep — 2D Heatmap
fast = ema(close, mbt.param("fast", default=12))
slow = ema(close, mbt.param("slow", default=50))
strategy = (
mbt.Strategy.create("ema_cross")
.signal("fast", fast)
.signal("slow", slow)
.size(mbt.when(fast > slow, 0.25, -0.25))
)
batch = mbt.run_sweep_lite(
strategy,
{"fast": list(range(5, 100)), "slow": list(range(10, 500))},
config, store,
)
# Build metric grid and visualize
mbt.plot.heatmap_2d({...}, show=True)
mbt.plot.surface_3d({...}, show=True)
Statistical Arbitrage — Cross-Asset
pair_close = mbt.symbol_ref("ETHUSDT", "close")
ratio = close / (pair_close + mbt.lit(1e-12))
equilibrium = kalman(ratio, q=1e-4, r=1e-2)
spread_z = (ratio - equilibrium).zscore(28)
strategy = (
mbt.Strategy.create("stat_arb")
.signal("pair_close", pair_close)
.signal("spread_z", spread_z)
.signal("signal", -spread_z)
.size(mbt.col("signal"))
)
config = mbt.BacktestConfig(
universe=[1, 2, 5],
symbol_names={"BTCUSDT": 1, "ETHUSDT": 2, "BNBUSDT": 5},
...
)
Indicator Reference
Moving Averages
| Function | Description |
|---|---|
sma(source, period) |
Simple Moving Average |
ema(source, span) |
Exponential Moving Average |
dema(source, period) |
Double EMA |
tema(source, period) |
Triple EMA |
wma(source, period) |
Weighted MA |
hma(source, period) |
Hull MA |
kama(source, period) |
Kaufman Adaptive MA |
Momentum
| Function | Description |
|---|---|
rsi(source, period) |
Relative Strength Index [0-100] |
roc(source, period) |
Rate of Change |
momentum(source, period) |
Raw price difference |
macd(source, fast, slow) |
MACD line |
stoch_k(period) |
Stochastic %K |
williams_r(period) |
Williams %R |
cci(period) |
Commodity Channel Index |
adx(period) |
Average Directional Index |
Volatility
| Function | Description |
|---|---|
atr(period) |
Average True Range |
natr(period) |
Normalized ATR |
bollinger_bands(source, period, num_std) |
Returns (upper, middle, lower) |
keltner_channels(period, multiplier) |
Returns (upper, middle, lower) |
Volume
| Function | Description |
|---|---|
obv(source, vol) |
On-Balance Volume |
vwap() |
Volume-Weighted Average Price |
mfi(period) |
Money Flow Index |
Filters
| Function | Description |
|---|---|
kalman(source, q, r) |
Kalman filter |
garch(source, omega, alpha, beta) |
GARCH volatility |
Statistics
| Function | Description |
|---|---|
source.zscore(window) |
Rolling z-score |
source.linreg_slope(window) |
Linear regression slope |
source.linreg_value(window) |
Linear regression fitted value |
source.linreg_r2(window) |
Linear regression R-squared |
source.rolling_median(window) |
Rolling median |
Time
| Function | Description |
|---|---|
source.lag(n) |
Value n bars ago |
source.lead(n) |
Value n bars ahead |
source.diff(n) |
Difference over n bars |
source.pct_change(n) |
Percentage change over n bars |
source.rolling_mean(w) |
Rolling mean |
source.rolling_std(w) |
Rolling standard deviation |
source.cumsum() |
Cumulative sum |
All period/window arguments accept
mbt.param("name", default)for sweep grids.
Metrics Reference
Every result includes these performance metrics:
| Metric | Description |
|---|---|
total_return |
Total return |
cagr |
Compound Annual Growth Rate |
volatility |
Annualized volatility |
sharpe |
Sharpe ratio |
sortino |
Sortino ratio |
calmar |
Calmar ratio |
max_drawdown |
Maximum drawdown |
tstat_sharpe |
t-statistic of Sharpe (sharpe * sqrt(years)) |
alpha |
Annualized CAPM alpha vs buy-and-hold benchmark |
beta |
Beta to benchmark |
tstat_alpha |
t-statistic of alpha (OLS regression) |
Best Practices
- Use
signal_delay=1(default).signal_delay=0introduces look-ahead bias. - Set
warmup_barsto at least the longest indicator period. - Use
mbt.when()for sizing. Keep signal logic readable and composable. - Run diagnostics (
detect_lookahead,check_exposure_stability) on new strategies. - Start with
bar_interval=hours(12)ordays(1)for fast iteration, then refine with smaller intervals. - Use
accuracy=Trueonly for final validation with SL/TP — it's 60x slower. - Sweep with
run_sweep_litefor large grids. Userun_sweeponly when you need full Result objects.