diff --git a/README.md b/README.md index f35169c..0f0450b 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,7 @@

- Join the ManifoldBT Discord -

- -

- PyPI - Python 3.9+ - Rust core - Benchmarks in public CI + Discord

@@ -33,7 +26,7 @@ sequential fill simulation with realistic fees, slippage, funding and look-ahead ## Why ManifoldBT -- **Fast**: 10M bars in 329 ms. 79x faster than vectorbt, and 311x once you also want drawdown and Sharpe. [Measured in public CI](#performance), every run linked. +- **Fast**: 10M bars in 317 ms. 78x faster than vectorbt, 308x once you also want drawdown and Sharpe, ~3,500x faster than backtrader. [Measured in public CI](#performance), every run linked. - **Expressive**: fluent DSL with 30+ indicators, conditional logic, cross-asset references - **Rigorous**: Monte Carlo, walk-forward, parameter sweeps, lookahead detection, exposure diagnostics - **Portable**: `pip install`, no Rust toolchain needed. Works on Python 3.9+. @@ -107,13 +100,26 @@ store = mbt.import_csv("EURUSD_1m.csv", symbol="EURUSD", symbol_id=1, interval="1m", asset_class="forex") ``` -**Exchange connectors**: Binance, Bybit, Hyperliquid, dYdX, Bitstamp (free); Databento, Massive (Pro): +**Market data connectors**: Binance, Bybit, Hyperliquid, dYdX, Bitstamp, Yahoo Finance (free); +Databento, Massive (Pro): ```python store = mbt.ingest(provider="binance", symbol="BTCUSDT", symbol_id=1, start="2024-01-01T00:00:00Z", end="2025-01-01T00:00:00Z") ``` +Yahoo Finance covers stocks, ETFs, indices (`^GSPC`), FX (`EURUSD=X`), futures +(`ES=F`) and crypto (`BTC-USD`) without an API key. Prices are dividend-adjusted, +like `yfinance`'s `auto_adjust=True`; pass `dataset="raw"` for unadjusted quotes. +Yahoo's own history limits apply: 1m over the last 30 days, 1h over ~2 years, +daily back to the listing date. + +```python +store = mbt.ingest(provider="yahoo", symbol="AAPL", symbol_id=1, interval="1d", + asset_class="equity", + start="2015-01-01T00:00:00Z", end="2026-01-01T00:00:00Z") +``` + Or from the CLI: ```bash @@ -121,59 +127,6 @@ manifoldbt import-csv data.csv --symbol EURUSD --symbol-id 1 --interval 1m manifoldbt ingest --provider binance --symbol BTCUSDT --symbol-id 1 --start ... --end ... ``` -## Higher timeframes - -Declare the timeframes you want alongside the simulation one, then read them -with `mbt.tf(...)`. Columns are forward-filled onto the simulation grid, and a -bar's value only becomes readable once that bar has closed, so there is no -look-ahead. - -```python -config = mbt.BacktestConfig( - ..., - bar_interval=Interval.minutes(1), # simulate on 1m - extra_timeframes={"1h": Interval.hours(1)}, # also resample to 1h -) - -h1 = mbt.tf("1h") -h1.close # the last closed hourly close, held across the minute bars -``` - -For an **indicator** on a higher timeframe, use `.apply(...)`. It evaluates the -expression on that timeframe's own grid, so the period counts in *its* bars: - -```python -from manifoldbt.indicators import close, sma - -band = mbt.tf("1h").apply(sma(close, 20)) # mean of 20 HOURLY closes -``` - -> Careful: `sma(mbt.tf("1h").close, 20)` is **not** the same thing. That reads -> the step-held hourly series on the simulation grid, so the period counts in -> simulation bars: on a 1m simulation it is a 20-*minute* smoothing of an hourly -> staircase. Use `.apply(...)` whenever you want an indicator *of* the higher -> timeframe. - -## Sweeping a choice, not just a number - -`mbt.param(...)` sweeps numbers. `mbt.choice(...)` sweeps *expressions*: the -selector becomes a grid axis, and each combination resolves to its branch before -the simulation runs, so the branches it did not pick cost nothing. - -```python -band = mbt.choice("band", { - "30m": mbt.tf("30m").apply(sma(close, mbt.param("len"))), - "1h": mbt.tf("1h").apply(sma(close, mbt.param("len"))), - "2h": mbt.tf("2h").apply(sma(close, mbt.param("len"))), -}) - -sweep = mbt.run_sweep(strategy, {"band": ["30m", "1h", "2h"], - "len": range(10, 210, 10)}, config, store) -``` - -The branches can hold any expression, so the same mechanism sweeps which -exogenous column to use, which asset to reference, or which indicator to apply. - ## Examples | # | Example | What it shows | @@ -206,30 +159,26 @@ engine from PyPI the way a user would, generates its own data, checks that the engines produced the **same result**, and only then reports how long each took: a workload they disagree on gets no published timing at all. -**Latest run: [#13](https://github.com/manifoldbt/manifoldbt/actions/runs/32469701489)** -ran on Linux x86_64, 4 vCPU (AMD EPYC 7763), Python 3.12, manifoldbt 0.18.0 / -vectorbt 0.28.4 / raptorbt 0.9.0, 3 interleaved repetitions, medians reported. +**Latest run: [#11](https://github.com/manifoldbt/manifoldbt/actions/runs/32396472073)** +ran on Linux x86_64, 4 vCPU, Python 3.12, manifoldbt 0.17.3 / vectorbt 0.28.4 / +raptorbt 0.9.0, 3 interleaved repetitions. | Workload | Bars | ManifoldBT | vectorbt | raptorbt | |---|---:|---:|---:|---:| -| SMA crossover | 10M | **327 ms** | 26.12 s (x79) | 913 ms (x2.8) | -| ...with drawdown, Sharpe, Sortino, volatility | 10M | **329 ms** | 102.38 s (**x311**) | 909 ms (x2.8) | -| ...with a 5 bps fee and 2 bps slippage | 10M | **337 ms** | 26.08 s (x79) | not supported | -| EMA + RSI filter, 5 bps fee | 1M | **57 ms** | 2.35 s (x40) | not supported | -| Five assets in one book | 1M | **148 ms** | 2.54 s (x17) | not supported | -| Stop-loss and take-profit bracket | 10M | **934 ms** | 26.24 s (x28) | 916 ms (**x1.0**) | +| SMA crossover | 10M | **317 ms** | 24.75 s (x78) | 878 ms (x2.8) | +| ...with drawdown, Sharpe, Sortino, volatility | 10M | **317 ms** | 97.46 s (**x308**) | 894 ms (x2.8) | +| ...with a 5 bps fee and 2 bps slippage | 10M | **316 ms** | 24.53 s (x78) | not supported | +| EMA + RSI filter, 5 bps fee | 1M | **52 ms** | 2.21 s (x41) | not supported | +| Five assets in one book | 1M | **140 ms** | 2.34 s (x17) | not supported | The second row is the one worth reading twice. Asking for a performance summary costs ManifoldBT nothing measurable, because it computes one during the run -whether you read it or not, and costs vectorbt 102 seconds, because it defers -the equity curve until a risk metric needs it and then has to build one. +whether you read it or not, and costs vectorbt 73 seconds, because it defers the +equity curve until a risk metric needs it and then has to build one. -The last two rows are the ones where ManifoldBT does worst, and they are -published for that reason. Broadcasting a column per asset is close to free for -vectorbt, while walking five books is not free for anything. And on a -stop-loss/take-profit bracket, raptorbt is level with us: the intra-bar check -that decides which of the two triggers first is a sequential walk in both -engines, so there is no vectorization left to win with. +The fifth row is the one where ManifoldBT does worst, and it is published for +that reason: broadcasting a column per asset is close to free for vectorbt, +while walking five books is not free for anything. ### Parameter sweeps @@ -254,14 +203,9 @@ The method, the parity gate and the known divergences are written up in backtrader runs the same EMA(12/26) + RSI(14) strategy on 500K 1-minute bars in **46,944 ms**, against **13 ms** for ManifoldBT: a factor of **3,556**. Measured -with `benchmarks/bench_vs_competitors.py`, median of 3 runs, on a developer -machine and not the CI runner, so it is not comparable line-for-line with the -table above. - -It sits outside the CI suite because its event-driven fills produce a different -PnL, and the parity gate publishes no timing for engines that did not do the -same work. Treat it as an order of magnitude, not a benchmark: the two engines -are not doing the same thing. +with `benchmarks/bench_vs_competitors.py`, median of 3 runs. It sits outside the +CI suite because its event-driven fills produce a different PnL, and the parity +gate publishes no timing for engines that did not do the same work. ### How it compares @@ -289,7 +233,7 @@ Full API reference, indicator list, configuration guide, and best practices: | Monte Carlo | 1K sims | Unlimited | | Walk-Forward | - | Anchored + Rolling | | Parameter Stability | - | Yes | -| Crypto connectors (Binance, Bybit, Hyperliquid) | Yes | Yes | +| Free connectors (Binance, Bybit, Hyperliquid, dYdX, Bitstamp, Yahoo) | Yes | Yes | | Databento & Massive connectors | - | Yes | | Safety checks (lookahead, exposure) | - | Yes | | Tearsheets & export | - | Yes | diff --git a/examples/00_template.py b/examples/00_template.py index cc48a23..c5391ca 100644 --- a/examples/00_template.py +++ b/examples/00_template.py @@ -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 """ diff --git a/examples/01_trend_following.py b/examples/01_trend_following.py index 5a87d9c..37be671 100644 --- a/examples/01_trend_following.py +++ b/examples/01_trend_following.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 """ diff --git a/examples/02_mean_reversion.py b/examples/02_mean_reversion.py index 602ed29..a2fa579 100644 --- a/examples/02_mean_reversion.py +++ b/examples/02_mean_reversion.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 """ diff --git a/examples/03_multi_asset_momentum.py b/examples/03_multi_asset_momentum.py index ba49b1f..beaf975 100644 --- a/examples/03_multi_asset_momentum.py +++ b/examples/03_multi_asset_momentum.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 """ diff --git a/examples/04_linear_regression.py b/examples/04_linear_regression.py index 34a4292..0c62342 100644 --- a/examples/04_linear_regression.py +++ b/examples/04_linear_regression.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 """ diff --git a/examples/05_stat_arb.py b/examples/05_stat_arb.py index 2f3f381..8067950 100644 --- a/examples/05_stat_arb.py +++ b/examples/05_stat_arb.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 """ diff --git a/examples/06_full_visualization.py b/examples/06_full_visualization.py index c533739..f04054f 100644 --- a/examples/06_full_visualization.py +++ b/examples/06_full_visualization.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 """ diff --git a/examples/07_walk_forward.py b/examples/07_walk_forward.py index 5b788d1..a133b78 100644 --- a/examples/07_walk_forward.py +++ b/examples/07_walk_forward.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 """ diff --git a/examples/08_sweep_2d_heatmap.py b/examples/08_sweep_2d_heatmap.py index ed41a8e..e57015d 100644 --- a/examples/08_sweep_2d_heatmap.py +++ b/examples/08_sweep_2d_heatmap.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 """ diff --git a/examples/09_surface_3d.py b/examples/09_surface_3d.py index 4a73c29..0e9337f 100644 --- a/examples/09_surface_3d.py +++ b/examples/09_surface_3d.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 """ diff --git a/examples/10_monte_carlo.py b/examples/10_monte_carlo.py index f6566c4..4c4e6b4 100644 --- a/examples/10_monte_carlo.py +++ b/examples/10_monte_carlo.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 """ diff --git a/examples/11_portfolio.py b/examples/11_portfolio.py index 26f5103..6dc9e9e 100644 --- a/examples/11_portfolio.py +++ b/examples/11_portfolio.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 """ diff --git a/examples/12_diagnostics.py b/examples/12_diagnostics.py index 3ee96ec..8a8ad44 100644 --- a/examples/12_diagnostics.py +++ b/examples/12_diagnostics.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 """ diff --git a/examples/13_stochastic_simulation.py b/examples/13_stochastic_simulation.py index 68ca03c..52c7053 100644 --- a/examples/13_stochastic_simulation.py +++ b/examples/13_stochastic_simulation.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 """ diff --git a/examples/14_multi_timeframe.py b/examples/14_multi_timeframe.py index 3036143..47648fc 100644 --- a/examples/14_multi_timeframe.py +++ b/examples/14_multi_timeframe.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), }, diff --git a/examples/15_cross_exchange.py b/examples/15_cross_exchange.py index 58310fb..4f8ed1c 100644 --- a/examples/15_cross_exchange.py +++ b/examples/15_cross_exchange.py @@ -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 diff --git a/examples/16_hashrate_exogene.py b/examples/16_hashrate_exogene.py index 231c614..56b4cad 100644 --- a/examples/16_hashrate_exogene.py +++ b/examples/16_hashrate_exogene.py @@ -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 diff --git a/examples/17_per_venue_fees.py b/examples/17_per_venue_fees.py index 7e8a037..09eb484 100644 --- a/examples/17_per_venue_fees.py +++ b/examples/17_per_venue_fees.py @@ -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 """ diff --git a/examples/18_csv_import.py b/examples/18_csv_import.py index 2f363c0..c50f837 100644 --- a/examples/18_csv_import.py +++ b/examples/18_csv_import.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 """ diff --git a/examples/19_custom_indicators.py b/examples/19_custom_indicators.py index 1d92342..403dd5a 100644 --- a/examples/19_custom_indicators.py +++ b/examples/19_custom_indicators.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 ---------------------------------------------------------------------- diff --git a/examples/20_entry_orders.py b/examples/20_entry_orders.py index 0a966fa..2f4e921 100644 --- a/examples/20_entry_orders.py +++ b/examples/20_entry_orders.py @@ -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 """ diff --git a/examples/21_fill_at_computed_level.py b/examples/21_fill_at_computed_level.py index 4dfdbc2..6a60a3a 100644 --- a/examples/21_fill_at_computed_level.py +++ b/examples/21_fill_at_computed_level.py @@ -1,17 +1,36 @@ """Filling at a computed level — ExecutionPrice.custom(). 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(): 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." ) diff --git a/examples/22_yahoo_equities.py b/examples/22_yahoo_equities.py new file mode 100644 index 0000000..aa7de98 --- /dev/null +++ b/examples/22_yahoo_equities.py @@ -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()) diff --git a/examples/23_deribit_options.py b/examples/23_deribit_options.py new file mode 100644 index 0000000..e653a91 --- /dev/null +++ b/examples/23_deribit_options.py @@ -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) diff --git a/examples/24_option_spread.py b/examples/24_option_spread.py new file mode 100644 index 0000000..89c2449 --- /dev/null +++ b/examples/24_option_spread.py @@ -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) diff --git a/examples/25_lookahead_trap.py b/examples/25_lookahead_trap.py new file mode 100644 index 0000000..81c24d3 --- /dev/null +++ b/examples/25_lookahead_trap.py @@ -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.") diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..68917c7 --- /dev/null +++ b/examples/README.md @@ -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: + +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. diff --git a/pyproject.toml b/pyproject.toml index 25e2146..b6e752c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "manifoldbt" -version = "0.18.0" +version = "0.19.0" description = "Rust-powered backtesting engine for quantitative research" requires-python = ">=3.9" license = { file = "LICENSE" } diff --git a/python/manifoldbt/__init__.py b/python/manifoldbt/__init__.py index 480ac07..d246665 100644 --- a/python/manifoldbt/__init__.py +++ b/python/manifoldbt/__init__.py @@ -54,7 +54,7 @@ from manifoldbt.exceptions import ( LicenseError, StrategyError, ) -from manifoldbt.expr import AssetRef, Expr, TimeframeRef, asset, col, exo, hold, lit, param, s, scan, symbol_ref, tf, when +from manifoldbt.expr import AssetRef, Expr, TimeframeRef, asset, choice, col, exo, hold, lit, param, s, scan, symbol_ref, tf, when from manifoldbt.helpers import ( ExecutionPrice, FillModel, @@ -452,9 +452,78 @@ def _prepare_config(config: BacktestConfig, strategy, store: DataStore) -> Backt # so the engine applies them per-strategy. This lets one batch/sweep call run # strategies carrying different brackets over a single data load. A bracket # set directly on config.execution.orders still applies as the fallback. + + _attach_option_contracts(cfg, store) return cfg +def _attach_option_contracts(cfg: BacktestConfig, store: DataStore) -> None: + """Fill ``cfg.option_contracts`` from what the store recorded at ingest. + + The terms come from the venue, so nothing here is guessed. The one thing the + caller must supply is ``option_underlyings``: Deribit settles against its own + index, whose ticker matches no series anyone can ingest, so which price + stands in for it is a decision, not a lookup. Getting it wrong silently would + settle every contract against the wrong number, so a missing entry raises. + """ + if cfg.option_contracts: + return # explicitly overridden by the caller + try: + available = store.option_contracts() + except AttributeError: + return # store predates option support (mock stores in tests) + + universe = cfg.universe if isinstance(cfg.universe, list) else [] + in_universe = {int(sid) for sid in universe if isinstance(sid, int)} + underlyings = {int(k): int(v) for k, v in (cfg.option_underlyings or {}).items()} + + # An option whose terms were never recorded is the dangerous case: it + # prices, it trades, it never expires, and nothing looks wrong. Catch it + # before the engine sees a plain price series. + try: + classes = store.asset_classes() + except AttributeError: + classes = {} + untermed = [ + int(sid) + for sid, klass in classes.items() + if klass == "EquityOption" + and int(sid) in in_universe + and int(sid) not in {int(k) for k in available} + ] + if untermed: + names = {int(i): t for i, t in store.list_symbols()} + listed = ", ".join(f"{sid} ({names.get(sid, '?')})" for sid in sorted(untermed)) + raise ValueError( + f"symbol(s) {listed} are recorded as options but carry no contract terms. " + "The connector that ingested them does not report a strike and an expiration, " + "so the engine would hold them forever at their last quoted premium instead of " + "settling them. Re-ingest from a connector that reports contract terms " + "(deribit, databento), or set config.option_contracts by hand." + ) + + missing = [] + contracts = {} + for sid, terms in available.items(): + sid = int(sid) + if sid not in in_universe: + continue + if sid not in underlyings: + missing.append(sid) + continue + contracts[sid] = dict(terms, underlying_id=underlyings[sid]) + + if missing: + names = {int(i): t for i, t in store.list_symbols()} + listed = ", ".join(f"{sid} ({names.get(sid, '?')})" for sid in sorted(missing)) + raise ValueError( + f"option symbol(s) {listed} have contract terms but no settlement " + "underlying. Set config.option_underlyings = {option_id: underlying_id}; " + "an option cannot be settled against its own last traded premium." + ) + cfg.option_contracts = contracts + + def _is_sub_daily(res: Any) -> bool: """Return True if an Interval dict represents sub-daily resolution.""" if not isinstance(res, dict): @@ -612,7 +681,8 @@ def ingest( """Ingest bars from a data provider into the Arrow IPC store. Providers (free): ``"binance"``, ``"bybit"``, ``"hyperliquid"``, ``"dydx"``, - ``"bitstamp"``. Pro: ``"databento"``, ``"massive"``. + ``"bitstamp"``, ``"deribit"``, ``"yahoo"`` (alias ``"yfinance"``). + Pro: ``"databento"``, ``"massive"``. Returns a :class:`DataStore` ready for :func:`run`. @@ -634,6 +704,50 @@ def ingest( start="2020-06-01T00:00:00Z", end="2026-03-01T00:00:00Z", ) + + Example (stocks, ETFs, indices, FX and futures via Yahoo Finance):: + + store = bt.ingest( + provider="yahoo", + symbol="AAPL", + symbol_id=1, + start="2015-01-01T00:00:00Z", + end="2026-01-01T00:00:00Z", + interval="1d", + asset_class="equity", + ) + + Yahoo caps its own history: 1m goes back 30 days, 1h about 2 years, daily + to the listing date. Prices are dividend-adjusted like ``yfinance``'s + ``auto_adjust=True``; pass ``dataset="raw"`` for unadjusted quotes. + + Example (a Deribit option, including one that has already expired):: + + store = bt.ingest( + provider="deribit", + symbol="BTC-27JUN25-100000-C", + symbol_id=2, + start="2025-05-01T00:00:00Z", + end="2025-07-01T00:00:00Z", + interval="1d", + asset_class="option", + ) + + Deribit is the only free connector here that serves expired contracts, which + is what an option backtest needs. The strike, expiration, side and settlement + style are read from the venue and stored beside the bars, so the engine can + settle the contract instead of holding it forever. Prices are quoted in the + base currency, so such a backtest is denominated in BTC, ``initial_capital`` + included. Set ``config.option_underlyings`` to say which series settles it. + + ``databento`` and ``massive`` (both Pro) report the same terms for US listed + options: Databento from the ``definition`` schema of a dataset such as + ``OPRA.PILLAR``, Massive from ``/v3/reference/options/contracts`` on an OSI + ticker like ``"O:SPY251219C00650000"``. Two things differ from Deribit. + Positions are counted in units of the underlying, so one 100-multiplier + contract is a position of 100. And US listed equity options are physically + settled, which the engine models as cash at intrinsic: exact for an index + option, an approximation for a single-stock one. """ _PRO_PROVIDERS = {"databento", "massive"} if provider in _PRO_PROVIDERS: @@ -1642,6 +1756,7 @@ __all__ = [ "s", "scan", "symbol_ref", + "choice", "tf", "when", # Strategy & config diff --git a/python/manifoldbt/cli.py b/python/manifoldbt/cli.py index cf387e9..a052068 100644 --- a/python/manifoldbt/cli.py +++ b/python/manifoldbt/cli.py @@ -18,7 +18,14 @@ def main() -> None: # ── ingest ──────────────────────────────────────────────────────────── ing = sub.add_parser("ingest", help="Ingest bars from a data provider") - ing.add_argument("--provider", required=True, help="binance | bybit | hyperliquid | databento") + ing.add_argument( + "--provider", + required=True, + help=( + "binance | bybit | hyperliquid | dydx | bitstamp | deribit | yahoo " + "| databento | massive" + ), + ) ing.add_argument("--symbol", required=True, help="e.g. BTCUSDT, ESH5") ing.add_argument("--symbol-id", required=True, type=int, help="Unique integer ID for this symbol") ing.add_argument("--start", required=True, help="RFC3339 start (e.g. 2025-01-01T00:00:00Z)") diff --git a/python/manifoldbt/config.py b/python/manifoldbt/config.py index 2bb0c65..20539a2 100644 --- a/python/manifoldbt/config.py +++ b/python/manifoldbt/config.py @@ -375,6 +375,32 @@ class BacktestConfig: """Explicit mapping from signal symbol to execution symbol. Required when signal and execution have different tickers. Example: ``{"BTC-USDT:perp": "BTC-USD:perp"}``""" + option_underlyings: Dict[int, int] = field(default_factory=dict) + """Maps an option symbol id to the symbol whose price settles it. + + Required for every option in the universe. Deribit settles against its own + index, whose ticker matches no series you can ingest, so the substitute is + yours to name (``BTC-PERPETUAL`` in practice). The engine refuses to run an + option without one rather than settle it against its own last traded + premium, which on an illiquid strike is days stale. + Example: ``{2: 1}`` to settle option id 2 against symbol id 1.""" + option_margin_model: str = "none" + """Margin formula short option positions pay: ``"none"`` or ``"deribit"``. + + ``"none"`` charges nothing, which is only honest when the strategy never + sells an option. ``"deribit"`` applies the venue's published per-contract + formula, refuses a short that does not fit initial margin, and force-closes + the book when maintenance margin passes equity.""" + option_contracts: Dict = field(default_factory=dict) + """Contract terms per option symbol id. Filled automatically from the data + store at run time; set it by hand only to override what was ingested. + + Positions on an option are counted in **units of the underlying**, not in + exchange contracts. On Deribit the two are the same thing (contract size 1). + On a listed equity option, one contract is 100 units: to hold one SPY + contract quoted at 4.70, target 100, which costs the 470 a contract costs + and settles for what a contract settles for. ``contract_size`` in the terms + is what converts a position back into contracts.""" # Deprecated — kept for backward compat provider: Optional[str] = None exo_sources: Dict = field(default_factory=dict) @@ -417,6 +443,12 @@ class BacktestConfig: d["signal_source"] = self.signal_source if self.execution_source: d["execution_source"] = self.execution_source + if self.option_contracts: + d["option_contracts"] = { + str(sid): spec for sid, spec in self.option_contracts.items() + } + if self.option_margin_model and self.option_margin_model != "none": + d["option_margin_model"] = self.option_margin_model # Deprecated fields (backward compat) if self.provider: d["provider"] = self.provider diff --git a/python/manifoldbt/diagnostics.py b/python/manifoldbt/diagnostics.py index 3955d6d..a734dea 100644 --- a/python/manifoldbt/diagnostics.py +++ b/python/manifoldbt/diagnostics.py @@ -111,11 +111,24 @@ def detect_lookahead( Data is loaded once and sliced for each sub-test (no redundant I/O). Two sub-tests: - * **extension** — split at 2/3 of the period. Catches *global* - look-ahead (e.g. ``np.mean(all_prices)`` instead of rolling). + * **extension** — split at 2/3 of the period. Catches look-ahead that + depends on how much data the run was given. * **truncation** — split at 1/3 of the period. Catches *rolling* look-ahead (e.g. signal at bar T using bar T+1). + .. warning:: + **What this cannot see.** Both sub-tests re-run the *same strategy* on + a shorter window. A parameter computed from the data *before* the + backtest — ``threshold = df.close.mean()`` in a notebook, then passed in + as a number — is unchanged by re-running, so the trades match and the + verdict is PASS. The leak already happened, outside the engine. + + This is a property of every re-run-based method, not a gap to be closed + here: no such test can audit a constant. The defence is to treat any + parameter derived from data as part of the pipeline and re-derive it on + the window under test. ``examples/25_lookahead_trap.py`` demonstrates + the blind spot and the technique that does catch it. + Args: strategy: Strategy definition. config: BacktestConfig. diff --git a/python/manifoldbt/expr.py b/python/manifoldbt/expr.py index db0203b..d1a5746 100644 --- a/python/manifoldbt/expr.py +++ b/python/manifoldbt/expr.py @@ -182,6 +182,17 @@ class Expr: if v == "IfElse": return {v: [args[0].to_json(), args[1].to_json(), args[2].to_json()]} + if v == "Choice": + # Choice(String, Vec<(String, Expr)>) -- serde attend une liste de + # paires, pas un dict : l'ORDRE des branches est porteur (la + # premiere sert de defaut a la compilation initiale). + return {"Choice": [args[0], [[k, e.to_json()] for k, e in args[1]]]} + + if v == "OnTimeframe": + # OnTimeframe(String, Box) -- l'expression est evaluee sur la + # grille de la timeframe nommee puis etalee en escalier. + return {"OnTimeframe": [args[0], args[1].to_json()]} + if v == "Column": return {"Column": args[0]} if v == "Literal": @@ -518,6 +529,62 @@ def when(condition: Expr, true_value: Any = 1.0, false_value: Any = float("nan") return Expr("IfElse", condition, _coerce(true_value), _coerce(false_value)) +def choice(name: str, branches: "dict[str, Expr]", *, description: str = "") -> Expr: + """Balayer un CHOIX d'expression, pas seulement un nombre. + + Un ``param()`` ordinaire porte une valeur numerique. ``choice()`` porte un + NOM, et chaque nom designe une sous-expression differente. Le moteur + remplace le noeud entier par la branche choisie AVANT de simuler, donc une + combinaison n'evalue que sa propre branche : les autres n'existent plus. + + C'est ce qui le distingue d'un ``when()`` imbrique, qui construit toutes + les variantes et tranche barre par barre. + + Usage (balayer la timeframe d'une bande, sim en 1m):: + + bande = mbt.choice("band", { + "30m": mbt.tf("30m").apply(sma(close, mbt.param("len"))), + "1h": mbt.tf("1h").apply(sma(close, mbt.param("len"))), + "2h": mbt.tf("2h").apply(sma(close, mbt.param("len"))), + }) + # grille : {"len": [10, 20, 30], "band": ["30m", "1h", "2h"]} + + Noter le ``apply()``. Ecrit ``sma(mbt.tf("30m").close, param("len"))``, le + balayage n'aurait pas le sens attendu : la periode compterait des barres de + SIMULATION sur une colonne etalee en escalier, donc les trois branches + lisseraient le meme nombre de MINUTES au lieu de 10, 20 ou 30 bougies de + leur timeframe. Voir :func:`tf`. + + Les branches acceptent n'importe quelle expression, donc le meme mecanisme + balaie une colonne exogene, un actif ou un type d'indicateur. + + Args: + name: nom du parametre selecteur, a mettre dans la grille. + branches: nom de branche -> expression. L'ordre compte : la premiere + sert de defaut quand le parametre est absent. + description: metadonnee libre. + + Raises: + ValueError: si ``branches`` est vide. + """ + if not branches: + raise ValueError( + f"choice({name!r}) needs at least one branch; an empty choice has " + f"nothing to resolve to." + ) + items = [(str(k), _coerce(v)) for k, v in branches.items()] + expr = Expr("Choice", name, items) + # Declare le selecteur comme un parametre a part entiere, sans quoi le + # balayer serait refuse par la validation ("parameter not declared"). + expr._param_meta = { + "name": name, + "default": items[0][0], + "range": None, + "description": description, + } + return expr + + def exo(name: str, column: Optional[str] = None) -> Expr: """Reference an exogenous data column. @@ -626,6 +693,28 @@ class TimeframeRef: """Reference any column from this timeframe.""" return col(f"{self._tf}.{name}") + def apply(self, expr: "Expr") -> Expr: + """Evaluate *expr* ON this timeframe's own grid, then step-hold the + result back onto the simulation grid (forward-filled, no lookahead: + a completed bar's value becomes readable from the next bar on). + + This is what makes higher-timeframe INDICATORS correct. Periods + inside *expr* count in THIS timeframe's bars:: + + h1 = bt.tf("1h") + band = h1.apply(sma(close, mbt.param("len"))) # len = HOURS + + is a true SMA of ``len`` hourly closes, sweepable like any param. + By contrast ``sma(h1.close, 20)`` counts 20 SIMULATION bars over a + step-held hourly series -- on a 1m simulation that is a 20-MINUTE + smoothing of a staircase, not a 20-hour average. + + Inside *expr*, ``close``/``open``/... refer to this timeframe's own + resampled columns. Requires ``extra_timeframes`` to declare the + timeframe. Nesting ``apply`` inside another ``apply`` is rejected. + """ + return Expr("OnTimeframe", self._tf, _coerce(expr)) + def __repr__(self) -> str: return f"TimeframeRef({self._tf!r})" @@ -633,12 +722,48 @@ class TimeframeRef: def tf(timeframe: str) -> TimeframeRef: """Reference a higher timeframe for multi-TF strategies. - Usage:: + Two different things, and the distinction matters:: h1 = bt.tf("1h") - trend = ema(h1.close, 20) > ema(h1.close, 50) + + h1.close # a COLUMN: the last closed hourly + # close, held across the minute bars + h1.apply(ema(close, 20)) # an INDICATOR on the hourly grid: + # 20 counts hourly candles Requires ``extra_timeframes={"1h": Interval.hours(1)}`` in config. + + .. warning:: + **An indicator applied to** ``h1.close`` **counts SIMULATION bars, not + candles of the higher timeframe.** The column is forward-filled onto the + simulation grid, so an indicator over it counts rows of that grid. + + On 1-minute bars, ``sma(h1.close, 8)`` averages the last 8 *minutes* of + a step function — which is the last closed hourly close, not an 8-hour + average. Measured on a ramp of +10/hour, it lags 1.63 h where a true + 8-hour mean lags 5.45 h. + + Multiplying by the ratio of the two intervals does not fix it either. + ``sma(h1.close, 8 * 60)`` averages 480 rows of the step function: at + every move it ramps in over 60 minutes instead of stepping, and its + window spans 9 hourly values with unequal weights rather than 8 with + equal ones. Measured against a true 8-hour mean on an impulse (one hour + at 200, base 100, so the true signal spans 12.5): the error reaches + 12.29, or 98 % of that span. A ramp cannot reveal this — a box filter + leaves a straight line straight — which is why a lag measurement alone + reads correct. + + Use :meth:`TimeframeRef.apply`, which evaluates on the hourly grid and + then step-holds the result. On that same impulse it matches the true + 8-hour mean exactly, on every bar:: + + sma(h1.close, 8) # 8 minutes of a step (gap 12.29) + sma(h1.close, 8 * 60) # 480 minutes of a step (gap 12.29) + h1.apply(sma(close, 8)) # the 8-hour mean (gap 0.00) + + The ~1 h of lag common to all three is the timeframe itself: an hourly + bar is only readable once closed, which is what makes it free of + look-ahead. """ return TimeframeRef(timeframe) diff --git a/python/manifoldbt/plot/_convert.py b/python/manifoldbt/plot/_convert.py index 24b8063..8b6e501 100644 --- a/python/manifoldbt/plot/_convert.py +++ b/python/manifoldbt/plot/_convert.py @@ -101,3 +101,77 @@ def trades_arrays(result) -> dict: else: out[name] = arrow_to_numpy(col) return out + + +def run_currency(result) -> str: + """Currency the run is denominated in, from its manifest. + + The manifest embeds the full BacktestConfig, so nothing is guessed. "USD" + is only the last resort for a result that has no manifest at all (mock + objects in tests). + """ + try: + code = result.manifest["config"]["currency"] + return str(code) if code else "USD" + except Exception: + return "USD" + + +_CURRENCY_PREFIX = {"USD": "$", "EUR": "€", "GBP": "£"} + + +def money_hovertemplate(values: np.ndarray, currency: str) -> str: + """Hover template for a money series, currency- and magnitude-aware. + + The old template was a hardcoded "$%{y:,.0f}": a 10-BTC equity hovered as + "$10" - wrong currency, and a precision that erased every variation the + chart existed to show. Decimals follow the magnitude of the series, and + the currency is written as a symbol when it has one, as a suffix code + (10.0443 BTC) when it does not. + """ + peak = float(np.nanmax(np.abs(values))) if len(values) else 0.0 + decimals = 0 if peak >= 10_000 else 2 if peak >= 100 else 4 + code = (currency or "USD").upper() + amount = "%{y:,." + str(decimals) + "f}" + prefix = _CURRENCY_PREFIX.get(code) + amount = prefix + amount if prefix else amount + " " + code + return "%{x|%d %b %Y} " + amount + "" + + +def date_tickformat(dates: np.ndarray) -> str: + """Date-axis tick format adapted to the span of the series. + + Hardcoding "%b %Y" labelled every tick of a two-month backtest "May 2025" + (and every tick of a 30-day synthetic run "Jan 2024"): the format must + follow the span, not assume it. Thresholds are where the coarser format + stops producing distinct labels for ~6 ticks. + """ + if len(dates) < 2: + return "%b %Y" + span_days = float( + (np.datetime64(dates[-1], "ns") - np.datetime64(dates[0], "ns")) + / np.timedelta64(1, "D") + ) + if span_days <= 3: + return "%d %b %H:%M" + if span_days <= 180: + return "%d %b" + # Beyond ~6 months the historical "%b %Y" is already distinct per tick, + # whatever the span: plotly widens the tick spacing with the range. Only + # the short end was ever broken. + return "%b %Y" + + +def percent_tickformat(magnitude: float) -> str: + """Percent-axis tick format adapted to the magnitude of the series. + + The date-axis disease, on the value axis: ".0%" labelled every tick of a + -0.9% max-drawdown chart "0%". Decimals follow the extreme value, so the + ticks always spell out distinct numbers. + """ + m = abs(float(magnitude)) + if m >= 0.05: + return ".0%" + if m >= 0.005: + return ".1%" + return ".2%" diff --git a/python/manifoldbt/plot/backtest.py b/python/manifoldbt/plot/backtest.py index 4a06a6c..4e0d4fd 100644 --- a/python/manifoldbt/plot/backtest.py +++ b/python/manifoldbt/plot/backtest.py @@ -25,6 +25,10 @@ from manifoldbt.plot._convert import ( positions_arrays, trades_arrays, _ts_to_int64, + date_tickformat, + percent_tickformat, + money_hovertemplate, + run_currency, ) from manifoldbt.plot._decimate import maybe_decimate from manifoldbt.plot._utils import finalize, format_pct, new_figure @@ -277,10 +281,10 @@ def equity( dates, values = maybe_decimate(dates, values) fig.add_traces(_area_traces( dates, values, float(values.min()), color, width=1.5, - hovertemplate="%{x|%d %b %Y} $%{y:,.0f}", + hovertemplate=money_hovertemplate(values, run_currency(result)), )) fig.update_yaxes(title_text="Equity") - fig.update_xaxes(tickformat="%b %Y") + fig.update_xaxes(tickformat=date_tickformat(dates)) return finalize(fig, show=show, save=save) @@ -324,7 +328,7 @@ def benchmark_equity( line=dict(color=benchmark_color, width=1.0), )) fig.update_yaxes(title_text="Normalized" if normalize else "Equity") - fig.update_xaxes(tickformat="%b %Y") + fig.update_xaxes(tickformat=date_tickformat(d1)) fig.update_layout(legend=dict(x=0.01, y=0.99)) return finalize(fig, show=show, save=save) @@ -357,9 +361,10 @@ def drawdown( hovertemplate="%{x|%d %b %Y} %{y:.1%}", )) dd_min = float(dd.min()) if len(dd) else -0.01 - fig.update_yaxes(title_text="Drawdown", tickformat=".0%", + fig.update_yaxes(title_text="Drawdown", + tickformat=percent_tickformat(dd_min), range=[dd_min * 1.08, 0]) - fig.update_xaxes(tickformat="%b %Y") + fig.update_xaxes(tickformat=date_tickformat(dates)) return finalize(fig, show=show, save=save) diff --git a/python/tests/test_choice.py b/python/tests/test_choice.py new file mode 100644 index 0000000..d765b70 --- /dev/null +++ b/python/tests/test_choice.py @@ -0,0 +1,140 @@ +"""Tests for bt.choice() — sweeping a CHOICE of expression, not just a number. + +The contract under test: `choice("sel", {...})` resolves to exactly one branch +per combo BEFORE simulation, so a sweep over the selector must be +bit-identical to running each branch inlined by hand. The selector must count +as a declared parameter (otherwise `_validate_swept_params` would reject the +sweep), and an unknown branch must fail with a message naming the known ones. +""" +import json +import os + +import pytest + +import manifoldbt as bt +from manifoldbt.indicators import close, sma + +pd = pytest.importorskip("pandas") +np = pytest.importorskip("numpy") + +N_BARS = 3_000 + + +def _store(tmp_path): + ts = pd.date_range("2022-01-01", periods=N_BARS, freq="1min", tz="UTC") + rng = np.random.default_rng(11) + px = 100.0 + np.cumsum(np.sin(np.arange(N_BARS) / 90.0) * 0.3 + rng.normal(0, 0.2, N_BARS)) * 0.05 + px = np.maximum(px, 1.0) + df = pd.DataFrame( + { + "timestamp": ts, + "open": px, + "high": px * 1.0005, + "low": px * 0.9995, + "close": px, + "volume": [1000.0] * N_BARS, + } + ) + root = tmp_path / "choice_store" + return bt.import_dataframe( + df, + symbol="ZC", + symbol_id=1, + interval="1m", + asset_class="equity", + exchange="TEST", + data_root=str(root / "data"), + metadata_db=str(root / "metadata.sqlite"), + ) + + +def _config(): + start, end = bt.time_range("2022-01-01", "2022-01-03") + cfg = bt.BacktestConfig( + universe=[1], + time_range_start=start, + time_range_end=end, + initial_capital=10_000.0, + provider="TEST", + bar_interval=bt.Interval.minutes(1), + symbol_names={"ZC": 1}, + ) + cfg.warmup_bars = 0 + return cfg + + +def _strategy_with_choice(): + band = bt.choice( + "pick", + { + "fast": sma(close, 5), + "slow": sma(close, 20), + }, + ) + return ( + bt.Strategy.create("choice_e2e") + .signal("band", band) + .size(bt.when(close > bt.col("band"), 1.0, 0.0)) + ) + + +def _strategy_inlined(period): + return ( + bt.Strategy.create(f"inline_{period}") + .signal("band", sma(close, period)) + .size(bt.when(close > bt.col("band"), 1.0, 0.0)) + ) + + +def test_serializes_as_ordered_pairs(): + """serde expects Choice(String, Vec<(String, Expr)>): a list of pairs, + order preserved — the first branch is the compile-time default.""" + e = bt.choice("pick", {"a": close, "b": sma(close, 3)}) + payload = json.loads(json.dumps(e.to_json())) + assert list(payload) == ["Choice"] + name, branches = payload["Choice"] + assert name == "pick" + assert [k for k, _ in branches] == ["a", "b"] + + +def test_empty_branches_rejected(): + with pytest.raises(ValueError, match="at least one branch"): + bt.choice("pick", {}) + + +def test_selector_counts_as_declared_parameter(): + """Sweeping the selector must pass strategy-side validation: choice() + declares it via _param_meta exactly like param() does.""" + strat = _strategy_with_choice() + assert "pick" in (strat.to_json_dict().get("parameters") or {}) + + +def test_sweep_over_choice_matches_inlined_branches(tmp_path): + """The money test: each combo of the selector sweep is bit-identical to + the strategy with that branch written directly.""" + store = _store(tmp_path) + cfg = _config() + + sweep = bt.run_sweep_lite( + _strategy_with_choice(), {"pick": ["fast", "slow"]}, cfg, store, device="cpu" + ) + assert len(sweep) == 2 + + by_branch = dict(zip(["fast", "slow"], sweep)) + for name, period in (("fast", 5), ("slow", 20)): + ref = bt.run_sweep_lite( + _strategy_inlined(period), {}, cfg, store, device="cpu" + )[0] + got, want = by_branch[name].metrics, ref.metrics + for key in ("total_return", "sharpe", "max_drawdown"): + assert got.get(key) == want.get(key), ( + f"branch {name!r}: {key} diverged ({got.get(key)} vs {want.get(key)})" + ) + + +def test_unknown_branch_names_the_known_ones(tmp_path): + store = _store(tmp_path) + with pytest.raises(Exception, match="fast"): + bt.run_sweep_lite( + _strategy_with_choice(), {"pick": ["nope"]}, _config(), store, device="cpu" + ) diff --git a/python/tests/test_lookahead_blind_spot.py b/python/tests/test_lookahead_blind_spot.py new file mode 100644 index 0000000..8bed227 --- /dev/null +++ b/python/tests/test_lookahead_blind_spot.py @@ -0,0 +1,161 @@ +"""The look-ahead the detector cannot see, pinned as a characterization test. + +`detect_lookahead` used to document itself as catching global look-ahead, +"e.g. np.mean(all_prices) instead of rolling". It does not, and it cannot: both +its sub-tests re-run the *same strategy* on a shorter window, so a threshold +computed in a notebook and passed in as a number is identical in every run. + +This file asserts the blind spot on purpose. A test that pins a limitation is +worth more than a docstring promising the opposite, because the docstring was +wrong for as long as nobody tried it. + +It also pins the method that DOES catch it, so the boundary is not just +described but demonstrated: re-derive the parameter on the truncated window and +compare the same prefix. +""" +import os + +import pytest + +import manifoldbt as bt + +np = pytest.importorskip("numpy") +pd = pytest.importorskip("pandas") + +from manifoldbt.helpers import Interval, Slippage # noqa: E402 + +N_DAYS = 400 +SPLIT = 260 + + +def _mean_reverting_daily(): + """A series that pulls back to its mean, where knowing that mean pays.""" + 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)) + return 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(frame, tmp_path, tag): + root = os.path.join(str(tmp_path), tag) + return bt.import_dataframe( + frame, symbol="SYNTH", symbol_id=1, interval="1d", + data_root=os.path.join(root, "data"), + metadata_db=os.path.join(root, "meta.sqlite"), + ) + + +def _config(frame): + ts = pd.DatetimeIndex(frame["timestamp"]) + return bt.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=bt.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 threshold is a number the researcher computed over everything.""" + from manifoldbt.indicators import close + + return ( + bt.Strategy.create("global_mean_leak") + .signal("edge", close) + .size(bt.when(close < mean_price, 1.0, -1.0)) + ) + + +def _equity(result): + return np.array([float(x) for x in result.equity_curve]) + + +def test_a_parameter_baked_at_research_time_flatters_the_result(tmp_path): + """First establish there IS a leak, otherwise the blind spot is moot.""" + from manifoldbt.indicators import close + + frame = _mean_reverting_daily() + store = _store(frame, tmp_path, "seduction") + global_mean = float(frame["close"].mean()) + + leaked = bt.run(_leaky(global_mean), _config(frame), store) + honest = bt.run( + bt.Strategy.create("rolling") + .signal("edge", close) + .size(bt.when(close < close.rolling_mean(60), 1.0, -1.0)), + _config(frame), store, + ) + + assert leaked.metrics["total_return"] > honest.metrics["total_return"], ( + "the global mean did not flatter the result, so this fixture no longer " + "demonstrates a leak worth detecting" + ) + + +def test_the_detector_is_blind_to_it(tmp_path): + """Pinned limitation: PASS here is the documented, expected answer. + + If this ever starts failing, the detector gained the ability to audit a + baked parameter. That would be good news, and the warning in + `detect_lookahead`'s docstring should be revisited rather than this test + silenced. + """ + from manifoldbt.diagnostics import detect_lookahead + + frame = _mean_reverting_daily() + result = detect_lookahead( + _leaky(float(frame["close"].mean())), + _config(frame), _store(frame, tmp_path, "blind"), mode="all", + ) + + compared = sum(r.total_trades_overlap for r in result.reports) + assert compared > 0, "empty verdict, the blind spot is not what is being shown" + assert result.passed, ( + "the detector now catches a research-time constant; update the docstring " + "warning instead of deleting this test" + ) + + +def test_re_deriving_the_parameter_catches_it(tmp_path): + """The technique that works, and the reason the blind spot is acceptable. + + Same window, same strategy shape: only the threshold differs, one computed + with the future and one without. The equity must diverge. + """ + frame = _mean_reverting_daily() + truncated = frame.iloc[:SPLIT + 1] + + with_future = _equity(bt.run( + _leaky(float(frame["close"].mean())), # knows all 400 days + _config(truncated), _store(truncated, tmp_path, "future"), + )) + with_past = _equity(bt.run( + _leaky(float(truncated["close"].mean())), # knows only the first 261 + _config(truncated), _store(truncated, tmp_path, "past"), + )) + + n = min(len(with_future), len(with_past)) + assert n > 100, f"only {n} bars compared, too few to conclude" + gap = float(np.abs(with_future[:n] - with_past[:n]).max()) + assert gap > 0.0, ( + "re-deriving the threshold changed nothing, so this method would not " + "catch the leak either" + ) diff --git a/python/tests/test_lookahead_custom_exec.py b/python/tests/test_lookahead_custom_exec.py new file mode 100644 index 0000000..f5397aa --- /dev/null +++ b/python/tests/test_lookahead_custom_exec.py @@ -0,0 +1,285 @@ +"""Anti-look-ahead tests for `ExecutionPrice.custom(...)` with `signal_delay=0`. + +This is the configuration of `examples/21_fill_at_computed_level.py`, and the +one with the most room for leakage in the whole engine: the fill price is read +from a strategy signal, the order acts on the same bar it was computed on, and +the level itself comes from a higher timeframe. Three chances for a bar to be +priced with information it could not have had. + +Two independent methods, because a single one can pass for the wrong reason: + + * **future perturbation** — corrupt every bar after K, re-run, and require + the equity of bars 0..K to be *bit-identical*. Any decision that read a + future bar moves the prefix. + * **the engine's own detector** — `detect_lookahead`, which compares the + trades of truncated runs against the full run. + +Both carry an anti-vacuity guard. A look-ahead test that compares nothing +passes just as loudly as one that compares everything, which is exactly how +the built-in detector reports PASS when its splits fall outside the data. +""" +import os +import tempfile + +import pytest + +import manifoldbt as bt + +np = pytest.importorskip("numpy") +pd = pytest.importorskip("pandas") + +from manifoldbt.helpers import ExecutionPrice, Interval, Slippage # noqa: E402 + +# Three days of 1-minute bars: enough for the hourly SMA to have a history, +# small enough not to weigh on the suite. +N_BARS = 3 * 1440 +SPLIT = 2 * 1440 # perturb everything after this bar + + +def _mean_reverting_bars(seed=7): + """The construction of example 21, at a size a test can afford.""" + rng = np.random.default_rng(seed) + steps = rng.normal(0.0, 0.0010, N_BARS) + level = np.cumsum(steps) * 0.85 + px = 100.0 * np.exp(level - np.linspace(0, level[-1], N_BARS)) + o = px + c = np.roll(px, -1) + c[-1] = px[-1] + amp = np.abs(rng.normal(0.0, 0.0012, N_BARS)) + return pd.DataFrame({ + "timestamp": pd.date_range("2024-01-01", periods=N_BARS, freq="1min", 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_BARS), + }) + + +def _band_strategy(): + """Short the upper band, cover the lower one, filling ON the band.""" + from manifoldbt.indicators import close, high, low, open as open_px, sma + + h1 = bt.tf("1h") + band_up = sma(h1.close, 8) * 1.004 + band_dn = sma(h1.close, 8) * 0.997 + touch_up = high >= band_up + touch_dn = low <= band_dn + target = bt.when(touch_dn, 0.0, bt.when(touch_up, -1.0)) + exec_level = bt.when( + touch_dn, bt.when(open_px <= band_dn, open_px, band_dn), + bt.when(touch_up, bt.when(open_px >= band_up, open_px, band_up), close), + ) + return ( + bt.Strategy.create("band_touch_short") + .signal("position", target) + .signal("exec_level", exec_level) + .size(target) + .stop_loss(pct=25.0) + ) + + +def _store(frame, tmp_path, tag): + root = os.path.join(str(tmp_path), tag) + return bt.import_dataframe( + frame, symbol="SYNTH", symbol_id=1, interval="1m", + data_root=os.path.join(root, "data"), + metadata_db=os.path.join(root, "meta.sqlite"), + ) + + +def _config(frame): + """A time range bounded by the DATA, not by the epoch. + + `time_range_start=0` would stretch the range over 54 years, which is what + makes the built-in detector split outside the data and pass vacuously. + """ + ts = pd.DatetimeIndex(frame["timestamp"]) + return bt.BacktestConfig( + universe=[1], + time_range_start=int(ts[0].value), + time_range_end=int(ts[-1].value) + 60_000_000_000, + bar_interval=Interval.minutes(1), + initial_capital=10_000, + execution=bt.ExecutionConfig( + signal_delay=0, + execution_price=ExecutionPrice.custom("exec_level"), + max_position_pct=0.4, + allow_short=True, + position_sizing_mode="FractionOfEquity", + ), + fees=bt.FeeConfig.binance_perps(), + slippage=Slippage.fixed_bps(2), + warmup_bars=60 * 4, + extra_timeframes={"1h": Interval.hours(1)}, + ) + + +def _equity(result): + return np.array([float(x) for x in result.equity_curve]) + + +def test_future_bars_cannot_move_the_past(tmp_path): + """The decisive test: corrupt the future, the past must not budge.""" + frame = _mean_reverting_bars() + strategy = _band_strategy() + config = _config(frame) + + reference = _equity(bt.run(strategy, _config(frame), _store(frame, tmp_path, "ref"))) + + # Same multiplicative factor on all four price columns, so the bars stay + # valid (high >= max(open, close), low <= min(open, close)). Small enough + # that the short strategy survives it: a 3x future turns the equity + # negative and the run refuses, which would prove nothing. + rng = np.random.default_rng(1234) + corrupted = frame.copy() + tail = slice(SPLIT + 1, None) + factor = 1.0 + rng.uniform(-0.005, 0.005, N_BARS - SPLIT - 1) + for col in ("open", "high", "low", "close"): + corrupted.loc[corrupted.index[tail], col] = corrupted[col].to_numpy()[tail] * factor + + perturbed = _equity(bt.run(strategy, config, _store(corrupted, tmp_path, "pert"))) + + # Anti-vacuity: if the corruption changed nothing at all, an identical + # prefix would be meaningless. + assert abs(reference[-1] - perturbed[-1]) > 1e-6, ( + "the perturbation left the future untouched; the test would be vacuous" + ) + + n = min(len(reference), len(perturbed), SPLIT + 1) + assert n > 1000, f"only {n} bars compared, too few to conclude" + delta = np.abs(reference[:n] - perturbed[:n]) + first = int(np.argmax(delta > 0)) if delta.max() > 0 else -1 + assert delta.max() == 0.0, ( + f"future data leaked into the past: bar {first} differs by {delta.max():.3e}" + ) + + +def test_builtin_detector_agrees_and_is_not_vacuous(tmp_path): + """The engine's own detector, plus a check that it compared something. + + `trades=0, mismatched=0` is reported as PASS. Asserting only on `.passed` + would accept that empty verdict. + """ + from manifoldbt.diagnostics import detect_lookahead + + frame = _mean_reverting_bars() + result = detect_lookahead( + _band_strategy(), _config(frame), _store(frame, tmp_path, "det"), mode="all" + ) + + assert result.passed, f"look-ahead reported: {result}" + compared = sum(r.total_trades_overlap for r in result.reports) + assert compared > 0, ( + f"the detector compared no trade at all, its PASS is empty: {result.reports}" + ) + + +def test_every_fill_lands_on_a_level_known_before_the_bar(tmp_path): + """No fill may be priced at its own bar's close. + + A fill at the close is only knowable once the bar is over. It is also what + the example would produce if `custom(...)` silently fell back to AtClose, + which would make the whole feature a no-op. + """ + frame = _mean_reverting_bars() + result = bt.run(_band_strategy(), _config(frame), _store(frame, tmp_path, "fills")) + + trades = result.trades_df() + assert len(trades) > 20, f"only {len(trades)} trades, too few to conclude" + + bars = frame.set_index(pd.DatetimeIndex(frame["timestamp"])) + at = bars.reindex(pd.DatetimeIndex(pd.to_datetime(trades["execution_timestamp"], utc=True))) + intended = trades["intended_price"].to_numpy() + + on_close = np.isclose(intended, at["close"].to_numpy(), rtol=0, atol=1e-12) + assert not on_close.any(), ( + f"{int(on_close.sum())} fill(s) landed on their bar's close, " + "which is AtClose behaviour, not a computed level" + ) + + +def _leaking_strategy(): + """The clean strategy, with one deliberate leak: the entry reads ahead.""" + from manifoldbt.indicators import close, low, open as open_px, sma + + h1 = bt.tf("1h") + base = sma(h1.close, 8) + band_up = base * 1.004 + band_dn = base * 0.997 + touch_up = close.lead(5) >= band_up # <- the leak + touch_dn = low <= band_dn + target = bt.when(touch_dn, 0.0, bt.when(touch_up, -1.0)) + exec_level = bt.when( + touch_dn, bt.when(open_px <= band_dn, open_px, band_dn), + bt.when(touch_up, bt.when(open_px >= band_up, open_px, band_up), close), + ) + return ( + bt.Strategy.create("leaky") + .signal("position", target) + .signal("exec_level", exec_level) + .size(target) + .stop_loss(pct=25.0) + ) + + +def test_the_perturbation_method_catches_a_real_leak(tmp_path): + """A look-ahead test that cannot fail proves nothing. + + Same data, same perturbation, same comparison as + :func:`test_future_bars_cannot_move_the_past` -- only the strategy reads + five bars ahead. The prefix MUST diverge, or the method above is blind and + its PASS is worthless. + """ + frame = _mean_reverting_bars() + strategy = _leaking_strategy() + config = _config(frame) + + reference = _equity(bt.run(strategy, config, _store(frame, tmp_path, "leak_ref"))) + + rng = np.random.default_rng(1234) + corrupted = frame.copy() + tail = slice(SPLIT + 1, None) + factor = 1.0 + rng.uniform(-0.005, 0.005, N_BARS - SPLIT - 1) + for col in ("open", "high", "low", "close"): + corrupted.loc[corrupted.index[tail], col] = corrupted[col].to_numpy()[tail] * factor + + perturbed = _equity(bt.run(strategy, config, _store(corrupted, tmp_path, "leak_pert"))) + + n = min(len(reference), len(perturbed), SPLIT + 1) + delta = np.abs(reference[:n] - perturbed[:n]) + assert delta.max() > 0.0, ( + "a strategy reading 5 bars ahead went undetected: the perturbation " + "method is blind and every PASS in this file is meaningless" + ) + # The divergence must sit just before the split, where the lead reaches + # into the corrupted tail -- not somewhere unrelated. + first = int(np.argmax(delta > 0)) + assert SPLIT - 60 <= first <= SPLIT, ( + f"divergence at bar {first}, expected it near the split at {SPLIT}" + ) + + +def test_detector_splits_on_the_data_not_on_the_configured_range(tmp_path): + """A config starting at the epoch must not empty the detector. + + `time_range_start=0` is what `examples/21_fill_at_computed_level.py` + writes, and it stretches the period over five decades: both split points + used to land before the first bar, so both truncated runs saw no data and + the detector announced PASS having compared nothing. + """ + from manifoldbt.diagnostics import detect_lookahead + + frame = _mean_reverting_bars() + config = _config(frame) + config.time_range_start = 0 # the epoch, as the example does + + result = detect_lookahead( + _band_strategy(), config, _store(frame, tmp_path, "epoch"), mode="all" + ) + + compared = sum(r.total_trades_overlap for r in result.reports) + assert compared > 0, ( + "the detector compared no trade: its splits fell outside the data again" + ) + assert result.passed, f"look-ahead reported: {result}" diff --git a/python/tests/test_mtf_indicator_period.py b/python/tests/test_mtf_indicator_period.py new file mode 100644 index 0000000..9c04004 --- /dev/null +++ b/python/tests/test_mtf_indicator_period.py @@ -0,0 +1,214 @@ +"""Indicator periods over a higher timeframe count SIMULATION bars. + +`bt.tf("1h").close` is the last closed hourly close, forward-filled onto the +simulation grid. An indicator over it counts rows of that grid, so on 1-minute +bars `sma(h1.close, 8)` averages 8 *minutes* of a step function — it tracks the +last closed hourly close instead of averaging 8 hours. + +That reading is surprising enough that `tf()`'s own usage example used to show +`ema(h1.close, 20)` as if 20 meant hours. These tests pin the real semantics +and the conversion, measured rather than argued: on a ramp of +10 per hour, the +lag of an average over K hours is (K+1)/2 hours, plus the ~1 h the timeframe +itself costs (an hourly bar is only readable once closed). +""" +import os + +import pytest + +import manifoldbt as bt + +np = pytest.importorskip("numpy") +pd = pytest.importorskip("pandas") + +from manifoldbt.helpers import ExecutionPrice, Interval, Slippage # noqa: E402 + +HOURS, PER_HOUR = 200, 60 +N_BARS = HOURS * PER_HOUR +SLOPE = 10.0 # the price gains exactly this much per hour + + +def _ramp_bars(): + """Hourly closes of 100, 110, 120 … so a lag reads directly as hours.""" + ts = pd.date_range("2024-01-01", periods=N_BARS, freq="1min", tz="UTC") + px = 100.0 + SLOPE * (np.arange(N_BARS) // PER_HOUR) + return pd.DataFrame({ + "timestamp": ts, "open": px, "high": px * 1.5, "low": px * 0.5, + "close": px, "volume": np.full(N_BARS, 1000.0), + }) + + +def _observed_series(period_or_expr, frame, tmp_path, tag): + """The values a higher-timeframe band actually took, by timestamp. + + Takes either a period — read as `sma(tf("1h").close, period)`, the + staircase form — or a ready-made expression, so the same probe serves both + candidates. + + The indicator is read back through `ExecutionPrice.custom`, which fills at + the value of a named signal: the trade log then carries the series itself. + """ + from manifoldbt.indicators import close, sma + from manifoldbt.expr import Expr + + root = os.path.join(str(tmp_path), tag) + store = bt.import_dataframe( + frame, symbol="S", symbol_id=1, interval="1m", + data_root=os.path.join(root, "data"), + metadata_db=os.path.join(root, "meta.sqlite"), + ) + band = (period_or_expr if isinstance(period_or_expr, Expr) + else sma(bt.tf("1h").close, period_or_expr)) + strategy = ( + bt.Strategy.create("probe") + .signal("band", band) + .size(bt.when(close > sma(close, 3), 1.0, -1.0)) + ) + ts = pd.DatetimeIndex(frame["timestamp"]) + config = bt.BacktestConfig( + universe=[1], + time_range_start=int(ts[0].value), + time_range_end=int(ts[-1].value) + 60_000_000_000, + bar_interval=Interval.minutes(1), + initial_capital=1_000_000, + execution=bt.ExecutionConfig( + signal_delay=0, + execution_price=ExecutionPrice.custom("band"), + max_position_pct=0.1, allow_short=True, + position_sizing_mode="FractionOfEquity", + ), + slippage=Slippage.fixed_bps(0), + warmup_bars=700, # clears the widest window under test + extra_timeframes={"1h": Interval.hours(1)}, + ) + result = bt.run(strategy, config, store) + trades = result.trades_df() + assert len(trades) > 50, f"only {len(trades)} trades, too few to measure a lag" + + at = pd.DatetimeIndex(pd.to_datetime(trades["execution_timestamp"], utc=True)) + return pd.Series(trades["intended_price"].to_numpy(), index=at) + + +def _observed_lag_hours(period, frame, tmp_path, tag): + """Average lag of `sma(tf("1h").close, period)`, in hours.""" + observed = _observed_series(period, frame, tmp_path, tag) + ts = pd.DatetimeIndex(frame["timestamp"]) + price_now = pd.Series(frame["close"].to_numpy(), index=ts).reindex(observed.index) + return float(np.median((price_now.to_numpy() - observed.to_numpy()) / SLOPE)) + + +def test_a_bare_period_does_not_average_the_higher_timeframe(tmp_path): + """`sma(h1.close, 8)` is NOT an 8-hour mean. + + An 8-hour mean would lag about (8+1)/2 + 1 = 5.5 hours. This lags under 2, + which is the timeframe's own delay: it is tracking the last closed hourly + close, not averaging eight of them. + """ + lag = _observed_lag_hours(8, _ramp_bars(), tmp_path, "bare") + assert lag < 2.5, f"lag {lag:.2f} h — this would be a real multi-hour average" + assert lag > 0.5, f"lag {lag:.2f} h — the timeframe delay itself is missing" + + +def test_multiplying_the_period_by_the_interval_ratio_matches_the_lag(tmp_path): + """`sma(h1.close, K * 60)` carries the LAG of a K-hour mean. + + Expected lag: (K+1)/2 from the average, plus ~1 h for the timeframe. This + is the whole of what a ramp can establish, and it is not enough to call the + result a K-hour mean: see `test_the_interval_ratio_is_not_the_hourly_mean`, + which uses an impulse to show the two series apart. + """ + frame = _ramp_bars() + for hours, expected in ((4, 1 + (4 + 1) / 2), (8, 1 + (8 + 1) / 2)): + lag = _observed_lag_hours(hours * PER_HOUR, frame, tmp_path, f"k{hours}") + assert abs(lag - expected) < 0.5, ( + f"{hours}-hour mean lags {lag:.2f} h, expected about {expected:.2f} h" + ) + + +def _impulse_bars(): + """Hourly closes flat at 100 but for ONE hour at 200. + + A ramp cannot separate the two candidates: a box filter leaves a straight + line straight, so a biased weighting still reports the right lag. An + impulse makes each hour's weight readable in the value itself. + + The last minute of every hour keeps the base value, so the hourly closes -- + the only rows `tf("1h")` reads -- stay exactly 100 or 200. The intra-hour + zigzag exists only to make the probe strategy trade on every bar. + """ + n = 60 * PER_HOUR + ts = pd.date_range("2024-01-01", periods=n, freq="1min", tz="UTC") + base = np.where(np.arange(n) // PER_HOUR == 30, 200.0, 100.0) + minute = np.arange(n) % PER_HOUR + px = base + np.where(minute == PER_HOUR - 1, 0.0, np.where(minute % 2 == 0, 0.5, -0.5)) + return pd.DataFrame({ + "timestamp": ts, "open": px, "high": px * 1.5, "low": px * 0.5, + "close": px, "volume": np.full(n, 1000.0), + }) + + +def test_the_interval_ratio_is_not_the_hourly_mean(tmp_path): + """`sma(h1.close, 8 * 60)` is not an equal-weight mean of 8 hourly closes. + + It averages 480 rows of a step function, so a move ramps in over 60 minutes + instead of stepping, and the window spans 9 hourly values with unequal + weights rather than 8 with equal ones. On the impulse the true signal spans + 12.5 (100 to 112.5) and the gap reaches nearly all of it. + """ + frame = _impulse_bars() + observed = _observed_series(8 * PER_HOUR, frame, tmp_path, "impulse") + + ts = pd.DatetimeIndex(frame["timestamp"]) + hourly = pd.Series(frame["close"].to_numpy(), index=ts).resample("1h").last() + assert set(np.round(hourly.dropna().unique(), 6)) <= {100.0, 200.0} + truth = hourly.rolling(8).mean().shift(1).reindex(ts, method="ffill") + + gap = (observed - truth.reindex(observed.index)).dropna().abs() + assert gap.max() > 10.0, ( + f"largest gap to a true 8-hour mean is {gap.max():.2f} on a signal " + "spanning 12.5; the two would then be the same series" + ) + + +def test_apply_is_the_hourly_mean_and_reads_only_closed_bars(tmp_path): + """`h1.apply(sma(close, 8))` IS the mean of 8 hourly closes — exactly. + + Same impulse that separates the two staircase forms. Two alignments are + checked against, and they disagree on 120 bars, so matching one excludes + the other: the shifted reference reads only CLOSED hours, the unshifted one + would need the hour in progress. Landing on the shifted one is what rules + out look-ahead. + """ + from manifoldbt.indicators import close, sma + + frame = _impulse_bars() + band = bt.tf("1h").apply(sma(close, 8)) + observed = _observed_series(band, frame, tmp_path, "apply") + + ts = pd.DatetimeIndex(frame["timestamp"]) + hourly = pd.Series(frame["close"].to_numpy(), index=ts).resample("1h").last() + rolled = hourly.rolling(8).mean() + safe = rolled.shift(1).reindex(ts, method="ffill") # closed hours only + leaking = rolled.reindex(ts, method="ffill") # the hour in progress + + disagree = (safe - leaking).dropna().abs() + assert (disagree > 1e-9).sum() > 50, "the two alignments must differ to discriminate" + + gap_safe = (observed - safe.reindex(observed.index)).dropna().abs() + gap_leak = (observed - leaking.reindex(observed.index)).dropna().abs() + assert gap_safe.max() < 1e-9, ( + f"apply() is off the true 8-hour mean by {gap_safe.max():.4f}" + ) + assert gap_leak.max() > 1.0, ( + "apply() matches the alignment that reads the hour in progress" + ) + + +def test_a_longer_period_lags_more(tmp_path): + """The ordering alone would catch a period silently ignored.""" + frame = _ramp_bars() + short = _observed_lag_hours(4 * PER_HOUR, frame, tmp_path, "ord4") + long = _observed_lag_hours(8 * PER_HOUR, frame, tmp_path, "ord8") + assert long > short + 1.0, ( + f"8-hour mean lags {long:.2f} h vs {short:.2f} h for 4 hours; " + "the period is not doing what it should" + ) diff --git a/python/tests/test_on_timeframe.py b/python/tests/test_on_timeframe.py new file mode 100644 index 0000000..924d756 --- /dev/null +++ b/python/tests/test_on_timeframe.py @@ -0,0 +1,205 @@ +"""Tests for tf(..).apply(..) — indicators evaluated ON the higher timeframe. + +The defect this feature fixes, pinned by `test_apply_differs_from_staircase`: +an indicator over a step-held `tf()` column counts its period in SIMULATION +bars, so `sma(tf("1h").close, 20)` on a 1m simulation is a 20-MINUTE smoothing +of an hourly staircase — mid-hour it equals the previous hourly close exactly. +`tf("1h").apply(sma(close, 20))` is the true 20-HOUR average. + +The reference implementation (`_hand_band`) is the exo-column recipe users had +to build by hand before this feature: resample to 1h in pandas, indicator on +the 1h grid, shift(1) (a closed bar is readable from the next bar on), ffill +onto the 1m grid. `test_apply_matches_hand_rolled_exo` demands bit-identical +metrics against it. +""" +import os + +import pytest + +import manifoldbt as bt +from manifoldbt.indicators import close, sma + +pd = pytest.importorskip("pandas") +np = pytest.importorskip("numpy") + +N_BARS = 20_000 # ~13.9 days of 1m bars -> ~333 hourly bars +PERIOD = 20 + + +def _bars_df(): + ts = pd.date_range("2022-01-01", periods=N_BARS, freq="1min", tz="UTC") + rng = np.random.default_rng(3) + px = 100.0 + np.cumsum(np.sin(np.arange(N_BARS) / 700.0) * 0.5 + rng.normal(0, 0.4, N_BARS)) * 0.01 + px = np.maximum(px, 1.0) + return pd.DataFrame( + { + "timestamp": ts, + "open": px, + "high": px * 1.0005, + "low": px * 0.9995, + "close": px, + "volume": [1000.0] * N_BARS, + } + ) + + +def _store(tmp_path, df): + root = tmp_path / "otf_store" + return bt.import_dataframe( + df, + symbol="ZT", + symbol_id=1, + interval="1m", + asset_class="equity", + exchange="TEST", + data_root=str(root / "data"), + metadata_db=str(root / "metadata.sqlite"), + ), str(root / "data") + + +def _hand_band(df, period): + """The pre-feature recipe: hourly SMA built by hand, no lookahead.""" + h1 = df.set_index("timestamp")["close"].resample("1h").last() + band = h1.rolling(period).mean().shift(1) + return band.reindex(df["timestamp"], method="ffill") + + +def _config(tmp_path=None, exo=False): + start, end = bt.time_range("2022-01-01", "2022-01-14") + cfg = bt.BacktestConfig( + universe=[1], + time_range_start=start, + time_range_end=end, + initial_capital=10_000.0, + provider="TEST", + bar_interval=bt.Interval.minutes(1), + symbol_names={"ZT": 1}, + extra_timeframes={} if exo else {"1h": bt.Interval.hours(1)}, + exo_data=["hand_band"] if exo else [], + ) + cfg.warmup_bars = 0 + return cfg + + +def _strategy(band_expr, name): + return ( + bt.Strategy.create(name) + .signal("band", band_expr) + .size(bt.when(close > bt.col("band"), 1.0, 0.0)) + ) + + +def test_serializes_as_on_timeframe(): + e = bt.tf("1h").apply(sma(close, PERIOD)) + payload = e.to_json() + assert list(payload) == ["OnTimeframe"] + label, inner = payload["OnTimeframe"] + assert label == "1h" + assert list(inner) == ["RollingMean"] + + +def test_apply_matches_hand_rolled_exo(tmp_path): + """The money test: tf("1h").apply(sma(close, 20)) must be bit-identical to + the hand-precomputed hourly-SMA exo column it replaces.""" + df = _bars_df() + store, data_root = _store(tmp_path, df) + + band = _hand_band(df, PERIOD) + bt.register_exo( + "hand_band", + pd.DataFrame({"timestamp": df["timestamp"], "value": band.values}), + data_root=data_root, + ) + + native = bt.run( + _strategy(bt.tf("1h").apply(sma(close, PERIOD)), "native"), + _config(), + store, + ) + hand = bt.run( + _strategy(bt.exo("hand_band", "value"), "hand"), + _config(exo=True), + store, + ) + + for key in ("total_return", "sharpe", "max_drawdown", "volatility"): + assert native.metrics.get(key) == hand.metrics.get(key), ( + f"{key}: native {native.metrics.get(key)} != hand {hand.metrics.get(key)}" + ) + assert len(native.trades_df()) == len(hand.trades_df()) + + +def test_apply_differs_from_staircase(tmp_path): + """Guard against regressing to the old semantics: the staircase version + (indicator over the step-held tf() column) must NOT equal apply().""" + df = _bars_df() + store, _ = _store(tmp_path, df) + + applied = bt.run( + _strategy(bt.tf("1h").apply(sma(close, PERIOD)), "applied"), + _config(), + store, + ) + staircase = bt.run( + _strategy(sma(bt.tf("1h").close, PERIOD), "staircase"), + _config(), + store, + ) + assert applied.metrics.get("total_return") != staircase.metrics.get("total_return"), ( + "apply() and the staircase smoothing agreed; the coarse-grid evaluation " + "is not actually happening" + ) + + +def test_swept_period_matches_fixed_runs(tmp_path): + """param() INSIDE apply(): each combo must equal the fixed-period run.""" + df = _bars_df() + store, _ = _store(tmp_path, df) + periods = [10, 20, 40] + + sweep = bt.run_sweep_lite( + _strategy(bt.tf("1h").apply(sma(close, bt.param("len"))), "swept"), + {"len": periods}, + _config(), + store, + device="cpu", + ) + assert len(sweep) == len(periods) + + for got, period in zip(sweep, periods): + ref = bt.run_sweep_lite( + _strategy(bt.tf("1h").apply(sma(close, period)), f"fixed_{period}"), + {}, + _config(), + store, + device="cpu", + )[0] + for key in ("total_return", "sharpe", "max_drawdown"): + assert got.metrics.get(key) == ref.metrics.get(key), ( + f"len={period}: {key} diverged" + ) + + +def test_lite_matches_run(tmp_path): + df = _bars_df() + store, _ = _store(tmp_path, df) + strat = _strategy(bt.tf("1h").apply(sma(close, PERIOD)), "parity") + + full = bt.run(strat, _config(), store) + lite = bt.run_sweep_lite(strat, {}, _config(), store, device="cpu")[0] + for key in ("total_return", "sharpe"): + assert full.metrics.get(key) == lite.metrics.get(key), f"{key} diverged" + # max_drawdown carries a pre-existing ~1e-16 run-vs-lite float-noise gap + # (measured on a plain sma(close, 20) strategy with no OnTimeframe on this + # same data), so exact equality would pin the wrong thing here. + a, b = full.metrics["max_drawdown"], lite.metrics["max_drawdown"] + assert a == pytest.approx(b, rel=1e-12), f"max_drawdown diverged: {a} vs {b}" + + +def test_missing_extra_timeframe_is_a_clear_error(tmp_path): + df = _bars_df() + store, _ = _store(tmp_path, df) + cfg = _config() + cfg.extra_timeframes = {} + with pytest.raises(Exception, match="extra_timeframes"): + bt.run(_strategy(bt.tf("1h").apply(sma(close, PERIOD)), "no_tf"), cfg, store) diff --git a/python/tests/test_options.py b/python/tests/test_options.py new file mode 100644 index 0000000..7713bea --- /dev/null +++ b/python/tests/test_options.py @@ -0,0 +1,365 @@ +"""Tests for the option path from Python: contract terms in, settlement out. + +The Rust side already proves the payoff arithmetic and the simulation loop. +What is under test here is the bridge: terms recorded at ingest must reach the +engine, and the one thing the user has to decide (which price series settles the +contract) must fail loudly when it is missing rather than be guessed. +""" +import os + +import pytest + +import manifoldbt as bt + +pd = pytest.importorskip("pandas") + +OPTION_ID = 2 +UNDERLYING_ID = 1 +STRIKE = 50_000.0 +N_BARS = 40 +# Expiry lands on bar 30 of a 40-bar daily series starting 2020-01-01. +EXPIRY_MS = 1_577_836_800_000 + 30 * 86_400_000 + + +def _daily(prices): + ts = pd.date_range("2020-01-01", periods=len(prices), freq="1D", tz="UTC") + return pd.DataFrame( + { + "timestamp": ts, + "open": prices, + "high": prices, + "low": prices, + "close": prices, + "volume": [100.0] * len(prices), + } + ) + + +def _store(tmp_path, underlying_price, premium, option_class="option"): + """A two-symbol store: a perpetual and a call written against it. + + ``option_class`` exists so a test can write the same series as a plain + linear instrument, which is a different thing from an option missing its + terms. + """ + root = os.path.join(str(tmp_path), "data") + meta = os.path.join(str(tmp_path), "m.sqlite") + store = bt.import_dataframe( + _daily([underlying_price] * N_BARS), + symbol="BTC-PERPETUAL", + symbol_id=UNDERLYING_ID, + interval="1d", + data_root=root, + metadata_db=meta, + asset_class="crypto_perp", + ) + store = bt.import_dataframe( + _daily([premium] * N_BARS), + symbol="BTC-CALL", + symbol_id=OPTION_ID, + interval="1d", + data_root=root, + metadata_db=meta, + asset_class=option_class, + ) + return store, root, meta + + +def _write_terms(meta_db, settlement="cash_inverse"): + """Record contract terms the way an option connector would.""" + import sqlite3 + + conn = sqlite3.connect(meta_db) + conn.execute( + "UPDATE symbols SET option_underlying = ?, option_type = ?, option_strike = ?," + " option_expiry = ?, option_contract_size = ?, option_settlement = ?" + " WHERE id = ?", + ( + "BTC_USD index", + "call", + STRIKE, + pd.Timestamp(EXPIRY_MS, unit="ms", tz="UTC").isoformat().replace("+00:00", "Z"), + 1.0, + settlement, + OPTION_ID, + ), + ) + conn.commit() + conn.close() + + +def _config(**kwargs): + from manifoldbt.helpers import time_range, Interval + + start, end = time_range("2020-01-01", "2020-03-01") + base = dict( + universe=[UNDERLYING_ID, OPTION_ID], + time_range_start=start, + time_range_end=end, + bar_interval=Interval.days(1), + initial_capital=10.0, + currency="BTC", + execution=bt.ExecutionConfig(position_sizing_mode="Units"), + ) + base.update(kwargs) + return bt.BacktestConfig(**base) + + +def _hold(**per_symbol): + """Hold a fixed number of units of each named symbol id, every bar. + + Legs are told apart by `col("symbol_id")` rather than by price level. A + price threshold is a trap: a premium crossing it flips the leg to zero and + the strategy closes its own position, which is exactly how an earlier + version of this file broke. + """ + from manifoldbt.indicators import col + + size = bt.when(col("symbol_id") < 0.0, 0.0, 0.0) # a typed zero to fold onto + for symbol_id, units in per_symbol.items(): + size = size + bt.when(col("symbol_id") == float(symbol_id), float(units), 0.0) + return ( + bt.Strategy.create("hold") + .signal("leg", col("symbol_id")) + .size(size) + .describe("Fixed units per leg, held into expiry") + ) + + +def _long_one_option(): + return _hold(**{str(OPTION_ID): 1.0}) + + +def test_contract_terms_round_trip_to_python(tmp_path): + _, _, meta = _store(tmp_path, 60_000.0, 0.05) + _write_terms(meta) + store = bt.DataStore(os.path.join(str(tmp_path), "data"), meta) + + terms = store.option_contracts() + assert OPTION_ID in terms + assert terms[OPTION_ID]["option_type"] == "call" + assert terms[OPTION_ID]["strike"] == STRIKE + assert terms[OPTION_ID]["settlement"] == "cash_inverse" + assert UNDERLYING_ID not in terms, "a perpetual has no contract terms" + + +def test_an_option_without_a_declared_underlying_is_refused(tmp_path): + store, _, meta = _store(tmp_path, 60_000.0, 0.05) + _write_terms(meta) + + # The public API re-classifies the failure, so catch what a user catches. + from manifoldbt.exceptions import DataError + + with pytest.raises(DataError) as excinfo: + bt.run(_long_one_option(), _config(), store) + + message = str(excinfo.value) + assert "option_underlyings" in message + assert "own last traded premium" in message + + +def test_a_call_expiring_in_the_money_settles_at_intrinsic(tmp_path): + # S = 60k against a 50k strike, inverse settlement: 10000/60000 BTC. + store, _, meta = _store(tmp_path, 60_000.0, 0.05) + _write_terms(meta) + + result = bt.run( + _long_one_option(), + _config(option_underlyings={OPTION_ID: UNDERLYING_ID}), + store, + ) + + trades = result.trades.to_pandas() + settlements = trades[(trades.symbol_id == OPTION_ID) & (trades.exit_reason == 5)] + assert len(settlements) == 1, f"expected one settlement, got:\n{trades}" + assert settlements.iloc[0].fill_price == pytest.approx(10_000.0 / 60_000.0, abs=1e-12) + + +def test_a_call_expiring_out_of_the_money_settles_at_zero(tmp_path): + store, _, meta = _store(tmp_path, 40_000.0, 0.05) + _write_terms(meta) + + result = bt.run( + _long_one_option(), + _config(option_underlyings={OPTION_ID: UNDERLYING_ID}), + store, + ) + + trades = result.trades.to_pandas() + settlements = trades[(trades.symbol_id == OPTION_ID) & (trades.exit_reason == 5)] + assert len(settlements) == 1 + assert settlements.iloc[0].fill_price == 0.0 + # The premium paid is the whole loss, and it is a loss. + assert float(result.equity_curve[-1]) < float(result.equity_curve[0]) + + +def test_a_linear_universe_is_untouched_by_the_option_path(tmp_path): + # Two ordinary linear instruments: the option path must not touch them. + store, _, _ = _store(tmp_path, 40_000.0, 0.05, option_class="crypto_spot") + + result = bt.run(_long_one_option(), _config(), store) + + trades = result.trades.to_pandas() + assert (trades.exit_reason != 5).all(), "nothing may settle without contract terms" + + +def test_an_option_symbol_without_contract_terms_is_refused(tmp_path): + """The Databento case before this branch: an option that never expires. + + A symbol recorded as an option but carrying no strike or expiration would + otherwise price, trade and be held forever at its last quoted premium, with + nothing in the output looking wrong. + """ + from manifoldbt.exceptions import DataError + + store, _, _ = _store(tmp_path, 60_000.0, 0.05) # asset_class="option", no terms + + with pytest.raises(DataError) as excinfo: + bt.run(_long_one_option(), _config(), store) + + message = str(excinfo.value) + assert "no contract terms" in message + assert "deribit, databento" in message + + +def test_a_multiplier_option_costs_and_settles_like_one_contract(tmp_path): + """A listed-style option: 100 units of premium IS one exchange contract.""" + # Premium 4.70, underlying 490, strike 470 -> one contract pays (490-470)*100. + store, _, meta = _store(tmp_path, 490.0, 4.70) + import sqlite3 + + conn = sqlite3.connect(meta) + conn.execute( + "UPDATE symbols SET option_underlying = ?, option_type = ?, option_strike = ?," + " option_expiry = ?, option_contract_size = ?, option_settlement = ? WHERE id = ?", + ( + "SPY", + "call", + 470.0, + pd.Timestamp(EXPIRY_MS, unit="ms", tz="UTC").isoformat().replace("+00:00", "Z"), + 100.0, + "cash_linear", + OPTION_ID, + ), + ) + conn.commit() + conn.close() + + # 100 units of the option leg, nothing on the underlying. + hold_one_contract = _hold(**{str(OPTION_ID): 100.0}) + config = _config( + initial_capital=100_000.0, + option_underlyings={OPTION_ID: UNDERLYING_ID}, + ) + + result = bt.run(hold_one_contract, config, store) + trades = result.trades.to_pandas() + legs = trades[trades.symbol_id == OPTION_ID] + + entry = legs[legs.exit_reason == 0].iloc[0] + assert entry.quantity * entry.fill_price == pytest.approx(470.0), "what one contract costs" + + settlement = legs[legs.exit_reason == 5].iloc[0] + assert settlement.fill_price == pytest.approx(20.0), "intrinsic per share, not per contract" + assert settlement.quantity * settlement.fill_price == pytest.approx( + 2_000.0 + ), "what one contract pays" + + +PUT_ID = 3 + + +def _store_two_legs(tmp_path, underlying_price, call_premium, put_premium): + """Underlying + a call + a put, all daily, all the same length.""" + root = os.path.join(str(tmp_path), "data") + meta = os.path.join(str(tmp_path), "m.sqlite") + for symbol, symbol_id, price, klass in ( + ("BTC-PERPETUAL", UNDERLYING_ID, underlying_price, "crypto_perp"), + ("BTC-CALL", OPTION_ID, call_premium, "option"), + ("BTC-PUT", PUT_ID, put_premium, "option"), + ): + store = bt.import_dataframe( + _daily([price] * N_BARS), + symbol=symbol, + symbol_id=symbol_id, + interval="1d", + data_root=root, + metadata_db=meta, + asset_class=klass, + ) + return store, meta + + +def _write_leg_terms(meta_db, symbol_id, option_type, strike): + import sqlite3 + + conn = sqlite3.connect(meta_db) + conn.execute( + "UPDATE symbols SET option_underlying = ?, option_type = ?, option_strike = ?," + " option_expiry = ?, option_contract_size = ?, option_settlement = ? WHERE id = ?", + ( + "BTC_USD index", + option_type, + strike, + pd.Timestamp(EXPIRY_MS, unit="ms", tz="UTC").isoformat().replace("+00:00", "Z"), + 1.0, + "cash_inverse", + symbol_id, + ), + ) + conn.commit() + conn.close() + + +def test_a_two_leg_structure_settles_each_leg_on_its_own_terms(tmp_path): + """A risk reversal: long a call, short a put, both expiring together. + + Each leg settles against the same underlying but on its own strike and + side, so one finishes in the money and the other worthless. + """ + # S = 60k at expiry: the 50k call is ITM, the 40k put is worthless. + store, meta = _store_two_legs(tmp_path, 60_000.0, 0.05, 0.03) + _write_leg_terms(meta, OPTION_ID, "call", 50_000.0) + _write_leg_terms(meta, PUT_ID, "put", 40_000.0) + + config = _config( + universe=[UNDERLYING_ID, OPTION_ID, PUT_ID], + option_underlyings={OPTION_ID: UNDERLYING_ID, PUT_ID: UNDERLYING_ID}, + option_margin_model="deribit", + execution=bt.ExecutionConfig(position_sizing_mode="Units", allow_short=True), + ) + result = bt.run( + _hold(**{str(OPTION_ID): 1.0, str(PUT_ID): -1.0}), + config, + store, + ) + + trades = result.trades.to_pandas() + settlements = trades[trades.exit_reason == 5] + assert set(settlements.symbol_id) == {OPTION_ID, PUT_ID}, "both legs must settle" + + call = settlements[settlements.symbol_id == OPTION_ID].iloc[0] + put = settlements[settlements.symbol_id == PUT_ID].iloc[0] + assert call.fill_price == pytest.approx(10_000.0 / 60_000.0, abs=1e-12) + assert put.fill_price == 0.0, "a 40k put is worthless with the underlying at 60k" + + # Long the call, short the put: the short is bought back to close. + assert call.side == 2 and put.side == 1 + + +def test_per_leg_sizing_leaves_the_other_leg_flat(tmp_path): + """`col("symbol_id")` must target one leg without disturbing the others.""" + store, meta = _store_two_legs(tmp_path, 60_000.0, 0.05, 0.03) + _write_leg_terms(meta, OPTION_ID, "call", 50_000.0) + _write_leg_terms(meta, PUT_ID, "put", 40_000.0) + + config = _config( + universe=[UNDERLYING_ID, OPTION_ID, PUT_ID], + option_underlyings={OPTION_ID: UNDERLYING_ID, PUT_ID: UNDERLYING_ID}, + ) + result = bt.run(_hold(**{str(OPTION_ID): 1.0}), config, store) + + trades = result.trades.to_pandas() + assert (trades.symbol_id == OPTION_ID).all(), ( + f"only the call leg may trade, got:\n{trades}" + ) diff --git a/python/tests/test_plot_charts.py b/python/tests/test_plot_charts.py index 92d84fb..e4beece 100644 --- a/python/tests/test_plot_charts.py +++ b/python/tests/test_plot_charts.py @@ -94,3 +94,58 @@ def test_returns_histogram_has_no_unnamed_legend_entry(monkeypatch): assert not any( (name or "").startswith("trace ") for name in legend_names ), f"auto-generated trace label in the legend: {legend_names}" + + +# ── Adaptive axis / hover formats ──────────────────────────────────────────── +# These formats used to be hardcoded, and the failures were invisible in any +# assertion on figure structure: a two-month backtest labelled every date tick +# "May 2025", a -0.9% drawdown labelled every value tick "0%", and a 10-BTC +# equity hovered as "$10". Pure functions now decide them, so the contract is +# testable without rendering. + + +def _dates(days): + np = pytest.importorskip("numpy") + return np.arange("2025-01-01", np.timedelta64(days, "D") + np.datetime64("2025-01-01"), + dtype="datetime64[D]").astype("datetime64[ns]") + + +def test_date_tickformat_follows_the_span(): + from manifoldbt.plot._convert import date_tickformat + + assert date_tickformat(_dates(2)) == "%d %b %H:%M" + assert date_tickformat(_dates(61)) == "%d %b", "a two-month window must show days" + assert date_tickformat(_dates(365 * 4)) == "%b %Y", "long spans keep the historical format" + + +def test_money_hovertemplate_is_currency_and_magnitude_aware(): + np = pytest.importorskip("numpy") + from manifoldbt.plot._convert import money_hovertemplate + + btc = money_hovertemplate(np.array([10.0, 10.04]), "BTC") + assert "BTC" in btc and "%{y:,.4f}" in btc, btc + assert "$" not in btc, "a BTC equity must not hover in dollars" + + usd = money_hovertemplate(np.array([10_000.0, 21_313.0]), "USD") + assert "$%{y:,.0f}" in usd, usd + + eur = money_hovertemplate(np.array([500.0]), "EUR") + assert "\u20ac" in eur and "%{y:,.2f}" in eur, eur + + +def test_percent_tickformat_keeps_small_drawdowns_legible(): + from manifoldbt.plot._convert import percent_tickformat + + assert percent_tickformat(-0.35) == ".0%" + assert percent_tickformat(-0.02) == ".1%" + assert percent_tickformat(-0.0037) == ".2%", "a -0.37% max dd must not read as 0%" + + +def test_run_currency_reads_the_manifest_and_survives_its_absence(): + from manifoldbt.plot._convert import run_currency + + class WithManifest: + manifest = {"config": {"currency": "BTC"}} + + assert run_currency(WithManifest()) == "BTC" + assert run_currency(object()) == "USD", "no manifest must fall back, not raise"