Files
manifoldbt/examples/19_custom_indicators.py
T
2026-08-23 13:31:37 +00:00

213 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Writing your own indicators — the ones the library does not ship.
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:
python examples/19_custom_indicators.py
────────────────────────────────────────────────────────────────────────────
THE MENTAL MODEL
────────────────────────────────────────────────────────────────────────────
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.
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.
"""
import os
from time import perf_counter
import manifoldbt as mbt
# Base columns (already Exprs) plus a few helpers.
from manifoldbt.indicators import open, high, low, close, volume, sma, rsi, ema
# Low-level bricks: lit (constant), col (column by name), when (if/else),
# scan/s (recursive state), param (sweepable parameter).
from manifoldbt.expr import lit, col, when, scan, s, param
from manifoldbt.helpers import time_range, Slippage, Interval
# ═══════════════════════════════════════════════════════════════════════════
# LEVEL 1 — COMPOSING PRIMITIVES (99% of cases)
# ═══════════════════════════════════════════════════════════════════════════
# Combine columns + operators (+ - * /, > < >= & | ~) + Expr methods
# (rolling_mean/std/min/max/median, ewm_mean, zscore, pct_change, diff, lag,
# rsi, linreg_*, cross_above/below, cumsum, rank, ...). Every call returns
# an Expr, so everything chains.
def awesome_oscillator(fast=5, slow=34):
"""Awesome Oscillator (Bill Williams) — NOT in the library.
AO = SMA(median price, 5) SMA(median price, 34), median = (H+L)/2
Momentum: positive means buying pressure, negative means selling.
"""
median_price = (high + low) / 2 # Expr: an operation on 2 columns
return sma(median_price, fast) - sma(median_price, slow) # the result Expr
def dist_to_ma_pct(period=20):
"""Distance from price to its moving average, in % — NOT in the library.
Negative means the price sits BELOW its average (oversold), which makes it
a natural building block for mean reversion. One line of composition.
"""
ma = sma(close, period)
return (close - ma) / ma * 100.0
def intraday_range_pct():
"""Bar range as a % of the close — NOT in the library.
An instant volatility proxy. Shows that OHLC columns mix freely.
"""
return (high - low) / close * 100.0
def rsi_zscore(period=14, lookback=365):
"""Standardised RSI: how extreme the RSI is against ITS OWN history.
Composes a built-in indicator (rsi) with rolling statistics — the same
pattern used in strategies/rsi_dynamic_alloc.py.
"""
r = rsi(close, period)
return (r - r.rolling_mean(lookback)) / r.rolling_std(lookback)
# ═══════════════════════════════════════════════════════════════════════════
# LEVEL 2 — `scan`: STATEFUL / RECURSIVE INDICATORS
# ═══════════════════════════════════════════════════════════════════════════
# 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).
#
# scan(state=..., update=..., output=...)
# • 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
#
# Proof that it is enough: the shipped Kalman and GARCH are written with scan
# ALONE (see manifoldbt/indicators.py).
def up_streak():
"""Count of consecutive UP bars — NOT in the library, and impossible with
a plain rolling window (it needs a counter that resets).
streak = previous streak + 1 if close > close(-1), else 0
"""
is_up = close > close.lag(1) # boolean Expr (1.0 / 0.0) per bar
return scan(
state={"n": lit(0.0)}, # counter seeded at 0
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):
"""A hand-rolled EMA via scan — purely to show the mechanism.
(EMA is built in: `ema(close, span)`. This one is pedagogical.)
ema = alpha * close + (1 - alpha) * previous ema
"""
return scan(
state={"ema": close}, # seeded with the first close
update={"ema": lit(alpha) * close + lit(1.0 - alpha) * s.prev("ema")},
output="ema",
)
# ═══════════════════════════════════════════════════════════════════════════
# LEVEL 3 — THE LIMITS (WORTH KNOWING)
# ═══════════════════════════════════════════════════════════════════════════
# • 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.
# ═══════════════════════════════════════════════════════════════════════════
# BONUS — MAKING YOUR INDICATOR SWEEPABLE
# ═══════════════════════════════════════════════════════════════════════════
# 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:
#
# ao = awesome_oscillator(fast=param("fast"), slow=param("slow"))
# # then, with the grid passed separately (the indicator is unchanged):
# # batch = mbt.run_sweep_lite(
# # strategy,
# # {"fast": [3, 5, 8], "slow": [21, 34, 55]},
# # config, store,
# # )
#
# (see examples/08_sweep_2d_heatmap.py for the full sweep.)
# ═══════════════════════════════════════════════════════════════════════════
# PUTTING A CUSTOM INDICATOR IN A STRATEGY AND BACKTESTING IT
# ═══════════════════════════════════════════════════════════════════════════
# Using `dist_to_ma_pct` (mean reversion): long when the price sits well below
# its average, out when it has caught up.
dist = dist_to_ma_pct(period=48) # our custom indicator
streak = up_streak() # a second one, exposed too
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
strategy = (
mbt.Strategy.create("custom_indicator_demo")
.signal("dist_to_ma_%", dist) # .signal() exposes it in the report
.signal("up_streak", streak)
.size(signal)
.describe("Mean reversion driven by a custom indicator (distance to the MA)")
)
# -- 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),
fees=mbt.FeeConfig.zero(), # fee-free, for the example
slippage=Slippage.fixed_bps(2),
warmup_bars=60, # >= the longest window used
)
# -- 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")