From 77551d0c24341b49edb57ca4394940a33422f7e2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 17 Aug 2026 00:02:26 +0000 Subject: [PATCH] release: v0.16.0 --- docs/exec-price-signal-plan.md | 234 +++++++++++++++++++++++++ docs/strategy-authoring.md | 61 ++++++- examples/21_fill_at_computed_level.py | 114 ++++++++++++ pyproject.toml | 2 +- python/manifoldbt/helpers.py | 21 ++- python/tests/test_exec_price_signal.py | 128 ++++++++++++++ python/tests/test_lite_matches_run.py | 144 +++++++++++++++ 7 files changed, 692 insertions(+), 12 deletions(-) create mode 100644 docs/exec-price-signal-plan.md create mode 100644 examples/21_fill_at_computed_level.py create mode 100644 python/tests/test_exec_price_signal.py create mode 100644 python/tests/test_lite_matches_run.py diff --git a/docs/exec-price-signal-plan.md b/docs/exec-price-signal-plan.md new file mode 100644 index 0000000..051e8df --- /dev/null +++ b/docs/exec-price-signal-plan.md @@ -0,0 +1,234 @@ +# Plan : prix d'exécution piloté par signal (fill au niveau calculé) + +Branche : à créer depuis `main` @ `eac22e6` (0.15.0). + +## État d'avancement + +| Phase | Statut | +|---|---| +| 0 : garde-fous perf | goldens verts avant/après ; bench à consigner | +| 1 : résolution du prix contre les signaux (boucles générales) | **fait** | +| 2 : surface utilisateur, validation, observabilité | **fait** | +| 3 : tests (dont repro utilisateur) | **fait** : 5 tests Rust + 3 tests Python (`test_exec_price_signal.py`, wheel debug dans un venv isolé) | +| 4 : mesure et documentation | doc + exemple runnable faits (`examples/21_fill_at_computed_level.py`, vérifié : mêmes 821 trades, -20.84% close vs +20.85% niveau) ; bench 0% AtClose TENTÉ 2026-08-17 : 5 passes alternées baseline/branche, variance de charge ~1.9x (branche mesurée 741µs ET 1.47ms, baseline 842µs ET 1.39ms, direction qui s'inverse entre paires) -- illisible sur machine active, À REFAIRE sur machine calme avant merge ; note : le bench (sans ordres, AtClose) passe par le kernel rapide où ce code ne s'exécute pas, une régression y serait structurelle, pas liée à ce chemin | + +Arbitrages tranchés (2026-08-17) : `Custom` élargi (pas de variante nouvelle), +indexation `signal_row`, NaN = fallback close + warning. + +But : permettre à `execution_price` de lire une **série calculée par la stratégie** +(signal du DSL), pas seulement une colonne du batch de barres. C'est ce qui manque à +toute stratégie « à niveau » (mean reversion sur bandes, pullback sur EMA, entrée sur +swing) : le moteur sait calculer le niveau, il ne sait pas remplir dessus. Le fill +tombe au close de la barre, systématiquement du mauvais côté sur un instrument qui +mean-reverse (mesuré sur le repro utilisateur : +19.09% au niveau contre -49.58% au +close, mêmes signaux, mêmes 293 ordres). + +Contrainte cardinale, inchangée : **zéro coût quand la fonctionnalité n'est pas +utilisée**, et pas de nouvelle transcription de la règle de fill. + +--- + +## 1. État des lieux + +### Ce qui existe déjà + +| Élément | Emplacement | +|---|---| +| `ExecutionPrice::Custom(String)` (serde + API Python `ExecutionPrice.custom()`) | `orchestrator.rs:298`, `helpers.py:125` | +| Résolution du prix, colonnes de barres uniquement | `fill.rs:58`, `Custom` à `fill.rs:89` via `f64_column(bars.batch, ...)` | +| Les DEUX seuls consommateurs : boucle générale de `run` et boucle générale lite | `orchestrator.rs:2017`, `orchestrator.rs:3336` | +| Récolte de séries par nom depuis les envs, alignement resample compris (0.15.0) | `collect_entry_series`, `orchestrator.rs:679` | +| SL/TP re-vérifiés sur la barre d'entrée quand le fill n'est pas au close | `check_entry_bar`, `orchestrator.rs:1755` | +| Warning quand un fill sort de `[low, high]` | `validate_fill_price`, `fill.rs:125` | +| Le kernel rapide et le GPU refusent déjà tout prix non-`AtClose` | `fast_path_blocker`, `orchestrator.rs:5285` | + +Autrement dit : la sérialisation existe, la récolte de séries existe, le garde-fou +existe, l'interaction SL/TP est pensée, et le périmètre est confiné aux deux boucles +générales par construction. Il manque un seul fil : `Custom` ne regarde que +`bars.batch`. + +### Les cinq impasses, mesurées (repro utilisateur, 0.15.0) + +| Voie tentée | Résultat mesuré | +|---|---| +| Colonne supplémentaire dans les barres | supprimée à l'import | +| `ExecutionPrice.custom("prix_exec")` | ne voit pas les exo, colonnes de barres seulement | +| Loger le niveau dans `vwap` | écrasé par `(o+h+l+c)/4`, écart 0.0 exact | +| Loger le niveau dans `bid`/`ask` | jetés, 0 valeur non nulle | +| `.market_if_touched(signal=...)` | niveau gelé à la création de l'ordre (`limit_price: Option`) | + +### Pourquoi PAS le repricing d'ordre au repos + +Le besoin réel est un prix d'exécution par barre : le signal et le fill tombent sur la +même barre fine, le niveau est connu avant qu'elle commence. Le repricing d'un ordre +au repos est un chantier séparé (2 boucles + GPU + sémantique StopLimit + opt-in) qui +ne débloque même pas ce cas. Hors périmètre ici. + +--- + +## 2. Conception + +### 2.1 Résolution une fois par symbole, avant la boucle + +```rust +/// Le prix d'exécution d'un symbole, résolu avant la boucle : soit le +/// comportement actuel (résolution par barre), soit une série de la stratégie. +enum ResolvedExecPrice<'a> { + /// AtClose / AtOpen / AtVwap / MidPrice / Custom(colonne de barre) : + /// comportement actuel, inchangé au bit près. + PerBar(&'a ExecutionPrice), + /// Custom(nom) résolu contre les signaux de la stratégie. + Series(&'a [f64]), +} +``` + +Ordre de résolution de `Custom(nom)` : + +1. colonne du batch de barres (rétro-compatibilité stricte, les configs actuelles ne + bougent pas) ; +2. sinon, signal de la stratégie, récolté par la même mécanique que + `collect_entry_series` (élargir la liste de noms passée à la récolte : noms + d'entrée + nom du prix d'exécution, un seul appel) ; +3. sinon, **erreur avant la boucle** (aujourd'hui : erreur au premier fill, donc + potentiellement des heures de simulation perdues avant de la voir) ; +4. collision colonne/signal : la colonne gagne, warning « signal shadowed by bar + column » pour que ce soit visible. + +`resolve_execution_price` prend le résolu ; le cas `Series` indexe un slice, le cas +`PerBar` est le code actuel déplacé tel quel. + +### 2.2 Indexation : `signal_row`, pas `row` + +La série est lue à `signal_row = row - signal_delay`, le même statut d'information que +le sizing et que les niveaux d'entrée conditionnelle (642994f : « no look-ahead the +sizing does not already have »). + +Avec le défaut livré `signal_delay = 0` (convention market-on-close, parité +vectorbt, cf. eac22e6), `signal_row == row` : l'utilisateur peut composer le clip à +l'open de la barre d'exécution dans le DSL (voir 2.5) sans surprise. Avec +`signal_delay > 0`, la série est lue sur la barre de signal ; à documenter comme tel. + +C'est le point où on fait MIEUX que vectorbt : leur `price=` accepte n'importe quel +tableau et laisse l'utilisateur se remplir à un prix calculé sur le close de la barre +courante, sans un mot. + +### 2.3 NaN de warm-up + +Fallback au close + warning, le précédent exact d'`AtVwap` (`fill.rs:67`). Compté dans +les warnings. Pas de rechute du bug b715248 ici : le prix d'exécution ne gate pas la +création d'ordre, donc aucune interaction avec le dedup de cible. + +### 2.4 Ce qui ne change pas + +- **Fees et slippage** : chemin market normal, taker + slippage sur le niveau. C'est + la sémantique live du cas d'usage (ordre au marché déclenché au franchissement). +- **Sizing au close** : l'écart sizing/fill existe déjà pour `Custom` colonne ; + `size_at_fill_price` reste réservé aux ordres d'entrée. Documenté, pas modifié. +- **`fast_path_blocker`** : refuse déjà tout non-`AtClose`, message déjà juste. Un + sweep avec prix custom reste sur les boucles générales, et le dit. +- **Kernel rapide, CUDA** : zéro changement, zéro risque de parité FP. +- **`validate_fill_price`** : conservé tel quel ; un niveau hors de `[low, high]` de + la barre d'exécution warn, c'est le garde-fou que vectorbt n'a pas. + +### 2.5 Exemple cible (le cas utilisateur, verbatim après le chantier) + +```python +# bande haute/basse en % d'une SMA horaire sur bougies fermées, diffusée au 1m +exec_level = bt.when(high >= bande_haute, + bt.when(open >= bande_haute, open, bande_haute), + bt.when(low <= bande_basse, + bt.when(open <= bande_basse, open, bande_basse), + close)) + +strat = strategie(...).signal("exec_level", exec_level) +cf.execution.execution_price = bt.ExecutionPrice.custom("exec_level") +``` + +Un seul mécanisme couvre l'entrée ET la sortie (le `when` choisit la bande touchée), +et le clip à l'open gère la barre qui ouvre au-delà du niveau. + +--- + +## 3. Phases + +### Phase 0 : garde-fous + +- Goldens verts (`BT_UNLOCKED=1 cargo test`), baseline `runner_benchmark` consignée. +- Seuil accepté : **0 %** de régression sur le chemin `AtClose`. + +### Phase 1 : moteur + +- `ResolvedExecPrice` dans `fill.rs`, calqué sur `ResolvedEntryPrice` + (`orders.rs:293`). +- Résolution par symbole avant la boucle, aux deux endroits où `resolve_entry` est + déjà appelé (`orchestrator.rs:1666`, `:2947`) ; fusion des noms avec + `entry_signal_names()` pour un seul appel de récolte. +- Les deux sites de consommation (`orchestrator.rs:2017`, `:3336`) passent le résolu + et `signal_row`. +- Erreur avant la boucle sur nom inconnu. + +### Phase 2 : surface, validation, observabilité + +- Docstring de `ExecutionPrice.custom()` (`helpers.py:125`) : « colonne de barre ou + signal de la stratégie », ordre de résolution, indexation, NaN. +- Warnings : shadowing colonne/signal, fallback close sur NaN (comptés). +- Message d'erreur du nom inconnu : citer les signaux disponibles. + +### Phase 3 : tests + +- Unitaire Rust : le fill atterrit sur la valeur de la série à `signal_row` + (slippage compris), pas au close. +- Repro utilisateur en test Python : bandes sur SMA horaire diffusée au 1m, assert + fill == niveau de bande sur les barres de franchissement, écart de rendement vs + `AtClose` du bon signe. +- Parité `run()` vs lite sur le même scénario. +- Goldens inchangés quand la fonctionnalité n'est pas utilisée. +- NaN de warm-up : fallback + warning, le backtest ne meurt pas. +- `check_entry_bar` : un SL est bien déclenchable sur la barre d'entrée quand le + fill vient d'une série. +- Nom inconnu : erreur avant la boucle, message avec les candidats. +- Shadowing : warning présent. + +### Phase 4 : mesure et documentation + +- Re-bench vs baseline phase 0 ; consigner dans `perf/`. +- `strategy-authoring.md` : ligne dans le tableau des modes d'exécution + section + « fill au niveau » avec l'exemple 2.5. +- Exemple runnable dans `examples/` (le pattern bandes), comme 12fc5e6 pour les + entrées conditionnelles. + +--- + +## 4. Risques + +| Risque | Mitigation | +|---|---| +| Régression sur le chemin sans prix custom | résolution hors boucle, `PerBar` = code actuel déplacé tel quel, goldens + bench 0 % | +| Look-ahead offert par une série mal construite | indexation `signal_row` par défaut + `validate_fill_price` qui warn hors `[low, high]` + doc explicite | +| Collision de nom colonne/signal | précédence colonne (rétro-compat) + warning | +| NaN silencieux au warm-up | fallback close compté + warning, précédent `AtVwap` | +| Utilisateur qui perd le fast path sans le savoir | `fast_path_blocker` le dit déjà ; phrase dédiée dans la doc | +| Fill flatteur (niveau jamais échangé dans la barre) | `validate_fill_price` warn ; le cas nominal (niveau entre open et high) est prouvé par la barre elle-même | + +## 5. Arbitrages à trancher + +1. **`Custom` élargi ou nouvelle variante `Signal(String)` ?** Recommandé : élargir + `Custom`. Le script utilisateur marche verbatim, pas de nouvelle surface serde, la + précédence colonne préserve l'existant. Une variante explicite reste possible plus + tard si la collision devient un vrai problème. +2. **Indexation `signal_row` ou `row` ?** Recommandé : `signal_row`. Avec le défaut + `signal_delay=0` c'est identique à `row` ; avec un délai, c'est le seul choix + cohérent avec le sizing et les entrées conditionnelles. +3. **NaN : fallback close + warning, ou pas de trade ?** Recommandé : fallback + + warning (précédent `AtVwap`). « Pas de trade » créerait une interaction avec le + dedup de cible, exactement la classe de bug de b715248. + +## 6. Hors périmètre, à traiter à part + +- **Repricing des ordres au repos** (niveau `Series` gelé à la création, + `orchestrator.rs:664`) : chantier réel mais distinct, ne débloque pas ce cas. +- **`import_dataframe` qui écrase `vwap` (recalculé ohlc4) et jette `bid`/`ask` en + silence** : piège mesuré, mérite au minimum un warning. Indépendant de ce plan. +- **GPU / kernel rapide** : un prix custom reste sur les boucles générales ; + n'ouvrir que si la demande le justifie, comme la phase 3b des entrées + conditionnelles. diff --git a/docs/strategy-authoring.md b/docs/strategy-authoring.md index dd76057..cccf7a5 100644 --- a/docs/strategy-authoring.md +++ b/docs/strategy-authoring.md @@ -255,8 +255,9 @@ When `accuracy=True`, the engine loads `bars_1m` and runs in hybrid mode: signal ```python mbt.ExecutionConfig( - signal_delay=1, # bars between signal and execution - execution_price="AtClose", # fill price: AtClose, AtOpen, AtVwap, MidPrice + signal_delay=0, # bars between signal and execution + execution_price="AtClose", # AtClose, AtOpen, AtVwap, MidPrice, + # or ExecutionPrice.custom(name) max_position_pct=0.5, # max position as fraction of equity allow_short=True, # allow short positions allow_fractional=True, # allow fractional units @@ -265,13 +266,57 @@ mbt.ExecutionConfig( ) ``` +### Filling at a computed level + +`ExecutionPrice.custom(name)` accepts a bar column (`"vwap"`, ...) **or the +name of any signal the strategy defines**, so a market fill can land on a level +the DSL computes instead of the bar's close. The canonical use is a band +strategy on native fine bars: the entry level is known before the bar starts, +and the touch bar itself proves the level traded (it sits between open and +high), yet a close fill would be systematically on the wrong side of it. + +```python +from manifoldbt.indicators import close, high, low, open +band_up, band_dn = sma * 1.012, sma * 0.992 +exec_level = mbt.when(high >= band_up, + mbt.when(open >= band_up, open, band_up), # gapped through + mbt.when(low <= band_dn, + mbt.when(open <= band_dn, open, band_dn), + close)) +strat = strat.signal("exec_level", exec_level) +config.execution.execution_price = mbt.ExecutionPrice.custom("exec_level") +``` + +One series covers entry AND exit fills. The rules that keep it honest: + +- the series is read at the order's **signal row**, never ahead of it; +- a fill outside the execution bar's `[low, high]` range draws a warning; +- a row with no value (warm-up) falls back to the close, with a warning; +- a name that is neither a column nor a signal is rejected before the run; +- a bar column always wins over a same-named signal (warned about). + +A custom execution price leaves the fast kernel, like every non-`AtClose` +price: `run()` is unaffected, large sweeps fall back to the general loop and +`fast_path_blocker` says so. + ### Signal delay -| Value | Behavior | -|-------|---------------------------------------------------| -| `0` | Execute same bar (look-ahead bias risk) | -| `1` | **Default.** Execute next bar (t+1) | -| `2+` | Execute N bars after signal | +| Value | Behavior | +|-------|-----------------------------------------------------------------| +| `0` | **Default.** Fill at the close of the signal bar | +| `1` | Fill on the next bar (t+1) | +| `2+` | Fill N bars after the signal | + +`0` models a decision taken on the bar's own close and filled at that close, the +market-on-close convention, and it is what vectorbt's `from_signals` does. It is +the right default for coarse bars, where one bar of delay would mean pricing a +full day of latency into a decision that in reality reaches the market in +seconds. + +Raise it when a bar is short enough that one bar is a plausible +decision-to-fill latency: on 1s or sub-second bars, `signal_delay=1` *is* the +realistic setting, and `0` assumes an infinitely fast round trip. The engine +does not infer this from `bar_interval`, so it is on you to set it. --- @@ -655,7 +700,7 @@ Every result includes these performance metrics: ## Best Practices -1. **Use `signal_delay=1`** (default). `signal_delay=0` introduces look-ahead bias. +1. **Set `signal_delay` deliberately.** It defaults to `0` (fill at the signal bar's close). Raise it to `1` when one bar is a realistic decision-to-fill latency, i.e. on fine-grained bars. 2. **Set `warmup_bars`** to at least the longest indicator period. 3. **Use `mbt.when()` for sizing.** Keep signal logic readable and composable. 4. **Run diagnostics** (`detect_lookahead`, `check_exposure_stability`) on new strategies. diff --git a/examples/21_fill_at_computed_level.py b/examples/21_fill_at_computed_level.py new file mode 100644 index 0000000..4dfdbc2 --- /dev/null +++ b/examples/21_fill_at_computed_level.py @@ -0,0 +1,114 @@ +"""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. + +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. + +Self-contained: generates its own synthetic data in a temp store. + +Usage: + python examples/21_fill_at_computed_level.py +""" + +import os +import tempfile + +import numpy as np +import pandas as pd + +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 --------------------------------- +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 +px = 100.0 * np.exp(level - np.linspace(0, level[-1], N)) +o, c = px, np.roll(px, -1) +c[-1] = px[-1] +amp = np.abs(rng.normal(0.0, 0.0012, N)) +ts = pd.date_range("2024-01-01", periods=N, freq="1min", tz="UTC") +frame = pd.DataFrame( + {"timestamp": ts, "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)} +) + +# -- Bands around an hourly SMA, evaluated on 1m native bars ------------------ +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) + +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 +target = mbt.when(touch_dn, 0.0, mbt.when(touch_up, -1.0)) + +# The level each fill should land on. The nesting mirrors the target's +# priority, and a bar that opens through a band fills at its open. +exec_level = mbt.when( + touch_dn, mbt.when(open_px <= band_dn, open_px, band_dn), + mbt.when(touch_up, mbt.when(open_px >= band_up, open_px, band_up), close), +) + +strategy = ( + mbt.Strategy.create("band_touch_short") + .signal("position", target) + .signal("exec_level", exec_level) + .size(target) + .stop_loss(pct=25.0) +) + +# -- Run the same strategy both ways ------------------------------------------ +if __name__ == "__main__": + root = tempfile.mkdtemp(prefix="mbt_example21_") + store = mbt.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 run(execution_price): + config = mbt.BacktestConfig( + universe=[1], + time_range_start=0, + time_range_end=int(ts[-1].value) + 86_400_000_000_000, + bar_interval=Interval.minutes(1), + initial_capital=10_000, + execution=mbt.ExecutionConfig( + signal_delay=0, + execution_price=execution_price, + max_position_pct=0.4, + allow_short=True, + position_sizing_mode="FractionOfEquity", + ), + fees=mbt.FeeConfig.binance_perps(), + slippage=Slippage.fixed_bps(2), + warmup_bars=60 * 10, + extra_timeframes={"1h": Interval.hours(1)}, + ) + return mbt.run(strategy, config, store) + + print(f"{'execution price':<22} {'trades':>7} {'return':>9} first entry fills") + print("-" * 78) + 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}") + + 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." + ) diff --git a/pyproject.toml b/pyproject.toml index dccc0b1..4e376a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "manifoldbt" -version = "0.15.0" +version = "0.16.0" description = "Rust-powered backtesting engine for quantitative research" requires-python = ">=3.9" license = { file = "LICENSE" } diff --git a/python/manifoldbt/helpers.py b/python/manifoldbt/helpers.py index a2a1256..fed8d36 100644 --- a/python/manifoldbt/helpers.py +++ b/python/manifoldbt/helpers.py @@ -122,9 +122,24 @@ class ExecutionPrice: MID_PRICE = "MidPrice" @staticmethod - def custom(column: str) -> Dict[str, str]: - """Fill at a named column from bar data.""" - return {"Custom": column} + def custom(name: str) -> Dict[str, str]: + """Fill at a named bar column, or at a signal the strategy defines. + + The name resolves against the bar schema first (``vwap``, ``bid``, ...), + then against the strategy's signals -- so a fill can land on any level + the DSL computes (a band around an SMA, a prior swing, ...):: + + strat = strat.signal("exec_level", sma * 1.012) + config.execution.execution_price = ExecutionPrice.custom("exec_level") + + The series is read at the order's SIGNAL row (no look-ahead beyond what + the sizing already has; with the default ``signal_delay=0`` that is the + execution bar). A row where the signal has no value falls back to the + close with a warning, and a fill outside the bar's [low, high] range is + warned about. A name that is neither a column nor a signal is rejected + before the simulation starts. + """ + return {"Custom": name} # --------------------------------------------------------------------------- diff --git a/python/tests/test_exec_price_signal.py b/python/tests/test_exec_price_signal.py new file mode 100644 index 0000000..9174446 --- /dev/null +++ b/python/tests/test_exec_price_signal.py @@ -0,0 +1,128 @@ +"""Signal-driven execution price: ``ExecutionPrice.custom()``. + +The user-facing surface of the band-strategy fix: the engine could always +COMPUTE a level in the DSL (a band around an SMA) but the only reachable fill +was the close of the bar, systematically on the wrong side of a mean-reverting +touch. ``custom()`` now also accepts the name of a signal the strategy +defines, and the fill lands on that series. + +The scenario is the minimal honest slice of the real case (short at the touch +of an upper band on native fine bars): the touch bar OPENS below the band and +its HIGH crosses it, so the band level provably traded inside the bar, yet it +equals neither the open nor the close. + +Runs on synthetic bars in a tmp store; no license assumptions beyond what the +other python tests already make (trade fills are exact on Community builds). +""" +import pytest + +pd = pytest.importorskip("pandas") + +import manifoldbt as bt # noqa: E402 +from manifoldbt.expr import col, lit, when # noqa: E402 +from manifoldbt.helpers import ExecutionPrice, Interval, Slippage # noqa: E402 + +CAPITAL = 10_000.0 +BAND = 100.5 + +# One-minute bars. Bar 1 is the touch bar: open 100.2 < BAND 100.5 <= high +# 100.8, close 100.7. The band level traded inside the bar, but AtClose can +# only fill at 100.7. +BARS = dict( + o=[100.0, 100.2, 100.7, 100.6], + h=[100.4, 100.8, 100.9, 100.8], + l=[99.8, 100.1, 100.5, 100.4], + c=[100.2, 100.7, 100.6, 100.5], +) + + +def _frame(): + ts = pd.date_range("2023-01-01", periods=len(BARS["c"]), freq="1min", tz="UTC") + return pd.DataFrame( + {"timestamp": ts, + "open": list(map(float, BARS["o"])), "high": list(map(float, BARS["h"])), + "low": list(map(float, BARS["l"])), "close": list(map(float, BARS["c"])), + "volume": [1000.0] * len(BARS["c"])} + ) + + +def _strategy(target: float): + """Enter (long or short) at the touch of the band; fill on its level. + + ``exec_level`` is the docs' composition: the band when touched (clipped to + the open when the bar opens through it), the close otherwise. + """ + touched = col("high") >= lit(BAND) + sig = when(touched, lit(target), lit(float("nan"))) + exec_level = when( + touched, + when(col("open") >= lit(BAND), col("open"), lit(BAND)), + col("close"), + ) + return ( + bt.Strategy.create("band-touch") + .signal("position", sig) + .signal("exec_level", exec_level) + .size(sig) + ) + + +def _run(tmp_path, name, strat, execution_price, *, allow_short=False): + import os + + root = str(tmp_path / name) + os.makedirs(root, exist_ok=True) + store = bt.import_dataframe( + _frame(), symbol="TEST", symbol_id=1, interval="1m", + data_root=os.path.join(root, "data"), + metadata_db=os.path.join(root, "meta.sqlite"), + ) + cfg = bt.BacktestConfig( + universe=[1], + time_range_start=0, + time_range_end=int(_frame()["timestamp"].iloc[-1].value) + 86_400_000_000_000, + bar_interval=Interval.minutes(1), + initial_capital=CAPITAL, + execution=bt.ExecutionConfig( + signal_delay=0, execution_price=execution_price, + max_position_pct=1.0, allow_short=allow_short, + position_sizing_mode="FractionOfEquity", + ), + fees=bt.FeeConfig.zero(), + slippage=Slippage.none(), + warmup_bars=0, + ) + return bt.run(strat, cfg, store) + + +def _entry_fill(res) -> float: + tr = res.trades_df() + assert len(tr) >= 1, f"expected an entry fill, got:\n{tr}" + return float(tr.iloc[0]["fill_price"]) + + +def test_long_entry_fills_on_the_band_not_at_the_close(tmp_path): + at_level = _run(tmp_path, "lvl", _strategy(1.0), ExecutionPrice.custom("exec_level")) + at_close = _run(tmp_path, "cls", _strategy(1.0), "AtClose") + assert _entry_fill(at_level) == pytest.approx(BAND), ( + "the fill must land on the band level the DSL computed" + ) + assert _entry_fill(at_close) == pytest.approx(BARS["c"][1]), ( + "the AtClose control must fill at the touch bar's close" + ) + + +def test_short_entry_fills_on_the_band_and_reports_the_worse_side(tmp_path): + at_level = _run(tmp_path, "lvl", _strategy(-1.0), + ExecutionPrice.custom("exec_level"), allow_short=True) + at_close = _run(tmp_path, "cls", _strategy(-1.0), "AtClose", allow_short=True) + assert _entry_fill(at_level) == pytest.approx(BAND) + # For a short at the touch of an upper band, the honest band fill (100.5) + # is WORSE than the close fill (100.7): the fix must be able to move the + # result down, not just up. + assert _entry_fill(at_close) > _entry_fill(at_level) + + +def test_unknown_name_is_rejected_before_the_run(tmp_path): + with pytest.raises(Exception, match="neither a bar column nor a signal"): + _run(tmp_path, "bad", _strategy(1.0), ExecutionPrice.custom("nope")) diff --git a/python/tests/test_lite_matches_run.py b/python/tests/test_lite_matches_run.py new file mode 100644 index 0000000..dedbc1f --- /dev/null +++ b/python/tests/test_lite_matches_run.py @@ -0,0 +1,144 @@ +"""The lite sweep path must agree with `run()` on intraday bars. + +`run_sweep_lite` is a separate transcription of the simulation, kept for speed +(roughly ten times the throughput of the full sweep). Its metrics are computed +from a *daily* equity curve, and that curve's first point is the equity at the +CLOSE of day one. Taking it as the growth base silently drops day one's profit +and loss from every metric measured against it, which shipped as an 8% error on +`total_return` for a fourteen-day intraday backtest. + +The bug was invisible on daily bars: with a 60-period indicator the warmup +covers sixty days, so the close of day one still equals the initial capital and +the base is right by accident. It only appears when trading starts on day one, +which on 1-minute bars is the normal case. Hence this test runs intraday. + +All fourteen metrics must be identical. `ulcer_index` used to be the exception: +it is accumulated over whichever curve it is handed, so the lite and GPU sweeps +measured it on daily points while `run()` measured it bar by bar, and the same +backtest carried two different values depending on the entry point. It now +follows the daily series on every path, like the Sharpe, Sortino and volatility +beside it, and like the published definition of the Ulcer Index. `max_drawdown` +deliberately stays full-resolution: a drawdown that opens and recovers inside a +day is a real one and belongs in the maximum. +""" +import os + +import pytest + +pd = pytest.importorskip("pandas") +np = pytest.importorskip("numpy") + +import manifoldbt as bt # noqa: E402 +from manifoldbt.expr import col, lit, param, when # noqa: E402 +from manifoldbt.helpers import Interval, Slippage # noqa: E402 +from manifoldbt.indicators import close as close_px, sma # noqa: E402 + +CAPITAL = 100_000.0 +FAST, SLOW = 10, 60 + +# Metrics that are pure functions of the equity path and its base, so the two +# code paths must agree to float-reordering noise. +MUST_MATCH = ( + "total_return", + "cagr", + "calmar", + "tstat_sharpe", + "sharpe", + "sortino", + "volatility", + "max_drawdown", + "avg_daily_return", + "best_day", + "worst_day", + "pct_positive_days", + "ulcer_index", + "alpha", + "beta", +) + + +def _intraday_bars(rows=8_000, seed=7): + """Gap-free 1-minute random walk. Long enough to span several days, and + volatile enough that the crossover trades inside the first day.""" + rng = np.random.default_rng(seed) + close = 100.0 * np.exp(np.cumsum(rng.normal(0.0, 3e-4, rows))) + open_ = np.empty(rows) + open_[0] = 100.0 + open_[1:] = close[:-1] + wick = rng.uniform(0.2, 1.8, rows) * 3e-4 * close + return pd.DataFrame( + { + "timestamp": pd.date_range("2021-03-01", periods=rows, freq="1min", tz="UTC"), + "open": open_, + "high": np.maximum(open_, close) + wick, + "low": np.minimum(open_, close) - wick, + "close": close, + "volume": np.full(rows, 1_000.0), + } + ) + + +def _config(df): + last_ns = int(df["timestamp"].iloc[-1].value) + return bt.BacktestConfig( + universe=[1], + time_range_start=0, + time_range_end=last_ns + 86_400_000_000_000, + bar_interval=Interval.minutes(1), + initial_capital=CAPITAL, + execution=bt.ExecutionConfig( + signal_delay=0, + execution_price="AtClose", + max_position_pct=1.0, + allow_short=False, + position_sizing_mode="FractionOfEquity", + ), + fees=bt.FeeConfig.zero(), + slippage=Slippage.none(), + warmup_bars=0, + ) + + +def test_lite_sweep_matches_run_on_intraday_bars(tmp_path): + df = _intraday_bars() + root = tmp_path / "store" + os.makedirs(root, exist_ok=True) + store = bt.import_dataframe( + df, + symbol="TEST", + symbol_id=1, + interval="1m", + data_root=os.path.join(root, "data"), + metadata_db=os.path.join(root, "meta.sqlite"), + ) + config = _config(df) + + sized = when(col("fast") > col("slow"), lit(1.0), lit(0.0)) + fixed = ( + bt.Strategy.create("fixed") + .signal("fast", sma(close_px, FAST)) + .signal("slow", sma(close_px, SLOW)) + .size(sized) + ) + swept = ( + bt.Strategy.create("swept") + .signal("fast", sma(close_px, param("fast"))) + .signal("slow", sma(close_px, param("slow"))) + .size(sized) + ) + + full = bt.run(fixed, config, store).metrics + lite = bt.run_sweep_lite( + swept, {"fast": [FAST], "slow": [SLOW]}, config, store + )[0].metrics + + # The strategy must actually trade on day one, otherwise the base is right + # by accident and the test proves nothing. + assert full["total_return"] != 0.0 + + for name in MUST_MATCH: + expected, got = full[name], lite[name] + assert abs(expected - got) <= 1e-9 * max(1.0, abs(expected)), ( + f"{name}: run()={expected!r} but run_sweep_lite()={got!r}. " + "The lite path has drifted from the full simulation." + )