diff --git a/examples/07_walk_forward.py b/examples/07_walk_forward.py index a2b0e21..3369ef0 100644 --- a/examples/07_walk_forward.py +++ b/examples/07_walk_forward.py @@ -15,10 +15,11 @@ from manifoldbt.indicators import close, ema from manifoldbt.helpers import time_range, Slippage, Interval # -- Strategy with tunable parameters ---------------------------------------- -# Indicators use concrete defaults; the Rust sweep engine replaces param() -# references at runtime with each grid value. -fast = ema(close, 12) -slow = ema(close, 26) +# The sweep engine substitutes each grid value into the param() references at +# runtime. The period MUST be param("..."): a hardcoded int compiles every +# combo to the same strategy, so the sweep becomes a silent no-op. +fast = ema(close, mbt.param("fast", default=12)) +slow = ema(close, mbt.param("slow", default=26)) signal = mbt.when(fast > slow, 1.0, mbt.when(fast < slow, -1.0, 0.0)) @@ -27,8 +28,6 @@ strategy = ( .signal("fast", fast) .signal("slow", slow) .size(signal * 0.25) - .param("fast", default=12, range=(5, 30)) - .param("slow", default=26, range=(20, 60)) .describe("EMA crossover with walk-forward parameter optimization") ) @@ -78,14 +77,20 @@ if __name__ == "__main__": elapsed = time.perf_counter() - t0 folds = result.get("folds", []) - best_params = result.get("best_params_per_fold", []) + metric = wf_config["optimize_metric"] - for i, (fold, params) in enumerate(zip(folds, best_params)): - train = fold.get("train_metric", 0) - test = fold.get("test_metric", 0) - print(f" Fold {i+1}: train={train:+.3f} test={test:+.3f} params={params}") + def unwrap(params): + # best_params values are ScalarValue dicts, e.g. {"Int64": 20} -> 20 + return {k: (next(iter(v.values())) if isinstance(v, dict) else v) + for k, v in params.items()} + + for i, fold in enumerate(folds): + is_m = fold["is_metrics"][metric] + oos_m = fold["oos_metrics"][metric] + params = unwrap(fold["best_params"]) + print(f" Fold {i+1}: IS {metric}={is_m:+.3f} OOS {metric}={oos_m:+.3f} params={params}") print(f"\n{len(folds)} folds in {elapsed:.2f}s") if folds: - mbt.plot.walk_forward({"metric": "sharpe", "folds": folds}, show=True) + mbt.plot.walk_forward({"optimize_metric": metric, "folds": folds}, show=True) diff --git a/examples/19_custom_indicators.py b/examples/19_custom_indicators.py new file mode 100644 index 0000000..1d92342 --- /dev/null +++ b/examples/19_custom_indicators.py @@ -0,0 +1,206 @@ +"""Créer ses propres indicateurs (indicateurs absents de la base). + + python examples/19_custom_indicators.py + +──────────────────────────────────────────────────────────────────────────── +LE MODÈLE MENTAL +──────────────────────────────────────────────────────────────────────────── +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. + +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. +""" + +import os +from time import perf_counter + +import manifoldbt as mbt +# Colonnes de base (ce sont déjà des Expr) + quelques 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). +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) +# ═══════════════════════════════════════════════════════════════════════════ +# On combine colonnes + opérateurs (+ - * /, > < >= & | ~) + méthodes d'Expr +# (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. + +def awesome_oscillator(fast=5, slow=34): + """Awesome Oscillator (Bill Williams) — ABSENT de la base. + + AO = SMA(prix médian, 5) − SMA(prix médian, 34), prix médian = (H+L)/2 + + Momentum : positif = pression acheteuse, négatif = vendeuse. + """ + median_price = (high + low) / 2 # Expr : opération sur 2 colonnes + return sma(median_price, fast) - sma(median_price, slow) # Expr résultat + + +def dist_to_ma_pct(period=20): + """Écart en % du prix à sa moyenne mobile — ABSENT de la base. + + Négatif = le prix est SOUS sa moyenne (survendu) → brique idéale pour du + retour à la moyenne. Une seule ligne de 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. + + Un proxy de volatilité instantané. Montre qu'on mélange librement les + colonnes OHLC. + """ + 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. + + Compose un indicateur natif (rsi) avec des stats roulantes. C'est + exactement le motif utilisé dans 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 +# ═══════════════════════════════════════════════════════════════════════════ +# 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). +# +# 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 +# +# Preuve que c'est puissant : le Kalman et le GARCH livrés sont écrits +# UNIQUEMENT avec scan (voir 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). + + streak = streak_précédent + 1 si close > close(-1), sinon 0 + """ + is_up = close > close.lag(1) # Expr booléen (1.0 / 0.0) par barre + return scan( + state={"n": lit(0.0)}, # compteur initialisé à 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): + """EMA « à la main » via scan — juste pour illustrer le mécanisme. + (L'EMA existe en natif : `ema(close, span)`. Ici c'est pédagogique.) + + ema = alpha * close + (1 - alpha) * ema_précédent + """ + return scan( + state={"ema": close}, # graine = 1er close + update={"ema": lit(alpha) * close + lit(1.0 - alpha) * s.prev("ema")}, + output="ema", + ) + + +# ═══════════════════════════════════════════════════════════════════════════ +# NIVEAU 3 — LES LIMITES (À CONNAÎTRE) +# ═══════════════════════════════════════════════════════════════════════════ +# • 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. + + +# ═══════════════════════════════════════════════════════════════════════════ +# BONUS — RENDRE SON INDICATEUR BALAYABLE (sweep) +# ═══════════════════════════════════════════════════════════════════════════ +# 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 : +# +# ao = awesome_oscillator(fast=param("fast"), slow=param("slow")) +# # puis, avec la grille passée séparément (l'indicateur ne change pas) : +# # 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.) + + +# ═══════════════════════════════════════════════════════════════════════════ +# METTRE UN INDICATEUR MAISON DANS UNE STRATÉGIE + BACKTEST +# ═══════════════════════════════════════════════════════════════════════════ +# 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. + +dist = dist_to_ma_pct(period=48) # notre indicateur maison +streak = up_streak() # et un second, pour l'exposer aussi + +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 + +strategy = ( + mbt.Strategy.create("custom_indicator_demo") + .signal("dist_to_ma_%", dist) # .signal() = exposer pour le rapport + .signal("up_streak", streak) + .size(signal) + .describe("Retour à la moyenne piloté par un indicateur maison (écart à la MM)") +) + +# -- 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(), # sans frais, pour l'exemple + slippage=Slippage.fixed_bps(2), + warmup_bars=60, # >= la plus longue fenêtre utilisée +) + +# -- 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") diff --git a/pyproject.toml b/pyproject.toml index d7fae7a..8d1a6b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "manifoldbt" -version = "0.10.0" +version = "0.11.0" description = "Rust-powered backtesting engine for quantitative research" requires-python = ">=3.9" license = { file = "LICENSE" }