2026-08-23 13:31:37 +00:00
|
|
|
|
"""Writing your own indicators — the ones the library does not ship.
|
2026-07-12 13:07:54 +00:00
|
|
|
|
|
2026-08-23 13:31:37 +00:00
|
|
|
|
Demonstrates:
|
|
|
|
|
|
- an indicator as a plain function returning an `Expr`
|
|
|
|
|
|
- `scan` for stateful indicators no rolling window can express
|
|
|
|
|
|
- `param(...)` to make a custom indicator sweepable
|
|
|
|
|
|
|
|
|
|
|
|
Data: shared store — real market data from `data/` (see examples/README.md)
|
|
|
|
|
|
|
|
|
|
|
|
Usage:
|
2026-07-12 13:07:54 +00:00
|
|
|
|
python examples/19_custom_indicators.py
|
|
|
|
|
|
|
|
|
|
|
|
────────────────────────────────────────────────────────────────────────────
|
2026-08-23 13:31:37 +00:00
|
|
|
|
THE MENTAL MODEL
|
2026-07-12 13:07:54 +00:00
|
|
|
|
────────────────────────────────────────────────────────────────────────────
|
2026-08-23 13:31:37 +00:00
|
|
|
|
An indicator here is NOTHING but a Python function returning an `Expr`. An
|
|
|
|
|
|
`Expr` is a *node in a computation graph*: writing `(high + low) / 2` touches
|
|
|
|
|
|
no data — it describes an operation. The whole graph is then compiled and
|
|
|
|
|
|
evaluated **in Rust**, in one vectorised pass. That is why your own indicators
|
|
|
|
|
|
run at the speed of the built-in ones: they end up in the same engine.
|
2026-07-12 13:07:54 +00:00
|
|
|
|
|
2026-08-23 13:31:37 +00:00
|
|
|
|
The whole `manifoldbt.indicators` library is written this way (`sma` ==
|
|
|
|
|
|
`source.rolling_mean(period)`). So "adding an indicator" means "writing a
|
|
|
|
|
|
function that composes `Expr`s". Three levels, from the common to the rare.
|
2026-07-12 13:07:54 +00:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
|
from time import perf_counter
|
|
|
|
|
|
|
|
|
|
|
|
import manifoldbt as mbt
|
2026-08-23 13:31:37 +00:00
|
|
|
|
# Base columns (already Exprs) plus a few helpers.
|
2026-07-12 13:07:54 +00:00
|
|
|
|
from manifoldbt.indicators import open, high, low, close, volume, sma, rsi, ema
|
2026-08-23 13:31:37 +00:00
|
|
|
|
# Low-level bricks: lit (constant), col (column by name), when (if/else),
|
|
|
|
|
|
# scan/s (recursive state), param (sweepable parameter).
|
2026-07-12 13:07:54 +00:00
|
|
|
|
from manifoldbt.expr import lit, col, when, scan, s, param
|
|
|
|
|
|
from manifoldbt.helpers import time_range, Slippage, Interval
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
2026-08-23 13:31:37 +00:00
|
|
|
|
# LEVEL 1 — COMPOSING PRIMITIVES (99% of cases)
|
2026-07-12 13:07:54 +00:00
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
2026-08-23 13:31:37 +00:00
|
|
|
|
# Combine columns + operators (+ - * /, > < >= & | ~) + Expr methods
|
2026-07-12 13:07:54 +00:00
|
|
|
|
# (rolling_mean/std/min/max/median, ewm_mean, zscore, pct_change, diff, lag,
|
2026-08-23 13:31:37 +00:00
|
|
|
|
# rsi, linreg_*, cross_above/below, cumsum, rank, ...). Every call returns
|
|
|
|
|
|
# an Expr, so everything chains.
|
2026-07-12 13:07:54 +00:00
|
|
|
|
|
|
|
|
|
|
def awesome_oscillator(fast=5, slow=34):
|
2026-08-23 13:31:37 +00:00
|
|
|
|
"""Awesome Oscillator (Bill Williams) — NOT in the library.
|
2026-07-12 13:07:54 +00:00
|
|
|
|
|
2026-08-23 13:31:37 +00:00
|
|
|
|
AO = SMA(median price, 5) − SMA(median price, 34), median = (H+L)/2
|
2026-07-12 13:07:54 +00:00
|
|
|
|
|
2026-08-23 13:31:37 +00:00
|
|
|
|
Momentum: positive means buying pressure, negative means selling.
|
2026-07-12 13:07:54 +00:00
|
|
|
|
"""
|
2026-08-23 13:31:37 +00:00
|
|
|
|
median_price = (high + low) / 2 # Expr: an operation on 2 columns
|
|
|
|
|
|
return sma(median_price, fast) - sma(median_price, slow) # the result Expr
|
2026-07-12 13:07:54 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def dist_to_ma_pct(period=20):
|
2026-08-23 13:31:37 +00:00
|
|
|
|
"""Distance from price to its moving average, in % — NOT in the library.
|
2026-07-12 13:07:54 +00:00
|
|
|
|
|
2026-08-23 13:31:37 +00:00
|
|
|
|
Negative means the price sits BELOW its average (oversold), which makes it
|
|
|
|
|
|
a natural building block for mean reversion. One line of composition.
|
2026-07-12 13:07:54 +00:00
|
|
|
|
"""
|
|
|
|
|
|
ma = sma(close, period)
|
|
|
|
|
|
return (close - ma) / ma * 100.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def intraday_range_pct():
|
2026-08-23 13:31:37 +00:00
|
|
|
|
"""Bar range as a % of the close — NOT in the library.
|
2026-07-12 13:07:54 +00:00
|
|
|
|
|
2026-08-23 13:31:37 +00:00
|
|
|
|
An instant volatility proxy. Shows that OHLC columns mix freely.
|
2026-07-12 13:07:54 +00:00
|
|
|
|
"""
|
|
|
|
|
|
return (high - low) / close * 100.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def rsi_zscore(period=14, lookback=365):
|
2026-08-23 13:31:37 +00:00
|
|
|
|
"""Standardised RSI: how extreme the RSI is against ITS OWN history.
|
2026-07-12 13:07:54 +00:00
|
|
|
|
|
2026-08-23 13:31:37 +00:00
|
|
|
|
Composes a built-in indicator (rsi) with rolling statistics — the same
|
|
|
|
|
|
pattern used in strategies/rsi_dynamic_alloc.py.
|
2026-07-12 13:07:54 +00:00
|
|
|
|
"""
|
|
|
|
|
|
r = rsi(close, period)
|
|
|
|
|
|
return (r - r.rolling_mean(lookback)) / r.rolling_std(lookback)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
2026-08-23 13:31:37 +00:00
|
|
|
|
# LEVEL 2 — `scan`: STATEFUL / RECURSIVE INDICATORS
|
2026-07-12 13:07:54 +00:00
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
2026-08-23 13:31:37 +00:00
|
|
|
|
# When today's value depends on YESTERDAY's (recursion) and no rolling window
|
|
|
|
|
|
# suffices, reach for `scan`. It runs as a small scalar VM, entirely in Rust
|
|
|
|
|
|
# (no Python callback per bar).
|
2026-07-12 13:07:54 +00:00
|
|
|
|
#
|
|
|
|
|
|
# scan(state=..., update=..., output=...)
|
2026-08-23 13:31:37 +00:00
|
|
|
|
# • state : state variables and their initial value (first row)
|
|
|
|
|
|
# • update : expressions evaluated on every bar, IN ORDER
|
|
|
|
|
|
# - s.prev("x") = value of "x" on the previous bar
|
|
|
|
|
|
# - s.var("k") = value computed earlier WITHIN THE SAME step
|
|
|
|
|
|
# - an update name matching a state name rewrites that state
|
|
|
|
|
|
# • output : which variable to emit as the result
|
2026-07-12 13:07:54 +00:00
|
|
|
|
#
|
2026-08-23 13:31:37 +00:00
|
|
|
|
# Proof that it is enough: the shipped Kalman and GARCH are written with scan
|
|
|
|
|
|
# ALONE (see manifoldbt/indicators.py).
|
2026-07-12 13:07:54 +00:00
|
|
|
|
|
|
|
|
|
|
def up_streak():
|
2026-08-23 13:31:37 +00:00
|
|
|
|
"""Count of consecutive UP bars — NOT in the library, and impossible with
|
|
|
|
|
|
a plain rolling window (it needs a counter that resets).
|
2026-07-12 13:07:54 +00:00
|
|
|
|
|
2026-08-23 13:31:37 +00:00
|
|
|
|
streak = previous streak + 1 if close > close(-1), else 0
|
2026-07-12 13:07:54 +00:00
|
|
|
|
"""
|
2026-08-23 13:31:37 +00:00
|
|
|
|
is_up = close > close.lag(1) # boolean Expr (1.0 / 0.0) per bar
|
2026-07-12 13:07:54 +00:00
|
|
|
|
return scan(
|
2026-08-23 13:31:37 +00:00
|
|
|
|
state={"n": lit(0.0)}, # counter seeded at 0
|
2026-07-12 13:07:54 +00:00
|
|
|
|
update={
|
|
|
|
|
|
# if is_up: prev(n) + 1 else: 0
|
|
|
|
|
|
"n": when(is_up, s.prev("n") + lit(1.0), lit(0.0)),
|
|
|
|
|
|
},
|
|
|
|
|
|
output="n",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ema_from_scratch(alpha=0.1):
|
2026-08-23 13:31:37 +00:00
|
|
|
|
"""A hand-rolled EMA via scan — purely to show the mechanism.
|
|
|
|
|
|
(EMA is built in: `ema(close, span)`. This one is pedagogical.)
|
2026-07-12 13:07:54 +00:00
|
|
|
|
|
2026-08-23 13:31:37 +00:00
|
|
|
|
ema = alpha * close + (1 - alpha) * previous ema
|
2026-07-12 13:07:54 +00:00
|
|
|
|
"""
|
|
|
|
|
|
return scan(
|
2026-08-23 13:31:37 +00:00
|
|
|
|
state={"ema": close}, # seeded with the first close
|
2026-07-12 13:07:54 +00:00
|
|
|
|
update={"ema": lit(alpha) * close + lit(1.0 - alpha) * s.prev("ema")},
|
|
|
|
|
|
output="ema",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
2026-08-23 13:31:37 +00:00
|
|
|
|
# LEVEL 3 — THE LIMITS (WORTH KNOWING)
|
2026-07-12 13:07:54 +00:00
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
2026-08-23 13:31:37 +00:00
|
|
|
|
# • NO Python callback per bar: `scan` runs in Rust, and you cannot inject a
|
|
|
|
|
|
# Python function called on every candle (it would be slow). As long as the
|
|
|
|
|
|
# logic expresses in Expr + when + scan, it works.
|
|
|
|
|
|
# • A GENUINELY new indicator, not expressible that way, needs a new `Expr`
|
|
|
|
|
|
# variant and its Rust kernel — the contributor path, not the user path.
|
|
|
|
|
|
# • External data (hashrate, funding, sentiment…): `mbt.register_exo(...)`,
|
|
|
|
|
|
# then `exo("name")` returns an Expr usable like any other column.
|
2026-07-12 13:07:54 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
2026-08-23 13:31:37 +00:00
|
|
|
|
# BONUS — MAKING YOUR INDICATOR SWEEPABLE
|
2026-07-12 13:07:54 +00:00
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
2026-08-23 13:31:37 +00:00
|
|
|
|
# Periods accept `param(...)` in place of an integer. The engine then
|
|
|
|
|
|
# recompiles once per combination and sweeps the grid in parallel, without
|
|
|
|
|
|
# changing a line of the indicator:
|
2026-07-12 13:07:54 +00:00
|
|
|
|
#
|
|
|
|
|
|
# ao = awesome_oscillator(fast=param("fast"), slow=param("slow"))
|
2026-08-23 13:31:37 +00:00
|
|
|
|
# # then, with the grid passed separately (the indicator is unchanged):
|
2026-07-12 13:07:54 +00:00
|
|
|
|
# # batch = mbt.run_sweep_lite(
|
|
|
|
|
|
# # strategy,
|
|
|
|
|
|
# # {"fast": [3, 5, 8], "slow": [21, 34, 55]},
|
|
|
|
|
|
# # config, store,
|
|
|
|
|
|
# # )
|
|
|
|
|
|
#
|
2026-08-23 13:31:37 +00:00
|
|
|
|
# (see examples/08_sweep_2d_heatmap.py for the full sweep.)
|
2026-07-12 13:07:54 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
2026-08-23 13:31:37 +00:00
|
|
|
|
# PUTTING A CUSTOM INDICATOR IN A STRATEGY AND BACKTESTING IT
|
2026-07-12 13:07:54 +00:00
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
2026-08-23 13:31:37 +00:00
|
|
|
|
# Using `dist_to_ma_pct` (mean reversion): long when the price sits well below
|
|
|
|
|
|
# its average, out when it has caught up.
|
2026-07-12 13:07:54 +00:00
|
|
|
|
|
2026-08-23 13:31:37 +00:00
|
|
|
|
dist = dist_to_ma_pct(period=48) # our custom indicator
|
|
|
|
|
|
streak = up_streak() # a second one, exposed too
|
2026-07-12 13:07:54 +00:00
|
|
|
|
|
2026-08-23 13:31:37 +00:00
|
|
|
|
signal = when(dist < -5.0, 1.0, # >5% below the MA -> buy the dip
|
|
|
|
|
|
when(dist > 0.0, 0.0)) # back at the MA -> exit, else hold
|
2026-07-12 13:07:54 +00:00
|
|
|
|
|
|
|
|
|
|
strategy = (
|
|
|
|
|
|
mbt.Strategy.create("custom_indicator_demo")
|
2026-08-23 13:31:37 +00:00
|
|
|
|
.signal("dist_to_ma_%", dist) # .signal() exposes it in the report
|
2026-07-12 13:07:54 +00:00
|
|
|
|
.signal("up_streak", streak)
|
|
|
|
|
|
.size(signal)
|
2026-08-23 13:31:37 +00:00
|
|
|
|
.describe("Mean reversion driven by a custom indicator (distance to the MA)")
|
2026-07-12 13:07:54 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# -- Config -------------------------------------------------------------------
|
|
|
|
|
|
start, end = time_range("2021-01-01", "2026-01-01")
|
|
|
|
|
|
|
|
|
|
|
|
config = mbt.BacktestConfig(
|
|
|
|
|
|
universe={"binance": ["BTC-USDT:perp"]},
|
|
|
|
|
|
time_range_start=start,
|
|
|
|
|
|
time_range_end=end,
|
|
|
|
|
|
bar_interval=Interval.hours(1),
|
|
|
|
|
|
initial_capital=10_000,
|
|
|
|
|
|
execution=mbt.ExecutionConfig(allow_short=False, max_position_pct=1.0),
|
2026-08-23 13:31:37 +00:00
|
|
|
|
fees=mbt.FeeConfig.zero(), # fee-free, for the example
|
2026-07-12 13:07:54 +00:00
|
|
|
|
slippage=Slippage.fixed_bps(2),
|
2026-08-23 13:31:37 +00:00
|
|
|
|
warmup_bars=60, # >= the longest window used
|
2026-07-12 13:07:54 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# -- Run ----------------------------------------------------------------------
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
root = os.path.join(os.path.dirname(__file__), "..")
|
|
|
|
|
|
data_root = os.path.abspath(os.path.join(root, "data"))
|
|
|
|
|
|
store = mbt.DataStore(
|
|
|
|
|
|
data_root=data_root,
|
|
|
|
|
|
metadata_db=os.path.abspath(os.path.join(root, "metadata", "metadata.sqlite")),
|
|
|
|
|
|
arrow_dir=os.path.join(data_root, "mega"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
t0 = perf_counter()
|
|
|
|
|
|
result = mbt.run(strategy, config, store)
|
|
|
|
|
|
print(result.summary())
|
|
|
|
|
|
print(f"\nElapsed: {perf_counter() - t0:.2f}s")
|