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." + )