release: v0.16.0

This commit is contained in:
github-actions[bot]
2026-08-17 00:02:26 +00:00
parent 5452806314
commit 77551d0c24
7 changed files with 692 additions and 12 deletions
+234
View File
@@ -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<f64>`) |
### 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.
+53 -8
View File
@@ -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.
+114
View File
@@ -0,0 +1,114 @@
"""Filling at a computed level — ExecutionPrice.custom(<signal name>).
A mean-reversion band strategy on native 1-minute bars: short at the touch of
an upper band around an hourly SMA, cover at the lower band. The engine always
knew how to COMPUTE the band; this example shows the fill landing ON it.
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."
)
+1 -1
View File
@@ -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" }
+18 -3
View File
@@ -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}
# ---------------------------------------------------------------------------
+128
View File
@@ -0,0 +1,128 @@
"""Signal-driven execution price: ``ExecutionPrice.custom(<signal name>)``.
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"))
+144
View File
@@ -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."
)