release: v0.16.0

This commit is contained in:
github-actions[bot]
2026-08-17 00:02:26 +00:00
parent f02d462480
commit fc48b3f074
6 changed files with 458 additions and 12 deletions
+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."
)