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:
@@ -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}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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