mirror of
https://github.com/manifoldbt/manifoldbt.git
synced 2026-08-24 22:48:05 +00:00
release: v0.19.0
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
"""Strategy template — copy this file and modify.
|
||||
|
||||
Demonstrates:
|
||||
- the minimal shape of a backtest: strategy, config, store, run
|
||||
|
||||
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||
|
||||
Usage:
|
||||
python examples/00_template.py
|
||||
"""
|
||||
|
||||
@@ -8,6 +8,8 @@ Demonstrates:
|
||||
- Diagnostics (lookahead, exposure stability, risk)
|
||||
- result.summary() rich output
|
||||
|
||||
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||
|
||||
Usage:
|
||||
python examples/01_trend_following.py
|
||||
"""
|
||||
|
||||
@@ -5,6 +5,8 @@ Demonstrates:
|
||||
- Long and short positions
|
||||
- Continuous sizing (signal * 0.25)
|
||||
|
||||
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||
|
||||
Usage:
|
||||
python examples/02_mean_reversion.py
|
||||
"""
|
||||
|
||||
@@ -5,6 +5,8 @@ Demonstrates:
|
||||
- Momentum via smoothed ROC on 12h bars
|
||||
- Volatility-adjusted sizing
|
||||
|
||||
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||
|
||||
Usage:
|
||||
python examples/03_multi_asset_momentum.py
|
||||
"""
|
||||
|
||||
@@ -11,6 +11,8 @@ The idea: fit a rolling OLS regression on price. When the slope is steep
|
||||
and the R² is high (price moves in a straight line), we have a strong trend.
|
||||
Size proportionally to slope strength * R² confidence.
|
||||
|
||||
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||
|
||||
Usage:
|
||||
python examples/04_linear_regression.py
|
||||
"""
|
||||
|
||||
@@ -5,6 +5,8 @@ Demonstrates:
|
||||
- Kalman filter for spread equilibrium
|
||||
- Z-score mean-reversion sizing
|
||||
|
||||
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||
|
||||
Usage:
|
||||
python examples/05_stat_arb.py
|
||||
"""
|
||||
|
||||
@@ -7,6 +7,13 @@ Strategy:
|
||||
|
||||
Demonstrates every plotting function available in manifoldbt.
|
||||
|
||||
Demonstrates:
|
||||
- every plotting function in manifoldbt, on one result
|
||||
- tearsheet, equity, drawdown, monthly and annual returns
|
||||
- rolling Sharpe and volatility, returns histogram
|
||||
|
||||
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||
|
||||
Usage:
|
||||
python examples/06_full_visualization.py
|
||||
"""
|
||||
|
||||
@@ -5,6 +5,8 @@ Demonstrates:
|
||||
- param() for sweep-able parameters
|
||||
- Walk-forward fold results inspection
|
||||
|
||||
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||
|
||||
Usage:
|
||||
python examples/07_walk_forward.py
|
||||
"""
|
||||
|
||||
@@ -5,6 +5,8 @@ Demonstrates:
|
||||
- run_sweep() for Cartesian grid search
|
||||
- Heatmap visualization with mbt.plot.heatmap_2d()
|
||||
|
||||
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||
|
||||
Usage:
|
||||
python examples/08_sweep_2d_heatmap.py
|
||||
"""
|
||||
|
||||
@@ -5,6 +5,8 @@ Demonstrates:
|
||||
- run_sweep_lite() for fast parameter grid search
|
||||
- 3D surface visualization with mbt.plot.surface_3d()
|
||||
|
||||
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||
|
||||
Usage:
|
||||
python examples/09_surface_3d.py
|
||||
"""
|
||||
|
||||
@@ -5,6 +5,8 @@ Demonstrates:
|
||||
- Monte Carlo fan chart visualization
|
||||
- Risk metrics from simulated distributions
|
||||
|
||||
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||
|
||||
Usage:
|
||||
python examples/10_monte_carlo.py
|
||||
"""
|
||||
|
||||
@@ -7,6 +7,8 @@ Demonstrates:
|
||||
- Periodic rebalancing
|
||||
- Per-strategy breakdown
|
||||
|
||||
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||
|
||||
Usage:
|
||||
python examples/11_portfolio.py
|
||||
"""
|
||||
|
||||
@@ -5,6 +5,8 @@ Demonstrates:
|
||||
- check_exposure_stability(): verify positions are consistent across time windows
|
||||
- risk_check(): post-run risk metrics validation
|
||||
|
||||
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||
|
||||
Usage:
|
||||
python examples/12_diagnostics.py
|
||||
"""
|
||||
|
||||
@@ -7,6 +7,9 @@ Demonstrates:
|
||||
- CUDA GPU acceleration (device="cuda")
|
||||
- All expressions compile to native Rust — full Rayon / CUDA parallelism
|
||||
|
||||
Data: none — this file simulates price paths, it does not backtest on any
|
||||
series. The paths are its output, not its input.
|
||||
|
||||
Usage:
|
||||
python examples/13_stochastic_simulation.py
|
||||
"""
|
||||
|
||||
@@ -10,6 +10,8 @@ Logic:
|
||||
- 1h entry: RSI(14) < 35 during bullish regime → buy the dip
|
||||
- Size: 50% of initial capital when conditions met, else flat
|
||||
|
||||
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||
|
||||
Usage:
|
||||
python examples/14_multi_timeframe.py
|
||||
"""
|
||||
@@ -23,9 +25,12 @@ from manifoldbt.helpers import time_range, Slippage, Interval
|
||||
h12 = mbt.tf("12h") # references columns like "12h.close"
|
||||
|
||||
# -- Indicators ---------------------------------------------------------------
|
||||
# Trend filter on 12-hour bars (forward-filled onto 1h grid)
|
||||
trend_fast = ema(h12.close, 20)
|
||||
trend_slow = ema(h12.close, 50)
|
||||
# Trend filter on 12-hour bars. `apply()` evaluates the EMA on the 12h grid, so
|
||||
# 20 and 50 count 12-HOUR candles. Written `ema(h12.close, 20)` they would count
|
||||
# 20 rows of the 1h simulation grid over a step-held column -- under two 12h
|
||||
# candles, not twenty. See `bt.tf`.
|
||||
trend_fast = h12.apply(ema(close, 20))
|
||||
trend_slow = h12.apply(ema(close, 50))
|
||||
bullish = trend_fast > trend_slow
|
||||
|
||||
# Entry signal on 1-hour bars (native resolution)
|
||||
@@ -59,7 +64,7 @@ config = mbt.BacktestConfig(
|
||||
),
|
||||
fees=mbt.FeeConfig.binance_perps(),
|
||||
slippage=Slippage.fixed_bps(2),
|
||||
warmup_bars=50,
|
||||
warmup_bars=50 * 12, # 50 twelve-hour candles, counted in 1h simulation bars
|
||||
extra_timeframes={
|
||||
"12h": Interval.hours(12),
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Example 15: Cross-Exchange — Signal Binance, Execution dYdX.
|
||||
"""Cross-exchange — signals on Binance, execution on dYdX.
|
||||
|
||||
Simple RSI mean-reversion:
|
||||
- RSI computed on Binance BTC perp data
|
||||
@@ -7,8 +7,18 @@ Simple RSI mean-reversion:
|
||||
- Per-venue fees: each symbol is charged its own exchange's fee schedule
|
||||
(see FeeConfig.multi_venue below)
|
||||
|
||||
Demonstrates:
|
||||
- signals computed on one venue, orders filled on another
|
||||
- a dict universe spanning two providers, with no special config
|
||||
- per-venue fees: each symbol charged its own exchange's schedule
|
||||
|
||||
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||
|
||||
Prerequisite:
|
||||
Binance perp data (bars_1m/201.arrow) + dYdX data (dydx/1h/BTC-USD.arrow)
|
||||
|
||||
Usage:
|
||||
python examples/15_cross_exchange.py
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Example 16: BTC-Hashrate Spread — Exogenous Data Strategy.
|
||||
"""Exogenous data — a BTC/hashrate spread as a signal.
|
||||
|
||||
Thesis: Bitcoin hashrate is a proxy for miner commitment and network
|
||||
security. When BTC price drops but hashrate holds (or rises), miners
|
||||
@@ -10,6 +10,15 @@ The strategy normalizes both BTC price and hashrate via EMA ratios
|
||||
A rolling z-score of the spread generates the signal: negative z means
|
||||
price is cheap relative to hashrate (long), positive means expensive.
|
||||
|
||||
Demonstrates:
|
||||
- an exogenous series ASOF-joined onto the bar grid
|
||||
- a spread between two EMA-normalised series as a signal
|
||||
- a rolling z-score turning that spread into a position
|
||||
|
||||
Data: shared store — real BTC bars from `data/`, plus a hashrate series
|
||||
(fetched, or generated by the sample generator below when absent).
|
||||
See examples/README.md.
|
||||
|
||||
Exogenous data flow:
|
||||
1. Fetch hashrate CSV (or use sample generator below)
|
||||
2. Register via mbt.register_exo("hashrate", df)
|
||||
@@ -18,6 +27,9 @@ Exogenous data flow:
|
||||
|
||||
Prerequisite:
|
||||
Binance BTC perp data + hashrate exo registered in data/mega/exo/
|
||||
|
||||
Usage:
|
||||
python examples/16_hashrate_exogene.py
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Example 17: Per-Venue Fees — charge each symbol its own fee schedule.
|
||||
"""Per-venue fees — charge each symbol its own fee schedule.
|
||||
|
||||
Real desks route different assets to different exchanges (or liquidity tiers),
|
||||
each with its own maker/taker fees, funding column and borrow rate. ``FeeConfig``
|
||||
@@ -9,6 +9,13 @@ Here a 4-asset momentum portfolio executes the majors (BTC, ETH) on a cheap
|
||||
venue and the alts (XRP, DOT) on a more expensive one. Single-provider universe,
|
||||
so it runs without Pro.
|
||||
|
||||
Demonstrates:
|
||||
- FeeConfig.per_venue: named fee schedules in one book
|
||||
- symbol_venue: which symbol trades where
|
||||
- the cost gap between routing majors and alts differently
|
||||
|
||||
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||
|
||||
Usage:
|
||||
python examples/17_per_venue_fees.py
|
||||
"""
|
||||
|
||||
@@ -8,6 +8,8 @@ Demonstrates:
|
||||
The standard format is a header row + `timestamp,open,high,low,close,volume`
|
||||
where timestamp is Unix milliseconds. MT4/MT5 exports are auto-detected.
|
||||
|
||||
Data: synthetic — a sample generated by this file, reproducible
|
||||
|
||||
Usage:
|
||||
python examples/18_csv_import.py
|
||||
"""
|
||||
|
||||
@@ -1,109 +1,115 @@
|
||||
"""Créer ses propres indicateurs (indicateurs absents de la base).
|
||||
"""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
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────
|
||||
LE MODÈLE MENTAL
|
||||
THE MENTAL MODEL
|
||||
────────────────────────────────────────────────────────────────────────────
|
||||
Un indicateur, ici, n'est RIEN d'autre qu'une fonction Python qui renvoie un
|
||||
`Expr`. Un `Expr` est un *nœud dans un graphe de calcul* : quand vous écrivez
|
||||
`(high + low) / 2`, aucune donnée n'est touchée — vous décrivez une opération.
|
||||
Le graphe complet est ensuite compilé et évalué **en Rust**, en une passe,
|
||||
vectorisé. C'est pour ça que vos indicateurs maison tournent à la vitesse des
|
||||
indicateurs natifs : ils finissent dans le même moteur.
|
||||
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.
|
||||
|
||||
Toute la lib `manifoldbt.indicators` est écrite comme ça (`sma` ==
|
||||
`source.rolling_mean(period)`). Donc « ajouter un indicateur » = « écrire une
|
||||
fonction qui compose des `Expr` ». Trois niveaux, du plus simple au plus rare.
|
||||
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
|
||||
# Colonnes de base (ce sont déjà des Expr) + quelques helpers.
|
||||
# Base columns (already Exprs) plus a few helpers.
|
||||
from manifoldbt.indicators import open, high, low, close, volume, sma, rsi, ema
|
||||
# Briques bas niveau : lit (constante), col (colonne par nom), when (if/else),
|
||||
# scan/s (état récursif), param (paramètre balayable).
|
||||
# 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
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# NIVEAU 1 — COMPOSER LES PRIMITIVES (99 % des cas)
|
||||
# LEVEL 1 — COMPOSING PRIMITIVES (99% of cases)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# On combine colonnes + opérateurs (+ - * /, > < >= & | ~) + méthodes d'Expr
|
||||
# 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, ...). Chaque appel renvoie
|
||||
# un Expr, donc tout se chaîne.
|
||||
# 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) — ABSENT de la base.
|
||||
"""Awesome Oscillator (Bill Williams) — NOT in the library.
|
||||
|
||||
AO = SMA(prix médian, 5) − SMA(prix médian, 34), prix médian = (H+L)/2
|
||||
AO = SMA(median price, 5) − SMA(median price, 34), median = (H+L)/2
|
||||
|
||||
Momentum : positif = pression acheteuse, négatif = vendeuse.
|
||||
Momentum: positive means buying pressure, negative means selling.
|
||||
"""
|
||||
median_price = (high + low) / 2 # Expr : opération sur 2 colonnes
|
||||
return sma(median_price, fast) - sma(median_price, slow) # Expr résultat
|
||||
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):
|
||||
"""Écart en % du prix à sa moyenne mobile — ABSENT de la base.
|
||||
"""Distance from price to its moving average, in % — NOT in the library.
|
||||
|
||||
Négatif = le prix est SOUS sa moyenne (survendu) → brique idéale pour du
|
||||
retour à la moyenne. Une seule ligne de composition.
|
||||
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():
|
||||
"""Amplitude de la bougie en % du close — ABSENT de la base.
|
||||
"""Bar range as a % of the close — NOT in the library.
|
||||
|
||||
Un proxy de volatilité instantané. Montre qu'on mélange librement les
|
||||
colonnes OHLC.
|
||||
An instant volatility proxy. Shows that OHLC columns mix freely.
|
||||
"""
|
||||
return (high - low) / close * 100.0
|
||||
|
||||
|
||||
def rsi_zscore(period=14, lookback=365):
|
||||
"""RSI standardisé : à quel point le RSI est extrême vs SA PROPRE histoire.
|
||||
"""Standardised RSI: how extreme the RSI is against ITS OWN history.
|
||||
|
||||
Compose un indicateur natif (rsi) avec des stats roulantes. C'est
|
||||
exactement le motif utilisé dans strategies/rsi_dynamic_alloc.py.
|
||||
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)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# NIVEAU 2 — `scan` : INDICATEURS À ÉTAT / RÉCURSIFS
|
||||
# LEVEL 2 — `scan`: STATEFUL / RECURSIVE INDICATORS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Quand la valeur d'aujourd'hui dépend de celle d'HIER (récursion) et qu'aucun
|
||||
# rolling ne suffit, on utilise `scan`. Il tourne comme une petite VM scalaire,
|
||||
# entièrement en Rust (pas de callback Python par barre).
|
||||
# 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 : variables d'état + leur valeur initiale (1re ligne)
|
||||
# • update : expressions évaluées à chaque barre, DANS L'ORDRE
|
||||
# - s.prev("x") = valeur de "x" à la barre précédente
|
||||
# - s.var("k") = valeur calculée plus tôt DANS LE MÊME pas
|
||||
# - si un nom d'update == un nom d'état, on réécrit cet état
|
||||
# • output : quelle variable émettre comme résultat
|
||||
# • 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
|
||||
#
|
||||
# Preuve que c'est puissant : le Kalman et le GARCH livrés sont écrits
|
||||
# UNIQUEMENT avec scan (voir manifoldbt/indicators.py).
|
||||
# Proof that it is enough: the shipped Kalman and GARCH are written with scan
|
||||
# ALONE (see manifoldbt/indicators.py).
|
||||
|
||||
def up_streak():
|
||||
"""Nombre de bougies HAUSSIÈRES consécutives — ABSENT de la base, et
|
||||
impossible avec un simple rolling (il faut un compteur qui se réinitialise).
|
||||
"""Count of consecutive UP bars — NOT in the library, and impossible with
|
||||
a plain rolling window (it needs a counter that resets).
|
||||
|
||||
streak = streak_précédent + 1 si close > close(-1), sinon 0
|
||||
streak = previous streak + 1 if close > close(-1), else 0
|
||||
"""
|
||||
is_up = close > close.lag(1) # Expr booléen (1.0 / 0.0) par barre
|
||||
is_up = close > close.lag(1) # boolean Expr (1.0 / 0.0) per bar
|
||||
return scan(
|
||||
state={"n": lit(0.0)}, # compteur initialisé à 0
|
||||
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)),
|
||||
@@ -113,66 +119,66 @@ def up_streak():
|
||||
|
||||
|
||||
def ema_from_scratch(alpha=0.1):
|
||||
"""EMA « à la main » via scan — juste pour illustrer le mécanisme.
|
||||
(L'EMA existe en natif : `ema(close, span)`. Ici c'est pédagogique.)
|
||||
"""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) * ema_précédent
|
||||
ema = alpha * close + (1 - alpha) * previous ema
|
||||
"""
|
||||
return scan(
|
||||
state={"ema": close}, # graine = 1er close
|
||||
state={"ema": close}, # seeded with the first close
|
||||
update={"ema": lit(alpha) * close + lit(1.0 - alpha) * s.prev("ema")},
|
||||
output="ema",
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# NIVEAU 3 — LES LIMITES (À CONNAÎTRE)
|
||||
# LEVEL 3 — THE LIMITS (WORTH KNOWING)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# • PAS de callback Python par barre : `scan` s'exécute en Rust, on ne peut pas
|
||||
# y injecter une fonction Python appelée sur chaque bougie (ce serait lent).
|
||||
# Tant que la logique s'exprime avec Expr + when + scan, ça passe.
|
||||
# • Un indicateur VRAIMENT nouveau, non exprimable ainsi, demande d'ajouter un
|
||||
# variant `Expr` + son kernel côté Rust — chemin contributeur, pas utilisateur.
|
||||
# • Données externes (hashrate, funding, sentiment…) : `mbt.register_exo(...)`
|
||||
# puis `exo("nom")` renvoie un Expr utilisable comme n'importe quelle colonne.
|
||||
# • 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 — RENDRE SON INDICATEUR BALAYABLE (sweep)
|
||||
# BONUS — MAKING YOUR INDICATOR SWEEPABLE
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Les périodes acceptent `param(...)` à la place d'un entier. Le moteur
|
||||
# recompile alors une fois par combinaison et balaie la grille en parallèle,
|
||||
# sans changer une ligne de l'indicateur :
|
||||
# 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"))
|
||||
# # puis, avec la grille passée séparément (l'indicateur ne change pas) :
|
||||
# # 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,
|
||||
# # )
|
||||
#
|
||||
# (voir examples/08_sweep_2d_heatmap.py pour le sweep complet.)
|
||||
# (see examples/08_sweep_2d_heatmap.py for the full sweep.)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# METTRE UN INDICATEUR MAISON DANS UNE STRATÉGIE + BACKTEST
|
||||
# PUTTING A CUSTOM INDICATOR IN A STRATEGY AND BACKTESTING IT
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# On utilise `dist_to_ma_pct` (retour à la moyenne) : long quand le prix est
|
||||
# nettement sous sa moyenne, on sort quand il l'a rejointe.
|
||||
# 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) # notre indicateur maison
|
||||
streak = up_streak() # et un second, pour l'exposer aussi
|
||||
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 % sous la MM → achat du creux
|
||||
when(dist > 0.0, 0.0)) # revenu à la MM → sortie, sinon hold
|
||||
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() = exposer pour le rapport
|
||||
.signal("dist_to_ma_%", dist) # .signal() exposes it in the report
|
||||
.signal("up_streak", streak)
|
||||
.size(signal)
|
||||
.describe("Retour à la moyenne piloté par un indicateur maison (écart à la MM)")
|
||||
.describe("Mean reversion driven by a custom indicator (distance to the MA)")
|
||||
)
|
||||
|
||||
# -- Config -------------------------------------------------------------------
|
||||
@@ -185,9 +191,9 @@ config = mbt.BacktestConfig(
|
||||
bar_interval=Interval.hours(1),
|
||||
initial_capital=10_000,
|
||||
execution=mbt.ExecutionConfig(allow_short=False, max_position_pct=1.0),
|
||||
fees=mbt.FeeConfig.zero(), # sans frais, pour l'exemple
|
||||
fees=mbt.FeeConfig.zero(), # fee-free, for the example
|
||||
slippage=Slippage.fixed_bps(2),
|
||||
warmup_bars=60, # >= la plus longue fenêtre utilisée
|
||||
warmup_bars=60, # >= the longest window used
|
||||
)
|
||||
|
||||
# -- Run ----------------------------------------------------------------------
|
||||
|
||||
@@ -8,6 +8,13 @@ the same signal four ways so the difference is visible in one place:
|
||||
stop wait for a breakout, fill through the level (taker + gap)
|
||||
limit on a signal rest on a level the DSL computes (here: 1 ATR below close)
|
||||
|
||||
Demonstrates:
|
||||
- market, limit, stop and signal-priced entries, side by side
|
||||
- passive fills (maker, no slippage) against aggressive ones
|
||||
- an entry resting on a level the DSL computes
|
||||
|
||||
Data: shared store — real market data from `data/` (see examples/README.md)
|
||||
|
||||
Usage:
|
||||
python examples/20_entry_orders.py
|
||||
"""
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
"""Filling at a computed level — ExecutionPrice.custom(<signal name>).
|
||||
|
||||
A mean-reversion band strategy on native 1-minute bars: short at the touch of
|
||||
an upper band around an hourly SMA, cover at the lower band. The engine always
|
||||
knew how to COMPUTE the band; this example shows the fill landing ON it.
|
||||
an upper band, cover at the lower one. The engine always knew how to COMPUTE
|
||||
the band; this example shows the fill landing ON it.
|
||||
|
||||
The touch bar itself proves the level traded: it opens below the band and its
|
||||
high crosses it, so the band price sits inside [open, high]. Yet with
|
||||
``AtClose`` the only reachable fill is the bar's close — on a mean-reverting
|
||||
touch, systematically on the wrong side of the level. The same run is done
|
||||
both ways so the difference is visible in one place.
|
||||
**This is a feature demo, not a claim about markets.** The data is synthetic and
|
||||
detrended by construction, so mean reversion cannot lose on it whatever the
|
||||
parameters — the returns printed below describe the fixture, nothing else. The
|
||||
same run is done both ways only so you can see that the setting takes effect.
|
||||
|
||||
Which of the two fills is the better one depends entirely on what the market
|
||||
does after the touch, and this fixture reverts by construction. Do not read a
|
||||
rule into the sign of the gap.
|
||||
|
||||
The touch bar proves the level traded: it opens below the band and its high
|
||||
crosses it, so the band price sits inside [open, high]. On this data all 248
|
||||
intended levels land inside their bar. A level that did not would be clamped
|
||||
back into [low, high] and reported in ``result.warnings`` — the band comes from
|
||||
the previous closed hour, so it can sit outside a bar that never reached it.
|
||||
|
||||
Look-ahead was tested for and ruled out: the fill price does not move when the
|
||||
triggering bar's high is inflated by 3%.
|
||||
|
||||
Self-contained: generates its own synthetic data in a temp store.
|
||||
|
||||
Demonstrates:
|
||||
- ExecutionPrice.custom(<signal name>): the fill price as a strategy signal
|
||||
- tf("1h").apply(...): an indicator whose period counts hourly candles
|
||||
- a level computed from a higher timeframe, known before the bar opens
|
||||
|
||||
Data: synthetic (seed 7) — generated by this file, reproducible
|
||||
|
||||
Usage:
|
||||
python examples/21_fill_at_computed_level.py
|
||||
"""
|
||||
@@ -26,11 +45,21 @@ import manifoldbt as mbt
|
||||
from manifoldbt.indicators import close, high, low, open as open_px, sma
|
||||
from manifoldbt.helpers import ExecutionPrice, Interval, Slippage
|
||||
|
||||
# -- Synthetic 1m data: a mean-reverting walk ---------------------------------
|
||||
# -- Synthetic 1m data: a walk with its drift REMOVED -------------------------
|
||||
# `- np.linspace(0, level[-1], N)` subtracts the entire trend, forcing the
|
||||
# series to end exactly where it started: the raw walk loses 25.6%, this one
|
||||
# returns -0.0%. Mean reversion is therefore GUARANTEED here, by construction.
|
||||
#
|
||||
# That is deliberate, and it is why the absolute returns printed below mean
|
||||
# nothing. Measured over nine parameter sets (bands 0.002 to 0.008, periods 4h
|
||||
# to 20h), the custom fill returns +2.2% to +9.1% and AtClose -25.5% to +4.2%:
|
||||
# neither range is a property of the strategy, and AtClose even turns positive
|
||||
# on the widest band. What holds across all nine is only the ORDER — the custom
|
||||
# fill is ahead every time — and that gap is the whole subject.
|
||||
N = 30 * 1440 # 30 days of 1-minute bars
|
||||
rng = np.random.default_rng(7)
|
||||
steps = rng.normal(0.0, 0.0010, N)
|
||||
level = np.cumsum(steps) * 0.85 # pull the walk back toward its mean
|
||||
level = np.cumsum(steps) * 0.85
|
||||
px = 100.0 * np.exp(level - np.linspace(0, level[-1], N))
|
||||
o, c = px, np.roll(px, -1)
|
||||
c[-1] = px[-1]
|
||||
@@ -42,12 +71,16 @@ frame = pd.DataFrame(
|
||||
"close": c, "volume": rng.uniform(1_000, 5_000, N)}
|
||||
)
|
||||
|
||||
# -- Bands around an hourly SMA, evaluated on 1m native bars ------------------
|
||||
# -- Bands around an 8-hour mean, on 1m native bars ---------------------------
|
||||
# `apply()` evaluates the SMA on the hourly grid, so its period counts hourly
|
||||
# candles. Written `sma(h1.close, 8)` the period would count 8 SIMULATION bars
|
||||
# over a step-held column, which is 8 minutes, not 8 hours. See `bt.tf`.
|
||||
DEV_UP, DEV_DN = 0.004, 0.003
|
||||
|
||||
h1 = mbt.tf("1h") # hourly columns, as of the last closed hour
|
||||
band_up = sma(h1.close, 8) * (1 + DEV_UP)
|
||||
band_dn = sma(h1.close, 8) * (1 - DEV_DN)
|
||||
h1 = mbt.tf("1h")
|
||||
hourly_mean = h1.apply(sma(close, 8)) # mean of the last 8 hourly closes
|
||||
band_up = hourly_mean * (1 + DEV_UP)
|
||||
band_dn = hourly_mean * (1 - DEV_DN)
|
||||
|
||||
touch_up = high >= band_up # entry: short at the touch of the upper band
|
||||
touch_dn = low <= band_dn # exit: cover at the touch of the lower band
|
||||
@@ -98,17 +131,34 @@ if __name__ == "__main__":
|
||||
)
|
||||
return mbt.run(strategy, config, store)
|
||||
|
||||
print(f"{'execution price':<22} {'trades':>7} {'return':>9} first entry fills")
|
||||
print("Synthetic detrended data: the LEVELS below are an artifact of the")
|
||||
print("fixture, not a result. Only the gap between the two rows is real.\n")
|
||||
print(f"{'execution price':<22} {'trades':>7} {'return*':>9} first entry fills")
|
||||
print("-" * 78)
|
||||
|
||||
returns = {}
|
||||
n_trades = 0
|
||||
for label, price in (("AtClose", "AtClose"),
|
||||
("custom('exec_level')", ExecutionPrice.custom("exec_level"))):
|
||||
result = run(price)
|
||||
tr = result.trades_df()
|
||||
entries = tr[tr["fill_price"] > 0].head(3)["fill_price"].round(4).tolist()
|
||||
print(f"{label:<22} {len(tr):>7} {result.metrics['total_return']:>8.2%} {entries}")
|
||||
returns[label] = result.metrics["total_return"]
|
||||
n_trades = len(tr)
|
||||
print(f"{label:<22} {len(tr):>7} {returns[label]:>8.2%} {entries}")
|
||||
|
||||
gap = returns["custom('exec_level')"] - returns["AtClose"]
|
||||
print(f"\n GAP: {gap:.1%} on identical signals and identical bars.")
|
||||
print(
|
||||
"\nSame signals, same bars: only WHERE the order fills changed. The"
|
||||
"\ncustom fills land on the band level (inside the touch bar's range),"
|
||||
"\nnot on its close. A fill outside [low, high] would be warned about."
|
||||
"\n* Both figures describe the fixture, not a market: the series is"
|
||||
"\n detrended, so reversion wins on it by construction. What the two"
|
||||
"\n rows show is that the setting takes effect -- the custom fills land"
|
||||
"\n on the band, computed from the previous closed hour, where AtClose"
|
||||
"\n can only reach the bar's close. Which of the two is the better"
|
||||
"\n price depends on what the market does next, and this series was"
|
||||
"\n built to revert."
|
||||
"\n"
|
||||
f"\n All {n_trades} intended levels land inside their own bar here. One"
|
||||
f"\n that did not would be clamped into [low, high] and reported in"
|
||||
f"\n result.warnings."
|
||||
)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Yahoo Finance -- stocks, ETFs, indices, FX and futures, free on all tiers.
|
||||
|
||||
Demonstrates:
|
||||
- mbt.ingest(provider="yahoo") -- no API key, no license required
|
||||
- Backtesting daily equity bars, exactly like a crypto connector
|
||||
- Dividend-adjusted prices (same convention as yfinance's auto_adjust=True)
|
||||
|
||||
Yahoo imposes its own history limits: 1m bars go back 30 days, 1h about two
|
||||
years, daily bars back to the listing date. Tickers follow Yahoo's own
|
||||
notation: AAPL, SPY, ^GSPC (index), EURUSD=X (FX), ES=F (future),
|
||||
BTC-USD (crypto), AIR.PA (Euronext).
|
||||
|
||||
Pass `dataset="raw"` to keep unadjusted quotes.
|
||||
|
||||
Data: self-contained (network) — ingested on each run from a free connector
|
||||
|
||||
Usage:
|
||||
python examples/22_yahoo_equities.py
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import manifoldbt as mbt
|
||||
from manifoldbt.indicators import close, ema
|
||||
from manifoldbt.helpers import time_range, Interval
|
||||
|
||||
# -- 1. Pull daily bars from Yahoo (free, all tiers) --------------------------
|
||||
tmp = tempfile.mkdtemp()
|
||||
store = mbt.ingest(
|
||||
provider="yahoo",
|
||||
symbol="AAPL",
|
||||
symbol_id=1,
|
||||
start="2020-01-01T00:00:00Z",
|
||||
end="2024-01-01T00:00:00Z",
|
||||
interval="1d",
|
||||
asset_class="equity",
|
||||
data_root=os.path.join(tmp, "data"),
|
||||
metadata_db=os.path.join(tmp, "meta.sqlite"),
|
||||
)
|
||||
print("Ingested:", store.list_symbols())
|
||||
|
||||
# -- 2. Backtest on it like any other data ------------------------------------
|
||||
strategy = (
|
||||
mbt.Strategy.create("ema_cross")
|
||||
.signal("fast", ema(close, 20))
|
||||
.signal("slow", ema(close, 50))
|
||||
.size(mbt.when(ema(close, 20) > ema(close, 50), 1.0, 0.0))
|
||||
.describe("EMA(20/50) crossover on daily AAPL bars from Yahoo Finance")
|
||||
)
|
||||
|
||||
start, end = time_range("2020-01-01", "2024-01-01")
|
||||
config = mbt.BacktestConfig(
|
||||
universe=[1],
|
||||
time_range_start=start,
|
||||
time_range_end=end,
|
||||
bar_interval=Interval.days(1),
|
||||
initial_capital=10_000,
|
||||
warmup_bars=60,
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = mbt.run(strategy, config, store)
|
||||
print(result.summary())
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Crypto options -- Deribit contracts that actually expire.
|
||||
|
||||
Demonstrates:
|
||||
- mbt.ingest(provider="deribit", ...) -- no API key, expired contracts included
|
||||
- Contract terms (strike, expiry, side, settlement) recorded alongside the bars
|
||||
- Cash settlement at expiration, at intrinsic value
|
||||
- config.option_underlyings -- which price series settles the contract
|
||||
- Per-leg sizing with col("symbol_id"), for multi-leg structures
|
||||
|
||||
Why Deribit and not Binance: Deribit serves the history of contracts that have
|
||||
already expired, which is the only data an option backtest can run on. Binance's
|
||||
options API answers HTTP 400 for anything past its expiration date.
|
||||
|
||||
Three things worth knowing before reading the numbers:
|
||||
|
||||
- **Everything is in BTC.** A Deribit BTC option is quoted, margined and
|
||||
settled in BTC, so `initial_capital` below is 10 BTC, not 10 dollars. The
|
||||
payoff of a call is `max(0, S - K) / S` BTC per contract.
|
||||
|
||||
- **You choose the settlement reference.** Deribit settles against its own
|
||||
`BTC_USD` index, whose ticker matches no series you can ingest.
|
||||
`BTC-PERPETUAL` stands in for it here; the basis between the two is small
|
||||
but real, and it is not modelled.
|
||||
|
||||
- **Positions are counted in units of the underlying.** On Deribit a contract
|
||||
is one unit, so the two are the same thing. On a 100-multiplier listed
|
||||
option, holding one contract means a position of 100.
|
||||
|
||||
Data: self-contained (network) — ingested on each run from a free connector
|
||||
|
||||
Usage:
|
||||
python examples/23_deribit_options.py
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import manifoldbt as mbt
|
||||
from manifoldbt.indicators import col
|
||||
from manifoldbt.helpers import time_range, Interval
|
||||
|
||||
# Two contracts that expired on 2025-06-27, and the series that settles them.
|
||||
UNDERLYING, UNDERLYING_ID = "BTC-PERPETUAL", 1
|
||||
CALL_100K, CALL_ID = "BTC-27JUN25-100000-C", 2
|
||||
PUT_90K, PUT_ID = "BTC-27JUN25-90000-P", 3
|
||||
START, END = "2025-05-01T00:00:00Z", "2025-07-01T00:00:00Z"
|
||||
|
||||
tmp = tempfile.mkdtemp()
|
||||
common = dict(
|
||||
start=START,
|
||||
end=END,
|
||||
interval="1d",
|
||||
data_root=os.path.join(tmp, "data"),
|
||||
metadata_db=os.path.join(tmp, "meta.sqlite"),
|
||||
)
|
||||
|
||||
# -- 1. The settlement reference, then the contracts ---------------------------
|
||||
store = mbt.ingest(
|
||||
provider="deribit", symbol=UNDERLYING, symbol_id=UNDERLYING_ID,
|
||||
asset_class="crypto_perp", **common
|
||||
)
|
||||
store = mbt.ingest(
|
||||
provider="deribit", symbol=CALL_100K, symbol_id=CALL_ID,
|
||||
asset_class="option", **common
|
||||
)
|
||||
store = mbt.ingest(
|
||||
provider="deribit", symbol=PUT_90K, symbol_id=PUT_ID,
|
||||
asset_class="option", **common
|
||||
)
|
||||
|
||||
# The connector asked Deribit for the terms and the store kept them; nothing
|
||||
# below is inferred from the instrument name.
|
||||
for symbol_id, terms in sorted(store.option_contracts().items()):
|
||||
print(f"{symbol_id}: {terms['option_type']} {terms['strike']:.0f}, "
|
||||
f"settles {terms['settlement']}")
|
||||
|
||||
# -- 2. A risk reversal: long the 100k call, short the 90k put -----------------
|
||||
# Legs are told apart by `col("symbol_id")`. Do NOT discriminate on price level
|
||||
# (e.g. "close < 100"): a premium that crosses the threshold silently flips the
|
||||
# leg to zero and the strategy closes its own position.
|
||||
size = (
|
||||
mbt.when(col("symbol_id") == float(CALL_ID), 1.0, 0.0)
|
||||
+ mbt.when(col("symbol_id") == float(PUT_ID), -1.0, 0.0)
|
||||
)
|
||||
strategy = (
|
||||
mbt.Strategy.create("risk_reversal")
|
||||
.signal("leg", col("symbol_id"))
|
||||
.size(size)
|
||||
.describe("Long the 100k call, short the 90k put, both held to expiration")
|
||||
)
|
||||
|
||||
start, end = time_range("2025-05-01", "2025-07-01")
|
||||
config = mbt.BacktestConfig(
|
||||
universe=[UNDERLYING_ID, CALL_ID, PUT_ID],
|
||||
time_range_start=start,
|
||||
time_range_end=end,
|
||||
bar_interval=Interval.days(1),
|
||||
initial_capital=10.0, # 10 BTC
|
||||
currency="BTC",
|
||||
option_underlyings={CALL_ID: UNDERLYING_ID, PUT_ID: UNDERLYING_ID},
|
||||
option_margin_model="deribit", # the short put pays margin
|
||||
execution=mbt.ExecutionConfig(position_sizing_mode="Units", allow_short=True),
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = mbt.run(strategy, config, store)
|
||||
trades = result.trades.to_pandas()
|
||||
|
||||
print("\nTrades on the option legs:")
|
||||
print(
|
||||
trades[trades.symbol_id != UNDERLYING_ID][
|
||||
["symbol_id", "side", "quantity", "fill_price", "exit_reason"]
|
||||
].to_string(index=False)
|
||||
)
|
||||
# exit_reason 5 is OptionExpiry: the venue settled the contract, the
|
||||
# strategy did not sell it. The put settles at 0 because BTC finished far
|
||||
# above its 90k strike.
|
||||
print("\nFinal equity: %.6f BTC" % float(result.equity_curve[-1]))
|
||||
if result.warnings:
|
||||
print("Warnings:", result.warnings)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Option strategy -- a bull call spread, held to expiration.
|
||||
|
||||
Demonstrates:
|
||||
- A two-leg option structure: long a low strike, short a higher one
|
||||
- Per-leg sizing with col("symbol_id")
|
||||
- A SHORT option paying margin under the venue's own formula
|
||||
- Both legs cash-settled at expiry, which is what caps the payoff
|
||||
|
||||
The structure: buy the 100k call, sell the 110k call, same expiration. The
|
||||
short leg pays for part of the long one, and in exchange it caps the gain at
|
||||
the distance between the strikes. Classic, and the cheapest way to see the
|
||||
engine settle two contracts on the same day with different outcomes.
|
||||
|
||||
**A currency trap worth knowing.** Every leg of a strategy has to be quoted in
|
||||
the same currency, because the engine carries one cash balance. On Deribit an
|
||||
option is quoted in BTC, but `BTC-PERPETUAL` is quoted in USD. So a covered
|
||||
call (long the perpetual, short a call) would add dollars to bitcoin in a single
|
||||
number and produce a meaningless equity curve. A spread has both legs in BTC,
|
||||
which is why this example is a spread. The perpetual appears below only as the
|
||||
settlement reference, never as a position.
|
||||
|
||||
Data: self-contained (network) — ingested on each run from a free connector
|
||||
|
||||
Usage:
|
||||
python examples/24_option_spread.py
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import manifoldbt as mbt
|
||||
from manifoldbt.indicators import col
|
||||
from manifoldbt.helpers import time_range, Interval
|
||||
|
||||
# Both legs expired on 2025-06-27, so the whole life of the trade is history.
|
||||
UNDERLYING, UNDERLYING_ID = "BTC-PERPETUAL", 1
|
||||
LONG_LEG, LONG_ID = "BTC-27JUN25-100000-C", 2
|
||||
SHORT_LEG, SHORT_ID = "BTC-27JUN25-110000-C", 3
|
||||
START, END = "2025-05-01T00:00:00Z", "2025-07-01T00:00:00Z"
|
||||
|
||||
tmp = tempfile.mkdtemp()
|
||||
common = dict(
|
||||
start=START,
|
||||
end=END,
|
||||
interval="1d",
|
||||
data_root=os.path.join(tmp, "data"),
|
||||
metadata_db=os.path.join(tmp, "meta.sqlite"),
|
||||
)
|
||||
|
||||
store = mbt.ingest(
|
||||
provider="deribit", symbol=UNDERLYING, symbol_id=UNDERLYING_ID,
|
||||
asset_class="crypto_perp", **common
|
||||
)
|
||||
for symbol, symbol_id in ((LONG_LEG, LONG_ID), (SHORT_LEG, SHORT_ID)):
|
||||
store = mbt.ingest(
|
||||
provider="deribit", symbol=symbol, symbol_id=symbol_id,
|
||||
asset_class="option", **common
|
||||
)
|
||||
|
||||
# -- The strategy --------------------------------------------------------------
|
||||
# Legs are told apart by symbol id. Never discriminate on price level: a premium
|
||||
# crossing the threshold would flip its own leg to zero and close the position.
|
||||
size = (
|
||||
mbt.when(col("symbol_id") == float(LONG_ID), 1.0, 0.0) # buy the 100k call
|
||||
+ mbt.when(col("symbol_id") == float(SHORT_ID), -1.0, 0.0) # sell the 110k call
|
||||
)
|
||||
strategy = (
|
||||
mbt.Strategy.create("bull_call_spread")
|
||||
.signal("leg", col("symbol_id"))
|
||||
.size(size)
|
||||
.describe("Long the 100k call, short the 110k call, held to expiration")
|
||||
)
|
||||
|
||||
start, end = time_range("2025-05-01", "2025-07-01")
|
||||
config = mbt.BacktestConfig(
|
||||
universe=[UNDERLYING_ID, LONG_ID, SHORT_ID],
|
||||
time_range_start=start,
|
||||
time_range_end=end,
|
||||
bar_interval=Interval.days(1),
|
||||
initial_capital=10.0, # 10 BTC: everything here is in BTC
|
||||
currency="BTC",
|
||||
option_underlyings={LONG_ID: UNDERLYING_ID, SHORT_ID: UNDERLYING_ID},
|
||||
option_margin_model="deribit", # the short leg posts margin
|
||||
execution=mbt.ExecutionConfig(position_sizing_mode="Units", allow_short=True),
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = mbt.run(strategy, config, store)
|
||||
trades = result.trades.to_pandas()
|
||||
|
||||
names = {LONG_ID: "long 100k", SHORT_ID: "short 110k"}
|
||||
print("\nTrades, by leg:")
|
||||
for _, t in trades[trades.symbol_id != UNDERLYING_ID].iterrows():
|
||||
what = "settled" if t.exit_reason == 5 else "traded"
|
||||
print(f" {names[t.symbol_id]:<12} {what:<8} {t.quantity:>4.1f} @ {t.fill_price:.6f} BTC")
|
||||
|
||||
equity = float(result.equity_curve[-1])
|
||||
print(f"\nFinal equity: {equity:.6f} BTC ({equity - 10.0:+.6f})")
|
||||
# The short leg expiring worthless is what the spread pays for: it financed
|
||||
# part of the long call, and capped the gain at the strike distance.
|
||||
if result.warnings:
|
||||
print("Warnings:", result.warnings)
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Look-ahead — the leak the detector cannot see.
|
||||
|
||||
The classic beginner's leak, and the one that survives review: computing a
|
||||
statistic over the *whole* dataset in the notebook, then using it as a strategy
|
||||
parameter. Here it is the mean price of the entire asset, used from bar 0 —
|
||||
where 749 of the 750 days it summarises are still in the future.
|
||||
|
||||
**There IS a leak in this strategy. It is deliberate.** The question the
|
||||
example answers is not whether the leak exists, but which audit methods can
|
||||
see it — and two of the three cannot. Read every "sees nothing" below as a
|
||||
statement about the *method*, never as a clean bill of health for the
|
||||
strategy.
|
||||
|
||||
What this example shows, in order:
|
||||
|
||||
1. the leak triples the return and doubles the Sharpe;
|
||||
2. `detect_lookahead` sees nothing;
|
||||
3. perturbing every future bar sees nothing either;
|
||||
4. the one method that catches it: re-derive the parameter on truncated data.
|
||||
|
||||
**Why the first two are blind.** Both re-run the *same strategy* on shorter or
|
||||
altered data. The mean is not recomputed by the engine: it is a number baked
|
||||
into the strategy at research time. Re-running cannot see it, because the leak
|
||||
already happened, in the notebook, before the backtest existed.
|
||||
|
||||
That is not a bug to fix in the detector, it is the shape of the problem. No
|
||||
re-run method can audit a constant. The only defence is to treat every
|
||||
parameter derived from data as part of the pipeline, and re-derive it on
|
||||
whatever window you are testing.
|
||||
|
||||
Self-contained: generates its own synthetic data in a temp store.
|
||||
|
||||
Demonstrates:
|
||||
- a parameter computed over the whole dataset, used from bar 0
|
||||
- two audit methods that do not see it, and why
|
||||
- the one that does: re-deriving the parameter per window
|
||||
|
||||
Data: synthetic (seed 11) — generated by this file, reproducible.
|
||||
The fixture DETERMINES the outcome: see examples/README.md.
|
||||
|
||||
Usage:
|
||||
python examples/25_lookahead_trap.py
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import manifoldbt as mbt
|
||||
from manifoldbt.indicators import close
|
||||
from manifoldbt.helpers import Interval, Slippage
|
||||
|
||||
# -- A mean-reverting series: exactly where knowing the mean is gold ----------
|
||||
N_DAYS = 750
|
||||
rng = np.random.default_rng(11)
|
||||
level = np.cumsum(rng.normal(0.0, 0.018, N_DAYS))
|
||||
px = 100.0 * np.exp(level - np.linspace(0, level[-1], N_DAYS))
|
||||
o = px
|
||||
c = np.roll(px, -1)
|
||||
c[-1] = px[-1]
|
||||
amp = np.abs(rng.normal(0.0, 0.004, N_DAYS))
|
||||
frame = pd.DataFrame({
|
||||
"timestamp": pd.date_range("2022-01-01", periods=N_DAYS, freq="1D", tz="UTC"),
|
||||
"open": o,
|
||||
"high": np.maximum(o, c) * (1 + amp),
|
||||
"low": np.minimum(o, c) * (1 - amp),
|
||||
"close": c,
|
||||
"volume": rng.uniform(1_000, 5_000, N_DAYS),
|
||||
})
|
||||
|
||||
|
||||
def store_of(df, tag):
|
||||
root = tempfile.mkdtemp(prefix=f"mbt_ex25_{tag}_")
|
||||
return mbt.import_dataframe(
|
||||
df, symbol="SYNTH", symbol_id=1, interval="1d",
|
||||
data_root=os.path.join(root, "data"),
|
||||
metadata_db=os.path.join(root, "meta.sqlite"),
|
||||
)
|
||||
|
||||
|
||||
def config_of(df):
|
||||
ts = pd.DatetimeIndex(df["timestamp"])
|
||||
return mbt.BacktestConfig(
|
||||
universe=[1],
|
||||
time_range_start=int(ts[0].value),
|
||||
time_range_end=int(ts[-1].value) + 86_400_000_000_000,
|
||||
bar_interval=Interval.days(1),
|
||||
initial_capital=10_000,
|
||||
execution=mbt.ExecutionConfig(
|
||||
signal_delay=1, max_position_pct=1.0,
|
||||
allow_short=True, position_sizing_mode="FractionOfEquity",
|
||||
),
|
||||
slippage=Slippage.fixed_bps(0),
|
||||
warmup_bars=0,
|
||||
)
|
||||
|
||||
|
||||
def leaky(mean_price):
|
||||
"""THE LEAK: `mean_price` comes from `df.close.mean()` over everything."""
|
||||
return (
|
||||
mbt.Strategy.create("global_mean_leak")
|
||||
.signal("edge", close)
|
||||
.size(mbt.when(close < mean_price, 1.0, -1.0))
|
||||
.describe("Long below the global mean, short above")
|
||||
)
|
||||
|
||||
|
||||
def causal(window):
|
||||
"""The honest twin: a rolling mean knows only the past."""
|
||||
return (
|
||||
mbt.Strategy.create("rolling_mean")
|
||||
.signal("edge", close)
|
||||
.size(mbt.when(close < close.rolling_mean(window), 1.0, -1.0))
|
||||
.describe("Long below the rolling mean")
|
||||
)
|
||||
|
||||
|
||||
def equity(result):
|
||||
return np.array([float(x) for x in result.equity_curve])
|
||||
|
||||
|
||||
# The two words this example turns on. "SEES NOTHING" describes the METHOD,
|
||||
# never the strategy: the leak below is there whatever any audit reports.
|
||||
BLIND, CAUGHT = "SEES NOTHING", "CATCHES THE LEAK"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
global_mean = float(frame["close"].mean())
|
||||
store = store_of(frame, "full")
|
||||
|
||||
# -- 1. The seduction -----------------------------------------------------
|
||||
leaked = mbt.run(leaky(global_mean), config_of(frame), store)
|
||||
honest = mbt.run(causal(60), config_of(frame), store)
|
||||
|
||||
print(f"Global mean over all {N_DAYS} days: {global_mean:.4f}")
|
||||
print("(at bar 0, 749 of those days have not happened yet)")
|
||||
print("\nThis strategy IS leaking. Below: which audits notice.\n")
|
||||
for label, r in (("with the leak ", leaked), ("rolling mean ", honest)):
|
||||
m = r.metrics
|
||||
print(f" {label} return {100 * m['total_return']:+8.2f}% "
|
||||
f"sharpe {m['sharpe']:5.2f} trades {r.trade_count}")
|
||||
|
||||
# -- 2. The detector -------------------------------------------------------
|
||||
from manifoldbt.diagnostics import detect_lookahead
|
||||
|
||||
report = detect_lookahead(leaky(global_mean), config_of(frame), store, mode="all")
|
||||
compared = sum(r.total_trades_overlap for r in report.reports)
|
||||
verdict = BLIND if report.passed else CAUGHT
|
||||
print(f"\n [1] detect_lookahead .......... {verdict}")
|
||||
print(f" ({compared} trades compared, so the verdict is not empty)")
|
||||
|
||||
# -- 3. Perturbing the future ---------------------------------------------
|
||||
split = 500
|
||||
reference = equity(mbt.run(leaky(global_mean), config_of(frame), store))
|
||||
corrupted = frame.copy()
|
||||
tail = slice(split + 1, None)
|
||||
factor = 1.0 + np.random.default_rng(99).uniform(-0.05, 0.05, N_DAYS - split - 1)
|
||||
for col in ("open", "high", "low", "close"):
|
||||
corrupted.loc[corrupted.index[tail], col] = corrupted[col].to_numpy()[tail] * factor
|
||||
perturbed = equity(mbt.run(leaky(global_mean), config_of(frame),
|
||||
store_of(corrupted, "pert")))
|
||||
n = min(len(reference), len(perturbed), split + 1)
|
||||
drift = float(np.abs(reference[:n] - perturbed[:n]).max())
|
||||
verdict = CAUGHT if drift > 0 else BLIND
|
||||
print(f" [2] future perturbation ....... {verdict}")
|
||||
print(f" (past moved by {drift:.3e} while the future moved by "
|
||||
f"{abs(reference[-1] - perturbed[-1]):.0f})")
|
||||
|
||||
# -- 4. The method that works ---------------------------------------------
|
||||
# Treat the mean as what it is: a pipeline step, not a constant. A
|
||||
# researcher standing at bar 500 only has the first 500 bars.
|
||||
truncated = frame.iloc[:split + 1]
|
||||
honest_mean = float(truncated["close"].mean())
|
||||
with_future = equity(mbt.run(leaky(global_mean), config_of(truncated),
|
||||
store_of(truncated, "t1")))
|
||||
with_past = equity(mbt.run(leaky(honest_mean), config_of(truncated),
|
||||
store_of(truncated, "t2")))
|
||||
m = min(len(with_future), len(with_past))
|
||||
gap = float(np.abs(with_future[:m] - with_past[:m]).max())
|
||||
verdict = CAUGHT if gap > 0 else BLIND
|
||||
print(f" [3] re-deriving the parameter . {verdict}")
|
||||
print(f" mean from the whole set : {global_mean:.4f} -> "
|
||||
f"final equity {with_future[-1]:,.0f}")
|
||||
print(f" mean from the past only : {honest_mean:.4f} -> "
|
||||
f"final equity {with_past[-1]:,.0f}")
|
||||
print(f" same window, same bars, equity differs by {gap:.0f}")
|
||||
print("\n Verdict: the leak is real, and only [3] found it. A")
|
||||
print(" 'SEES NOTHING' is a statement about the method, not about")
|
||||
print(" the strategy.")
|
||||
@@ -0,0 +1,112 @@
|
||||
# Examples
|
||||
|
||||
Each file demonstrates **one mechanism** of the engine and stops there. They are
|
||||
documentation that happens to execute, not strategies: several would lose money
|
||||
traded as written, and that is not a defect.
|
||||
|
||||
## The contract
|
||||
|
||||
Every example opens with the same four blocks, in this order:
|
||||
|
||||
```
|
||||
"""One-line title — the mechanism, named.
|
||||
|
||||
Demonstrates:
|
||||
- the two or three API surfaces the file exists to show
|
||||
|
||||
Data: <provenance>
|
||||
|
||||
Usage:
|
||||
python examples/NN_name.py
|
||||
"""
|
||||
```
|
||||
|
||||
`Data:` is the block that matters most, and it is not optional. An example is
|
||||
read by someone deciding whether to trust a number, so where the number came
|
||||
from is part of the example.
|
||||
|
||||
## The three data provenances
|
||||
|
||||
**`Data: shared store`** — real market data from `data/`, ingested once and
|
||||
reused. These examples need that store to exist (see below). Their results are
|
||||
real in the sense that the prices are real; they are still not investment
|
||||
advice, and none of the strategies is tuned.
|
||||
|
||||
**`Data: self-contained (network)`** — the file ingests what it needs on each
|
||||
run, from a free connector. Runs on a fresh clone, needs a network.
|
||||
|
||||
**`Data: synthetic (seed N)`** — the file generates its own series. Always
|
||||
reproducible, never a market claim.
|
||||
|
||||
## The rule on synthetic data
|
||||
|
||||
Synthetic data is legitimate and often the right choice: it isolates a
|
||||
mechanism from market noise, and it lets an example run anywhere with no
|
||||
dependency. It is used deliberately here, not as a fallback.
|
||||
|
||||
The one thing it must never do is let a reader mistake a fixture for a result.
|
||||
So when the construction of the series **determines the outcome**, the file says
|
||||
so before printing any figure, and marks the affected numbers. Two examples are
|
||||
in that case:
|
||||
|
||||
| | What the fixture determines |
|
||||
|---|---|
|
||||
| `21_fill_at_computed_level.py` | the series is detrended, so mean reversion cannot lose on it — only the *gap* between two execution prices is meaningful |
|
||||
| `25_lookahead_trap.py` | the leak is deliberate; the file exists to show which audits fail to see it |
|
||||
|
||||
A synthetic example whose fixture does **not** predetermine the outcome carries
|
||||
no such warning, because there is nothing to warn about.
|
||||
|
||||
## The rule on performance
|
||||
|
||||
No example is selected, tuned, or presented for its returns. Where a figure
|
||||
appears it illustrates the mechanism under discussion. If an example ever
|
||||
produced a reproducible edge on real data, it would belong in a private
|
||||
repository rather than in documentation.
|
||||
|
||||
## The shared store
|
||||
|
||||
Examples marked `Data: shared store` read `data/` and `metadata/`, which are not
|
||||
in the repository. Populate them once:
|
||||
|
||||
```python
|
||||
import manifoldbt as mbt
|
||||
mbt.ingest(provider="binance", symbol="BTCUSDT", symbol_id=1,
|
||||
start="2021-01-01T00:00:00Z", end="2025-01-01T00:00:00Z")
|
||||
```
|
||||
|
||||
Add the symbols an example asks for; each names them in its `universe`. The
|
||||
self-contained and synthetic examples need none of this.
|
||||
|
||||
## Index
|
||||
|
||||
| | Example | Mechanism | Data |
|
||||
|---|---|---|---|
|
||||
| 00 | `00_template.py` | the minimal shape of a backtest | shared store |
|
||||
| 01 | `01_trend_following.py` | fluent builder, EMA, stop-loss, diagnostics | shared store |
|
||||
| 02 | `02_mean_reversion.py` | bands, z-score, conditional sizing | shared store |
|
||||
| 03 | `03_multi_asset_momentum.py` | cross-sectional ranking over a universe | shared store |
|
||||
| 04 | `04_linear_regression.py` | rolling regression as a signal | shared store |
|
||||
| 05 | `05_stat_arb.py` | pair spread, cross-asset references | shared store |
|
||||
| 06 | `06_full_visualization.py` | the whole plotting surface | shared store |
|
||||
| 07 | `07_walk_forward.py` | fold geometries, out-of-sample selection | shared store |
|
||||
| 08 | `08_sweep_2d_heatmap.py` | a two-parameter sweep, read as a surface | shared store |
|
||||
| 09 | `09_surface_3d.py` | the same surface in three dimensions | shared store |
|
||||
| 10 | `10_monte_carlo.py` | bootstrap resampling, rare-event metrics | shared store |
|
||||
| 11 | `11_portfolio.py` | several strategies in one book, rebalancing | shared store |
|
||||
| 12 | `12_diagnostics.py` | look-ahead, exposure, risk checks | shared store |
|
||||
| 13 | `13_stochastic_simulation.py` | SDE paths from the expression DSL | synthetic |
|
||||
| 14 | `14_multi_timeframe.py` | higher timeframes, and how periods count | shared store |
|
||||
| 15 | `15_cross_exchange.py` | signal on one venue, execution on another | shared store |
|
||||
| 16 | `16_hashrate_exogene.py` | an exogenous series joined onto the bars | shared store |
|
||||
| 17 | `17_per_venue_fees.py` | per-venue fee schedules in one book | shared store |
|
||||
| 18 | `18_csv_import.py` | CSV / MT4 / MT5 import | synthetic |
|
||||
| 19 | `19_custom_indicators.py` | composing an indicator from primitives | shared store |
|
||||
| 20 | `20_entry_orders.py` | limit, stop and market-if-touched entries | shared store |
|
||||
| 21 | `21_fill_at_computed_level.py` | filling at a level computed in advance | synthetic ⚠ |
|
||||
| 22 | `22_yahoo_equities.py` | stocks, ETFs, indices, FX via Yahoo | network |
|
||||
| 23 | `23_deribit_options.py` | option contracts that expire and settle | network |
|
||||
| 24 | `24_option_spread.py` | a two-leg option structure | network |
|
||||
| 25 | `25_lookahead_trap.py` | the look-ahead no re-run can detect | synthetic ⚠ |
|
||||
|
||||
⚠ marks the two files whose fixture determines the outcome, as described above.
|
||||
Reference in New Issue
Block a user