Files
manifoldbt/docs/strategy-authoring.md
T
2026-08-16 12:02:58 +00:00

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

  1. Quick Start
  2. Indicators
  3. Signals & Sizing
  4. Parameters & Sweeps
  5. Backtest Configuration
  6. Execution Model
  7. Fee & Slippage Models
  8. Orders (SL/TP/Trailing)
  9. Entry Orders
  10. Cross-Asset References
  11. Dataset Auto-Resolution
  12. Diagnostics
  13. Profiling
  14. Complete Examples
  15. 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 inside mbt.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

  1. Use signal_delay=1 (default). signal_delay=0 introduces look-ahead bias.
  2. Set warmup_bars to at least the longest indicator period.
  3. Use mbt.when() for sizing. Keep signal logic readable and composable.
  4. Run diagnostics (detect_lookahead, check_exposure_stability) on new strategies.
  5. Start with bar_interval=hours(12) or days(1) for fast iteration, then refine with smaller intervals.
  6. Use accuracy=True only for final validation with SL/TP — it's 60x slower.
  7. Sweep with run_sweep_lite for large grids. Use run_sweep only when you need full Result objects.