mirror of
https://github.com/manifoldbt/manifoldbt.git
synced 2026-08-24 22:48:05 +00:00
release: v0.16.0
This commit is contained in:
@@ -255,8 +255,9 @@ When `accuracy=True`, the engine loads `bars_1m` and runs in hybrid mode: signal
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
mbt.ExecutionConfig(
|
mbt.ExecutionConfig(
|
||||||
signal_delay=1, # bars between signal and execution
|
signal_delay=0, # bars between signal and execution
|
||||||
execution_price="AtClose", # fill price: AtClose, AtOpen, AtVwap, MidPrice
|
execution_price="AtClose", # AtClose, AtOpen, AtVwap, MidPrice,
|
||||||
|
# or ExecutionPrice.custom(name)
|
||||||
max_position_pct=0.5, # max position as fraction of equity
|
max_position_pct=0.5, # max position as fraction of equity
|
||||||
allow_short=True, # allow short positions
|
allow_short=True, # allow short positions
|
||||||
allow_fractional=True, # allow fractional units
|
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
|
### Signal delay
|
||||||
|
|
||||||
| Value | Behavior |
|
| Value | Behavior |
|
||||||
|-------|---------------------------------------------------|
|
|-------|-----------------------------------------------------------------|
|
||||||
| `0` | Execute same bar (look-ahead bias risk) |
|
| `0` | **Default.** Fill at the close of the signal bar |
|
||||||
| `1` | **Default.** Execute next bar (t+1) |
|
| `1` | Fill on the next bar (t+1) |
|
||||||
| `2+` | Execute N bars after signal |
|
| `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
|
## 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.
|
2. **Set `warmup_bars`** to at least the longest indicator period.
|
||||||
3. **Use `mbt.when()` for sizing.** Keep signal logic readable and composable.
|
3. **Use `mbt.when()` for sizing.** Keep signal logic readable and composable.
|
||||||
4. **Run diagnostics** (`detect_lookahead`, `check_exposure_stability`) on new strategies.
|
4. **Run diagnostics** (`detect_lookahead`, `check_exposure_stability`) on new strategies.
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "manifoldbt"
|
name = "manifoldbt"
|
||||||
version = "0.15.0"
|
version = "0.16.0"
|
||||||
description = "Rust-powered backtesting engine for quantitative research"
|
description = "Rust-powered backtesting engine for quantitative research"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
|
|||||||
@@ -122,9 +122,24 @@ class ExecutionPrice:
|
|||||||
MID_PRICE = "MidPrice"
|
MID_PRICE = "MidPrice"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def custom(column: str) -> Dict[str, str]:
|
def custom(name: str) -> Dict[str, str]:
|
||||||
"""Fill at a named column from bar data."""
|
"""Fill at a named bar column, or at a signal the strategy defines.
|
||||||
return {"Custom": column}
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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"))
|
||||||
@@ -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."
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user