mirror of
https://github.com/manifoldbt/manifoldbt.git
synced 2026-08-24 14:38:04 +00:00
release: v0.15.0
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
"""Community sweep gating: cumulative combo budget + throughput penalty.
|
||||
|
||||
See docs/sweep-combo-limit-plan.md. Two mechanisms, tested here through real
|
||||
sweep calls on a tiny dataset:
|
||||
|
||||
* the 500-combo cap is enforced on the running total **per process**, not per
|
||||
call — otherwise slicing a grid into small calls bypasses it at a measured
|
||||
+0.5% cost;
|
||||
* every accepted Community call waits `SWEEP_MIN_INTERVAL`, held under a
|
||||
machine-wide file lock, so the remaining bypass (a fresh interpreter per
|
||||
slice) costs 5 s each and cannot be parallelised away.
|
||||
|
||||
The rate-gate tests run in subprocesses on purpose: "serialised across
|
||||
processes" is only observable between processes, and an in-process test would
|
||||
also depend on which test happened to run first.
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
import manifoldbt as bt
|
||||
from manifoldbt._native import _combo_budget
|
||||
|
||||
IS_PRO = bt.license_info()[0] == "Pro"
|
||||
community_only = pytest.mark.skipif(
|
||||
IS_PRO, reason="Community-only; deactivate Pro/BT_UNLOCKED to test"
|
||||
)
|
||||
pro_only = pytest.mark.skipif(not IS_PRO, reason="requires an active Pro license")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def store_paths(tmp_path_factory):
|
||||
"""A minimal store on disk; returns (data_root, metadata_db, arrow_dir)."""
|
||||
root = tmp_path_factory.mktemp("combo_limit")
|
||||
idx = pd.date_range("2024-01-01", periods=120, freq="1min", tz="UTC")
|
||||
close = 100.0 + np.arange(120, dtype=float)
|
||||
df = pd.DataFrame({
|
||||
"timestamp": idx,
|
||||
"open": close, "high": close * 1.01, "low": close * 0.99,
|
||||
"close": close, "volume": np.full(120, 1_000.0),
|
||||
})
|
||||
data_root, metadata_db = str(root / "data"), str(root / "metadata.sqlite")
|
||||
bt.import_dataframe(
|
||||
df, symbol="CL", symbol_id=1, interval="1m",
|
||||
data_root=data_root, metadata_db=metadata_db,
|
||||
)
|
||||
return data_root, metadata_db, f"{data_root}/mega"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def daily_store(store_paths):
|
||||
data_root, metadata_db, arrow_dir = store_paths
|
||||
return bt.DataStore(data_root, metadata_db, "bars_1m", None, arrow_dir)
|
||||
|
||||
|
||||
# --- shared snippet: build strategy + config, run one sweep of n combos ------
|
||||
_HARNESS = '''
|
||||
import sys, time
|
||||
import manifoldbt as bt
|
||||
|
||||
store = bt.DataStore({data_root!r}, {metadata_db!r}, "bars_1m", None, {arrow_dir!r})
|
||||
strat = bt.Strategy(
|
||||
name="budget_probe",
|
||||
signals={{"signal": bt.lit(1.0)}},
|
||||
position_sizing=bt.lit(1.0) * bt.param("size", default=1.0),
|
||||
parameters={{"size": bt.param("size", default=1.0)}},
|
||||
)
|
||||
t0, t1 = bt.time_range("2024-01-01", "2024-01-02")
|
||||
cfg = bt.BacktestConfig(universe=[1], time_range_start=t0, time_range_end=t1,
|
||||
bar_interval={{"Minutes": 1}})
|
||||
|
||||
def sweep(n):
|
||||
grid = {{"size": [1.0 + 0.001 * i for i in range(n)]}}
|
||||
return bt.run_sweep_lite(strat, grid, cfg, store)
|
||||
|
||||
def timed(n):
|
||||
# PermissionError comes from the native gate (cumulative cap), LicenseError
|
||||
# from the Python mirror (a single call larger than the cap). Both are
|
||||
# "refused", and neither should have waited out the rate limit.
|
||||
t = time.perf_counter()
|
||||
try:
|
||||
sweep(n)
|
||||
ok = True
|
||||
except (PermissionError, bt.LicenseError):
|
||||
ok = False
|
||||
return time.perf_counter() - t, ok
|
||||
'''
|
||||
|
||||
|
||||
def _run(store_paths, body):
|
||||
"""Run `body` in a fresh interpreter; return its stdout floats/flags."""
|
||||
data_root, metadata_db, arrow_dir = store_paths
|
||||
code = _HARNESS.format(
|
||||
data_root=data_root, metadata_db=metadata_db, arrow_dir=arrow_dir
|
||||
) + textwrap.dedent(body)
|
||||
out = subprocess.run(
|
||||
[sys.executable, "-c", code], capture_output=True, text=True, timeout=300
|
||||
)
|
||||
assert out.returncode == 0, f"subprocess failed:\n{out.stdout}\n{out.stderr}"
|
||||
return out.stdout.strip().splitlines()[-1].split()
|
||||
|
||||
|
||||
def _sweep(store, n_combos):
|
||||
"""One in-process run_sweep_lite call with exactly n_combos combinations."""
|
||||
strat = bt.Strategy(
|
||||
name="budget_probe",
|
||||
signals={"signal": bt.lit(1.0)},
|
||||
position_sizing=bt.lit(1.0) * bt.param("size", default=1.0),
|
||||
parameters={"size": bt.param("size", default=1.0)},
|
||||
)
|
||||
# Minutes(1) is silently coarsened to daily on Community; the runs then
|
||||
# produce zero trades, which is irrelevant here — only the gate matters.
|
||||
t0, t1 = bt.time_range("2024-01-01", "2024-01-02")
|
||||
cfg = bt.BacktestConfig(
|
||||
universe=[1], time_range_start=t0, time_range_end=t1,
|
||||
bar_interval={"Minutes": 1},
|
||||
)
|
||||
grid = {"size": [1.0 + 0.001 * i for i in range(n_combos)]}
|
||||
return bt.run_sweep_lite(strat, grid, cfg, store)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- the budget --
|
||||
|
||||
@community_only
|
||||
def test_cumulative_budget(daily_store):
|
||||
used0, limit, is_pro = _combo_budget()
|
||||
assert not is_pro
|
||||
remaining = limit - used0
|
||||
if remaining < 8:
|
||||
pytest.skip(f"only {remaining} combos left in this process")
|
||||
|
||||
# Two calls of just over half the remaining budget: the first fits,
|
||||
# the second would cross the cap even though it is individually small.
|
||||
n = int(remaining) // 2 + 1
|
||||
_sweep(daily_store, n)
|
||||
with pytest.raises(PermissionError, match="already used this session"):
|
||||
_sweep(daily_store, n)
|
||||
|
||||
# The rejected call consumed nothing: what actually remains still fits.
|
||||
leftover = int(limit - _combo_budget()[0])
|
||||
assert leftover == int(remaining) - n
|
||||
if leftover >= 1:
|
||||
_sweep(daily_store, leftover)
|
||||
assert _combo_budget()[0] == limit
|
||||
|
||||
# Budget now exhausted: even a single combo is refused.
|
||||
with pytest.raises(PermissionError, match="0 remaining"):
|
||||
_sweep(daily_store, 1)
|
||||
|
||||
|
||||
# ------------------------------------------------------------ the rate gate --
|
||||
#
|
||||
# SWEEP_MIN_INTERVAL is 5 s. Thresholds leave generous slack: a call that
|
||||
# waited is asserted above 4 s, one that did not below 2 s. Nothing here
|
||||
# depends on machine speed — that is the point of a wall-clock gate.
|
||||
_INTERVAL = 5.0
|
||||
_WAITED = 4.0
|
||||
_DID_NOT_WAIT = 2.0
|
||||
|
||||
|
||||
@community_only
|
||||
def test_rate_gate_applies_to_every_call(store_paths):
|
||||
"""Each accepted call waits the interval — it is a rate limit, not a toll."""
|
||||
first, second = (
|
||||
float(x) for x in _run(store_paths, """
|
||||
t1, _ = timed(2)
|
||||
t2, _ = timed(2)
|
||||
print(t1, t2)
|
||||
""")
|
||||
)
|
||||
assert first > _WAITED, f"first call took {first:.2f}s, expected a ~5 s wait"
|
||||
assert second > _WAITED, (
|
||||
f"second call took {second:.2f}s — the gate is behaving like a one-off "
|
||||
f"charge instead of a rate limit"
|
||||
)
|
||||
|
||||
|
||||
@community_only
|
||||
def test_rate_gate_serialises_across_processes(store_paths):
|
||||
"""The lock is the mechanism: concurrent waits must queue, not overlap.
|
||||
|
||||
Without the file lock two processes would sleep through the same 5 s and
|
||||
both proceed — the failure mode of every sleep-based limiter. With it, two
|
||||
concurrent sweeps cost two intervals.
|
||||
"""
|
||||
data_root, metadata_db, arrow_dir = store_paths
|
||||
code = _HARNESS.format(
|
||||
data_root=data_root, metadata_db=metadata_db, arrow_dir=arrow_dir
|
||||
) + "timed(2)\n"
|
||||
t = time.perf_counter()
|
||||
procs = [
|
||||
subprocess.Popen([sys.executable, "-c", code],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
for _ in range(2)
|
||||
]
|
||||
for proc in procs:
|
||||
assert proc.wait(timeout=120) == 0
|
||||
elapsed = time.perf_counter() - t
|
||||
assert elapsed > 2 * _WAITED, (
|
||||
f"two concurrent sweeps took {elapsed:.2f}s — under two intervals, so "
|
||||
f"their waits overlapped and the lock is not serialising them"
|
||||
)
|
||||
|
||||
|
||||
@community_only
|
||||
def test_refused_call_does_not_wait(store_paths):
|
||||
"""Refusing is instant: no 5 s wait before being told no.
|
||||
|
||||
The refusal comes first in a fresh process, so a later accepted call still
|
||||
waits — the refusal neither charged nor exempted anything.
|
||||
"""
|
||||
refused, ok, accepted = _run(store_paths, """
|
||||
t_refused, ok = timed(1000) # larger than the cap
|
||||
t_accepted, _ = timed(2) # first *accepted* call: waits
|
||||
print(t_refused, ok, t_accepted)
|
||||
""")
|
||||
assert ok == "False", "expected the over-cap call to be refused"
|
||||
assert float(refused) < _DID_NOT_WAIT, (
|
||||
f"refused call took {float(refused):.2f}s — it should not wait"
|
||||
)
|
||||
assert float(accepted) > _WAITED, (
|
||||
"the accepted call after a refusal did not wait out the rate limit"
|
||||
)
|
||||
|
||||
|
||||
@pro_only
|
||||
def test_pro_is_not_rate_limited(store_paths):
|
||||
"""Pro skips the gate entirely: no counter, no wait, on any call."""
|
||||
first, second = (
|
||||
float(x) for x in _run(store_paths, """
|
||||
t1, _ = timed(2)
|
||||
t2, _ = timed(2)
|
||||
print(t1, t2)
|
||||
""")
|
||||
)
|
||||
assert first < _DID_NOT_WAIT and second < _DID_NOT_WAIT, (
|
||||
f"Pro waited ({first:.2f}s, {second:.2f}s) — the rate gate leaked"
|
||||
)
|
||||
@@ -162,3 +162,51 @@ def test_import_dataframe_integer_timestamp_raises(tmp_path):
|
||||
def test_import_dataframe_empty_raises(tmp_path):
|
||||
with pytest.raises(bt.DataError, match="no data rows"):
|
||||
_import_df(_bars_df(0), tmp_path)
|
||||
|
||||
|
||||
def test_import_dataframe_daily_interval_runs(tmp_path):
|
||||
"""Daily bars import AND backtest.
|
||||
|
||||
Regression: the resolution table listed only 1m/1h, so a daily store
|
||||
resolved to the (empty) 1m directory and the run died with "empty bar
|
||||
dataset for symbol". A ``1d`` entry in the table lets the daily provider
|
||||
layout be found. 1m/1h were unaffected, which is exactly why this slipped.
|
||||
"""
|
||||
n = 30
|
||||
ts = pd.date_range("2021-01-01", periods=n, freq="1D", tz="UTC")
|
||||
close = [100.0 + i for i in range(n)] # strictly rising → buy & hold profits
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"timestamp": ts,
|
||||
"open": close,
|
||||
"high": [c + 1.0 for c in close],
|
||||
"low": [c - 1.0 for c in close],
|
||||
"close": close,
|
||||
"volume": [10.0] * n,
|
||||
}
|
||||
)
|
||||
store = _import_df(df, tmp_path, name="daily", interval="1d")
|
||||
assert store.resolve_symbol("BTCUSDT") == 1
|
||||
|
||||
strategy = bt.Strategy(
|
||||
name="bh",
|
||||
signals={"signal": bt.lit(1.0)},
|
||||
position_sizing=bt.col("signal"),
|
||||
)
|
||||
config = bt.BacktestConfig(
|
||||
universe=[1],
|
||||
time_range_start=0,
|
||||
time_range_end=int(ts[-1].value) + 5 * 86_400_000_000_000,
|
||||
bar_interval={"Days": 1},
|
||||
initial_capital=1000.0,
|
||||
execution=bt.ExecutionConfig(
|
||||
signal_delay=1, execution_price="AtClose",
|
||||
position_sizing_mode="Units",
|
||||
),
|
||||
fees=bt.FeeConfig(),
|
||||
slippage={"FixedBps": {"bps": 0.0}},
|
||||
)
|
||||
result = bt.run(strategy, config, store)
|
||||
equity = result.equity_curve.to_pylist()
|
||||
assert len(equity) > 0
|
||||
assert equity[-1] > 1000.0
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
"""Cross-engine parity: manifoldbt vs vectorbt on brackets, shorts, fees.
|
||||
|
||||
This suite pins manifoldbt's fill semantics against an independent engine
|
||||
(vectorbt) on controlled synthetic bars, so a refactor that silently changes a
|
||||
fill price, a stop level, or PnL booking is caught here rather than in the wild.
|
||||
|
||||
Coverage — only what vectorbt can legitimately model apples-to-apples:
|
||||
|
||||
* market entry + take-profit (test_market_take_profit_parity)
|
||||
* market entry + stop-loss (test_market_stop_loss_parity)
|
||||
* combined SL+TP bracket (test_bracket_sl_tp_parity)
|
||||
* short entry + take-profit (test_short_take_profit_parity)
|
||||
* trailing stop (test_trailing_stop_parity)
|
||||
* fees over multiple round-trips (test_fees_multi_trade_parity)
|
||||
|
||||
Out of scope for vectorbt (validated separately, NOT against vectorbt):
|
||||
|
||||
* determined-price / resting limit entry — vectorbt has no resting order, so
|
||||
``test_limit_entry_matches_independent_reference`` pins it against a NumPy
|
||||
model instead.
|
||||
* sizing under fees — with ``FractionOfEquity`` the engines size differently
|
||||
once fees exist (manifoldbt charges the fee on top of a full-equity notional;
|
||||
vectorbt reserves it out of cash). Both are legitimate; the fee test sizes in
|
||||
fixed units to compare the fee arithmetic without that policy difference.
|
||||
|
||||
What is compared, and why only this:
|
||||
|
||||
* Trade fills (entry price, exit price, exit reason) and final ``total_return``.
|
||||
These are computed at full internal resolution and are exact. The *equity
|
||||
curve* is deliberately NOT compared: on a Community build the output series is
|
||||
capped to daily resolution, so its shape is not apples-to-apples with
|
||||
vectorbt. The realised trades and the final equity are unaffected by that cap.
|
||||
|
||||
Convention alignment (measured against manifoldbt 0.14.1, not assumed):
|
||||
|
||||
* ``signal_delay=0`` + ``AtClose`` → a market entry fills at the *close* of the
|
||||
signal bar. vectorbt ``from_signals`` fills the entry bar at close by default,
|
||||
so entries line up with no shift.
|
||||
* ``FractionOfEquity`` sizing is taken at the *signal-bar close*
|
||||
(``size_at_fill_price=False``). For a market entry that equals the fill price,
|
||||
so vectorbt ``size_type="percent"`` matches. For a resting limit entry the
|
||||
signal close and the fill price differ, so vectorbt is fed an explicit unit
|
||||
size to reproduce manifoldbt's "size at signal close" rule.
|
||||
* Take-profit is a passive target: it fills at the level even if the bar gaps
|
||||
through it. Stop-loss fills at the level (or worse on a gap). vectorbt's
|
||||
``stop_exit_price=StopMarket`` reproduces the level fill on these
|
||||
no-gap-at-open scenarios.
|
||||
|
||||
vectorbt has no resting entry order, so the limit-entry scenario also carries an
|
||||
independent NumPy reference for *where* the order fills; vectorbt only checks the
|
||||
downstream take-profit off that fill.
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
pd = pytest.importorskip("pandas")
|
||||
vbt = pytest.importorskip("vectorbt")
|
||||
|
||||
import manifoldbt as bt # noqa: E402
|
||||
from manifoldbt.expr import col, lit, when # noqa: E402
|
||||
from manifoldbt.helpers import Interval, Slippage # noqa: E402
|
||||
from vectorbt.portfolio.enums import StopExitPrice, Direction # noqa: E402
|
||||
|
||||
CAPITAL = 10_000.0
|
||||
REL_TOL = 1e-6
|
||||
|
||||
# Exit-reason codes emitted in trades_df (measured):
|
||||
REASON_NONE, REASON_SL, REASON_TP, REASON_TRAIL = 0, 1, 2, 3
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _bars(o, h, l, c, start="2023-01-01"):
|
||||
ts = pd.date_range(start, periods=len(c), freq="1h", tz="UTC")
|
||||
return pd.DataFrame(
|
||||
{"timestamp": ts, "open": list(map(float, o)), "high": list(map(float, h)),
|
||||
"low": list(map(float, l)), "close": list(map(float, c)),
|
||||
"volume": [1000.0] * len(c)}
|
||||
)
|
||||
|
||||
|
||||
def _mbt_run(df, strat, tmp_path, name, *, delay=0, allow_short=False,
|
||||
sizing="FractionOfEquity", fees=None):
|
||||
"""Run manifoldbt on an in-memory OHLC frame; return the Result."""
|
||||
root = str(tmp_path / name)
|
||||
os.makedirs(root, exist_ok=True)
|
||||
store = bt.import_dataframe(
|
||||
df, symbol="TEST", symbol_id=1, interval="1h",
|
||||
data_root=os.path.join(root, "data"),
|
||||
metadata_db=os.path.join(root, "meta.sqlite"),
|
||||
)
|
||||
ts = df["timestamp"]
|
||||
cfg = bt.BacktestConfig(
|
||||
universe=[1],
|
||||
time_range_start=0,
|
||||
time_range_end=int(ts.iloc[-1].value) + 30 * 86_400_000_000_000,
|
||||
bar_interval=Interval.hours(1),
|
||||
initial_capital=CAPITAL,
|
||||
execution=bt.ExecutionConfig(
|
||||
signal_delay=delay, execution_price="AtClose",
|
||||
max_position_pct=1.0, allow_short=allow_short,
|
||||
position_sizing_mode=sizing,
|
||||
),
|
||||
fees=fees if fees is not None else bt.FeeConfig.zero(),
|
||||
slippage=Slippage.none(),
|
||||
warmup_bars=0,
|
||||
)
|
||||
return bt.run(strat, cfg, store)
|
||||
|
||||
|
||||
def _mbt_trades(res):
|
||||
"""(entry_fill, exit_fill, exit_reason) from a two-row round-trip."""
|
||||
tr = res.trades_df()
|
||||
assert len(tr) == 2, f"expected one round-trip, got {len(tr)} rows:\n{tr}"
|
||||
entry = tr.iloc[0]
|
||||
exit_ = tr.iloc[1]
|
||||
return float(entry["fill_price"]), float(exit_["fill_price"]), int(exit_["exit_reason"])
|
||||
|
||||
|
||||
def _vbt_from_signals(df, entries, *, exits=None, tp=None, sl=None,
|
||||
sl_trail=False, size=1.0, size_type="percent",
|
||||
direction=None, fees=0.0):
|
||||
idx = pd.DatetimeIndex(df["timestamp"])
|
||||
close = pd.Series(df["close"].values, index=idx, dtype=float)
|
||||
ent = pd.Series(entries, index=idx)
|
||||
ex = pd.Series(exits if exits is not None else False, index=idx)
|
||||
kwargs = dict(
|
||||
open=pd.Series(df["open"].values, index=idx, dtype=float),
|
||||
high=pd.Series(df["high"].values, index=idx, dtype=float),
|
||||
low=pd.Series(df["low"].values, index=idx, dtype=float),
|
||||
init_cash=CAPITAL, size=size, size_type=size_type,
|
||||
fees=fees, slippage=0.0, sl_stop=sl, tp_stop=tp, sl_trail=sl_trail,
|
||||
stop_exit_price=StopExitPrice.StopMarket,
|
||||
freq="1h", accumulate=False,
|
||||
)
|
||||
if direction is not None:
|
||||
kwargs["direction"] = direction
|
||||
return vbt.Portfolio.from_signals(close, ent, ex, **kwargs)
|
||||
|
||||
|
||||
def _assert_close(a, b, msg):
|
||||
assert abs(a - b) <= REL_TOL * max(1.0, abs(b)), f"{msg}: {a} != {b}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Scenario A — market entry + take-profit
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_market_take_profit_parity(tmp_path):
|
||||
# Enter long at bar 0 close (100). TP +10% (110) is crossed at bar 3
|
||||
# (open 108 < 110 < high 115): both engines fill the target at 110.
|
||||
df = _bars(
|
||||
o=[100, 100, 104, 108, 111, 113],
|
||||
h=[101, 102, 106, 115, 112, 114],
|
||||
l=[99, 99, 103, 107, 110, 112],
|
||||
c=[100, 100, 105, 112, 111, 113],
|
||||
)
|
||||
# Long only while close in (99.5, 106): true on bars 0-2, false after, so
|
||||
# the position is a single clean round-trip closed by the TP.
|
||||
entry = when((col("close") > lit(99.5)) & (col("close") < lit(106.0)),
|
||||
lit(1.0), lit(0.0))
|
||||
strat = (bt.Strategy.create("mkt_tp")
|
||||
.signal("d", col("close")).size(entry).take_profit(pct=10.0))
|
||||
|
||||
res = _mbt_run(df, strat, tmp_path, "A")
|
||||
m_entry, m_exit, reason = _mbt_trades(res)
|
||||
assert reason == REASON_TP
|
||||
_assert_close(m_entry, 100.0, "mbt entry")
|
||||
_assert_close(m_exit, 110.0, "mbt tp exit")
|
||||
|
||||
pf = _vbt_from_signals(df, [True, False, False, False, False, False], tp=0.10)
|
||||
v_tr = pf.trades.records_readable.iloc[0]
|
||||
_assert_close(float(v_tr["Avg Entry Price"]), m_entry, "entry price")
|
||||
_assert_close(float(v_tr["Avg Exit Price"]), m_exit, "exit price")
|
||||
_assert_close(pf.total_return(), res.metrics["total_return"], "total_return")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Scenario B — market entry + stop-loss
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_market_stop_loss_parity(tmp_path):
|
||||
# Enter long at bar 0 close (100). SL -5% (95) is hit at bar 3
|
||||
# (open 97 > 95, low 94 <= 95): both engines fill the stop at 95.
|
||||
df = _bars(
|
||||
o=[100, 100, 99, 97, 96, 95],
|
||||
h=[101, 101, 100, 98, 97, 96],
|
||||
l=[99, 99, 96, 94, 95, 94],
|
||||
c=[100, 100, 98, 96, 96, 95],
|
||||
)
|
||||
entry = when(col("close") >= lit(97.0), lit(1.0), lit(0.0))
|
||||
strat = (bt.Strategy.create("mkt_sl")
|
||||
.signal("d", col("close")).size(entry).stop_loss(pct=5.0))
|
||||
|
||||
res = _mbt_run(df, strat, tmp_path, "B")
|
||||
m_entry, m_exit, reason = _mbt_trades(res)
|
||||
assert reason == REASON_SL
|
||||
_assert_close(m_entry, 100.0, "mbt entry")
|
||||
_assert_close(m_exit, 95.0, "mbt sl exit")
|
||||
|
||||
pf = _vbt_from_signals(df, [True, False, False, False, False, False], sl=0.05)
|
||||
v_tr = pf.trades.records_readable.iloc[0]
|
||||
_assert_close(float(v_tr["Avg Entry Price"]), m_entry, "entry price")
|
||||
_assert_close(float(v_tr["Avg Exit Price"]), m_exit, "exit price")
|
||||
_assert_close(pf.total_return(), res.metrics["total_return"], "total_return")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Scenario C — resting limit entry at a determined price + take-profit
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _resting_limit_reference(df, signal_bar, offset_frac, tp_frac, capital):
|
||||
"""Independent NumPy model of a resting buy-limit + take-profit.
|
||||
|
||||
Mirrors the measured manifoldbt rule: the limit rests at
|
||||
``signal_close * (1 - offset_frac)``, fills on the first bar AFTER the
|
||||
signal bar whose low touches it (fill AT the level), sizes at the signal
|
||||
close, then a passive TP at ``fill * (1 + tp_frac)`` closes it on the first
|
||||
later bar whose high reaches it.
|
||||
"""
|
||||
close = df["close"].to_numpy(float)
|
||||
high = df["high"].to_numpy(float)
|
||||
low = df["low"].to_numpy(float)
|
||||
signal_close = close[signal_bar]
|
||||
limit = signal_close * (1.0 - offset_frac)
|
||||
qty = capital / signal_close # size_at_fill_price=False
|
||||
|
||||
fill_bar = next((i for i in range(signal_bar + 1, len(low)) if low[i] <= limit), None)
|
||||
assert fill_bar is not None, "limit never filled in reference"
|
||||
tp = limit * (1.0 + tp_frac)
|
||||
exit_bar = next((i for i in range(fill_bar, len(high)) if high[i] >= tp), None)
|
||||
assert exit_bar is not None, "TP never reached in reference"
|
||||
total_return = qty * (tp - limit) / capital
|
||||
return dict(limit=limit, qty=qty, fill_bar=fill_bar, tp=tp,
|
||||
exit_bar=exit_bar, total_return=total_return)
|
||||
|
||||
|
||||
def test_limit_entry_matches_independent_reference(tmp_path):
|
||||
"""Determined-price (resting limit) entry — validated WITHOUT vectorbt.
|
||||
|
||||
vectorbt has no resting entry order: it cannot wait across bars for price to
|
||||
trade down to a level, so a "vs vectorbt" check would not be apples-to-apples
|
||||
and is deliberately not attempted. This manifoldbt-only feature is pinned
|
||||
against an independent NumPy model of the resting fill instead. The vectorbt
|
||||
suite above covers what both engines share (market entry, SL, TP).
|
||||
|
||||
Signal at bar 0 (close 100). Limit rests 2% below (98). Bar 1 low 97 <= 98
|
||||
fills at 98. TP +5% off the fill (102.9) is reached at bar 3 (open 102 < the
|
||||
target, so it fills the passive target at the level, not on a gap).
|
||||
"""
|
||||
df = _bars(
|
||||
o=[100, 99, 101, 102, 104, 105],
|
||||
h=[100.5, 100, 102, 104, 105, 106],
|
||||
l=[99.5, 97, 100, 101.5, 103, 104],
|
||||
c=[100, 99, 101, 103, 104, 105],
|
||||
start="2023-01-02",
|
||||
)
|
||||
# Signal fires only on bar 0 so exactly one resting order is placed.
|
||||
entry = when((col("close") >= lit(99.5)) & (col("close") <= lit(100.5)),
|
||||
lit(1.0), lit(0.0))
|
||||
strat = (bt.Strategy.create("lim_tp")
|
||||
.signal("d", col("close"))
|
||||
.size(entry)
|
||||
.limit_entry(offset_bps=200, time_in_force="GTC") # 200 bps = 2%
|
||||
.take_profit(pct=5.0))
|
||||
|
||||
res = _mbt_run(df, strat, tmp_path, "C")
|
||||
m_entry, m_exit, reason = _mbt_trades(res)
|
||||
|
||||
ref = _resting_limit_reference(df, signal_bar=0, offset_frac=0.02,
|
||||
tp_frac=0.05, capital=CAPITAL)
|
||||
_assert_close(m_entry, ref["limit"], "limit fill price") # 98.0
|
||||
_assert_close(m_exit, ref["tp"], "tp exit price") # 102.9
|
||||
assert reason == REASON_TP
|
||||
_assert_close(res.metrics["total_return"], ref["total_return"], "total_return")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Scenario D — combined SL+TP bracket (both armed, the right one fires)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_bracket_sl_tp_parity(tmp_path):
|
||||
# SL -5% (95) AND TP +10% (110) armed together. Price rises, so the TP fires
|
||||
# at bar 3 and the stop never triggers — the bracket must not misfire.
|
||||
df = _bars(
|
||||
o=[100, 100, 104, 108, 111, 113],
|
||||
h=[101, 102, 106, 115, 112, 114],
|
||||
l=[99, 99, 103, 107, 110, 112],
|
||||
c=[100, 100, 105, 112, 111, 113],
|
||||
)
|
||||
entry = when((col("close") > lit(99.5)) & (col("close") < lit(106.0)),
|
||||
lit(1.0), lit(0.0))
|
||||
strat = (bt.Strategy.create("bracket")
|
||||
.signal("d", col("close")).size(entry)
|
||||
.stop_loss(pct=5.0).take_profit(pct=10.0))
|
||||
|
||||
res = _mbt_run(df, strat, tmp_path, "D")
|
||||
m_entry, m_exit, reason = _mbt_trades(res)
|
||||
assert reason == REASON_TP
|
||||
_assert_close(m_exit, 110.0, "mbt tp exit")
|
||||
|
||||
pf = _vbt_from_signals(df, [True, False, False, False, False, False],
|
||||
sl=0.05, tp=0.10)
|
||||
v_tr = pf.trades.records_readable.iloc[0]
|
||||
_assert_close(float(v_tr["Avg Exit Price"]), m_exit, "exit price")
|
||||
_assert_close(pf.total_return(), res.metrics["total_return"], "total_return")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Scenario E — short entry + take-profit
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_short_take_profit_parity(tmp_path):
|
||||
# Short at bar 0 close (100). TP -5% (95, profit for a short) is hit at bar 3
|
||||
# (open 96 > 95, low 94 <= 95): both engines cover at 95 for a +5% return.
|
||||
# Signal is short on bars 0-2 and flat from bar 3, so the TP closes it with
|
||||
# no re-entry.
|
||||
df = _bars(
|
||||
o=[100, 99, 98, 96, 95, 94],
|
||||
h=[100.5, 100, 99, 97, 96, 95],
|
||||
l=[99.5, 98, 97, 94, 94, 93],
|
||||
c=[100, 98, 97, 95, 94, 93],
|
||||
)
|
||||
entry = when(col("close") >= lit(96.0), lit(-1.0), lit(0.0))
|
||||
strat = (bt.Strategy.create("short_tp")
|
||||
.signal("d", col("close")).size(entry).take_profit(pct=5.0))
|
||||
|
||||
res = _mbt_run(df, strat, tmp_path, "E", allow_short=True)
|
||||
m_entry, m_exit, reason = _mbt_trades(res)
|
||||
assert reason == REASON_TP
|
||||
_assert_close(m_entry, 100.0, "short entry")
|
||||
_assert_close(m_exit, 95.0, "short cover")
|
||||
|
||||
pf = _vbt_from_signals(df, [True, False, False, False, False, False],
|
||||
tp=0.05, direction=Direction.ShortOnly)
|
||||
v_tr = pf.trades.records_readable.iloc[0]
|
||||
_assert_close(float(v_tr["Avg Entry Price"]), m_entry, "entry price")
|
||||
_assert_close(float(v_tr["Avg Exit Price"]), m_exit, "cover price")
|
||||
_assert_close(pf.total_return(), res.metrics["total_return"], "total_return")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Scenario F — trailing stop
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_trailing_stop_parity(tmp_path):
|
||||
# Always long. The high peaks at 112 (bar 3-4), so a 5% trailing stop rests
|
||||
# at 112 * 0.95 = 106.4. Bar 5 low (104) trades through it: both engines exit
|
||||
# at 106.4. (vectorbt's sl_trail also trails off the high when high is given.)
|
||||
df = _bars(
|
||||
o=[100, 101, 106, 110, 111, 108],
|
||||
h=[100, 102, 108, 112, 112, 109],
|
||||
l=[100, 100, 105, 109, 109, 104],
|
||||
c=[100, 102, 107, 111, 110, 105],
|
||||
)
|
||||
strat = (bt.Strategy.create("trail")
|
||||
.signal("d", col("close")).size(lit(1.0))
|
||||
.trailing_stop(pct=5.0, use_high=True))
|
||||
|
||||
res = _mbt_run(df, strat, tmp_path, "F")
|
||||
tr = res.trades_df()
|
||||
# Always-long re-enters at the exit bar's close (a mark-flat no-op on the
|
||||
# last bar), so the round-trip is the first two rows; assert on those.
|
||||
assert float(tr.iloc[1]["fill_price"]) == pytest.approx(106.4)
|
||||
assert int(tr.iloc[1]["exit_reason"]) == REASON_TRAIL
|
||||
|
||||
pf = _vbt_from_signals(df, [True, False, False, False, False, False],
|
||||
sl=0.05, sl_trail=True)
|
||||
v_tr = pf.trades.records_readable.iloc[0]
|
||||
_assert_close(float(v_tr["Avg Exit Price"]), 106.4, "trailing exit")
|
||||
_assert_close(pf.total_return(), res.metrics["total_return"], "total_return")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Scenario G — fees over multiple round-trips (cumulative accounting)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_fees_multi_trade_parity(tmp_path):
|
||||
# Two round-trips with a 20 bps taker fee, sized in FIXED UNITS. Fixed units
|
||||
# are deliberate: under FractionOfEquity the engines size differently once
|
||||
# fees exist (manifoldbt charges the fee on top of a full-equity notional,
|
||||
# vectorbt reserves the fee out of cash), which is a legitimate design choice
|
||||
# rather than a parity bug. Fixing the unit count isolates the thing both
|
||||
# engines must agree on — the fee arithmetic and its cumulative effect.
|
||||
units = 50.0
|
||||
close = [100, 101, 102, 99, 98, 103, 99]
|
||||
df = _bars(
|
||||
o=close, h=[c + 0.5 for c in close], l=[c - 0.5 for c in close], c=close,
|
||||
start="2023-06-01",
|
||||
)
|
||||
# Long while close > 100: enters bar 1, exits bar 3, re-enters bar 5, exits
|
||||
# bar 6 → two clean round-trips.
|
||||
entry = when(col("close") > lit(100.0), lit(units), lit(0.0))
|
||||
strat = bt.Strategy.create("fees").signal("d", col("close")).size(entry)
|
||||
fees = bt.FeeConfig(maker_fee_bps=10.0, taker_fee_bps=20.0)
|
||||
|
||||
res = _mbt_run(df, strat, tmp_path, "G", sizing="Units", fees=fees)
|
||||
|
||||
sig = pd.Series(close, dtype=float) > 100
|
||||
entries = sig & ~sig.shift(1, fill_value=False)
|
||||
exits = ~sig & sig.shift(1, fill_value=False)
|
||||
pf = _vbt_from_signals(df, entries.tolist(), exits=exits.tolist(),
|
||||
size=units, size_type="amount", fees=0.002)
|
||||
_assert_close(pf.total_return(), res.metrics["total_return"], "total_return")
|
||||
Reference in New Issue
Block a user