release: v0.29.0

This commit is contained in:
github-actions[bot]
2026-10-25 15:51:43 +00:00
parent 039d342c50
commit 58ca076a34
22 changed files with 277 additions and 59 deletions
+9 -25
View File
@@ -40,8 +40,7 @@ included. Python stays out of the hot loop.
vectorbt in CI, on the wheel published to PyPI. A benchmark workload the
engines disagree on gets no timing at all.
- **Explicit.** Every fill rule is [written down](#execution-semantics),
pessimistic wherever a choice existed, and so is
[what the engine does not model](#what-is-not-covered).
pessimistic wherever a choice existed.
```bash
pip install manifoldbt
@@ -273,32 +272,17 @@ was available, the pessimistic one was taken.
| Trailing stops | Never trigger on the bar they ratchet on, which would require reading the intra-bar path. |
| An execution-price expression evaluates to NaN | Falls back to the close and warns, rather than dropping the trade silently. |
| A short option's maintenance margin exceeds equity | The position is force-closed on that bar. |
| A leveraged position's equity reaches its maintenance margin (`cfg.liquidation`) | The book is liquidated inside the bar, at the liquidation price; a stop placed before that price fires first. |
| Fees, funding and borrow rates | Resolved per symbol, so venues inside one portfolio keep their own rates. |
### What is not covered
**Prices and margin.** Prices are used as the data source supplies them: Yahoo
defaults to dividend-adjusted (`dataset="raw"` opts out) and splits follow the
provider. Margin force-close applies to short options; leveraged spot and
perpetual positions are held as sized, so leverage limits belong in the
strategy.
The edges, stated rather than left to be discovered:
- **No general liquidation model.** Margin force-close exists for short options
only. A leveraged spot or perpetual position is not liquidated by the engine.
- **No corporate actions.** Yahoo prices arrive dividend-adjusted from the
source (`dataset="raw"` opts out) and splits are whatever the provider
returns. Nothing in the engine reconstructs either.
- The cross-engine scenarios run on short synthetic series, each built to
isolate one behaviour. They are not a long backtest over market data compared
trade for trade.
- Perpetual funding is exercised by the engine's own tests, but has no
cross-engine parity test.
- The largest universe under test is a handful of instruments. Cross-sectional
research across thousands of assets is exercised by the sweep benchmarks, not
by the correctness suite.
- Look-ahead coverage is a set of regression tests and one worked example, not
a systematic battery of leak archetypes.
The engine's own Rust suite is not published, which is why the rules above are
written out rather than linked. Independent verification is welcome, and a
reproduction showing a fill this engine gets wrong is the most useful report
this project can receive.
Independent verification is welcome, and a reproduction showing a fill this
engine gets wrong is the most useful report this project can receive.
## Look-ahead
+1 -1
View File
@@ -5,7 +5,7 @@
[project]
name = "manifoldbt"
version = "0.28.1"
version = "0.29.0"
description = "Rust-powered backtesting engine for quantitative research"
readme = "README.md"
requires-python = ">=3.9"
+41 -17
View File
@@ -80,6 +80,7 @@ from manifoldbt.config import (
BacktestConfig,
ExecutionConfig,
FeeConfig,
Liquidation,
OrderConfig,
VenueFees,
account_sessions,
@@ -128,10 +129,15 @@ __version__ = _update.installed_version() or "0.1.0"
# ---------------------------------------------------------------------------
# License banner
# ---------------------------------------------------------------------------
# Every tier that includes Pro. Researcher carries all of Pro, so a Pro gate
# opens on either. Defined before the banner, which reads it at import.
_PAID_TIERS = ("Pro", "Researcher")
def _print_banner():
try:
tier, email = _license_info()
if tier == "Pro" and email:
if tier in _PAID_TIERS:
# A trial shows its end date up front. Without this the banner is
# identical to a paid licence, and the expiry is discovered the day
# everything stops working, mid-session.
@@ -142,7 +148,8 @@ def _print_banner():
trial = f" (trial ends {expiry[:10]})"
except Exception:
pass
print(f"manifoldbt v{__version__} | \033[38;5;214mPro\033[0m{trial} | {email}")
who = f" | {email}" if email else ""
print(f"manifoldbt v{__version__} | \033[38;5;214m{tier}\033[0m{trial}{who}")
else:
print(f"manifoldbt v{__version__} | \033[36mCommunity\033[0m | upgrade: www.manifoldbt.com")
except Exception:
@@ -206,15 +213,19 @@ def check_for_update() -> Optional[str]:
def license_info() -> tuple:
"""Get license info: (tier, email). tier is "Pro" or "Community", email is str or None."""
"""Get license info: ``(tier, email)``.
``tier`` is ``"Researcher"``, ``"Pro"`` or ``"Community"``; ``email`` is a
str, or ``None`` on Community.
"""
return _license_info()
def _is_pro() -> bool:
"""Check if current license is Pro."""
"""Check if the current license includes Pro (Pro or Researcher)."""
try:
tier, _ = _license_info()
return tier == "Pro"
return tier in _PAID_TIERS
except Exception:
return False
@@ -242,10 +253,9 @@ def _require_grant_for_gpu(device, feature: str) -> None:
``LicenseError``, instead of each surfacing its own error type from deeper in
the run. No-op for CPU, and no-op for a licence that carries it.
GPU acceleration is a Researcher feature. Note that
``license_info()`` reports ``"Pro"`` for a Researcher licence, because
Researcher includes everything Pro has: the tier string is not the way to
tell whether a given feature is covered.
GPU acceleration is a Researcher feature. The grant is asked directly
rather than read from the tier: a Pro licence may carry the GPU grant on
its own.
"""
if isinstance(device, str) and device.lower() in ("cuda", "gpu"):
if _grant_couvre("gpu_sweep"):
@@ -669,6 +679,19 @@ def _prepare_config(config: BacktestConfig, strategy, store: DataStore) -> Backt
resolved_sv[int(store.resolve_symbol(key))] = venue
fees.symbol_venue = resolved_sv
# --- Liquidation overrides may be keyed by symbol name, like symbol_venue ---
liq = getattr(cfg, "liquidation", None)
if liq is not None and getattr(liq, "per_symbol", None):
resolved_liq = {}
for key, rate in liq.per_symbol.items():
if isinstance(key, int):
resolved_liq[key] = rate
elif cfg.symbol_names and key in cfg.symbol_names:
resolved_liq[int(cfg.symbol_names[key])] = rate
else:
resolved_liq[int(store.resolve_symbol(key))] = rate
liq.per_symbol = resolved_liq
# Per-strategy SL/TP/trailing orders are NOT merged into the config anymore:
# they travel inside the strategy JSON (Strategy.to_json -> StrategyDef.orders)
# so the engine applies them per-strategy. This lets one batch/sweep call run
@@ -2579,15 +2602,15 @@ def run_sweep_lite(
path. ``"auto"`` picks per sweep: small grids run on the CPU (the
GPU has a ~50 ms fixed launch floor, so the CPU wins below ~1,000
combos -- override with ``MBT_GPU_AUTO_MIN_COMBOS``), large grids
run on the GPU when the build, a device, and a Pro license are
available, and the CPU otherwise. This is the default because it is
never slower than the better of the two by more than the launch
run on the GPU when the build, a device, and a Researcher licence
are available, and the CPU otherwise. This is the default because it
is never slower than the better of the two by more than the launch
floor and its results match the CPU bit-for-bit, so it is safe to
leave on: with no GPU, no Pro license, or a Community build it is
simply the CPU sweep. **Pro-only**: a Community license raises
``PermissionError`` for ``device="cuda"`` (``"auto"`` simply stays
on the CPU; Community keeps the full-speed CPU sweep with no
restriction). ``"cuda"`` requires a build with ``--features cuda``
leave on: with no GPU or a CPU-only build it is simply the CPU
sweep. **Researcher**: without it, ``device="cuda"`` raises
``LicenseError``, and ``"auto"`` runs a large grid on the CPU with a
``UserWarning`` saying so when a GPU is present; the CPU sweep
itself is full speed on every tier. ``"cuda"`` requires a build with ``--features cuda``
and a CUDA device; for any unsupported strategy/config (or when no
GPU is present at runtime) it falls back to the CPU sweep with a
``UserWarning`` naming the reason, so results are never affected.
@@ -3380,6 +3403,7 @@ __all__ = [
"calibrate_gpu",
"AccountPhase",
"AccountRules",
"Liquidation",
"account_sessions",
# Core types
"BacktestResult",
+1
View File
@@ -28,6 +28,7 @@ EXIT_REASON_LABELS: Dict[int, str] = {
4: "limit expiry",
5: "option expiry",
6: "margin call",
7: "liquidation",
}
# ``exit_reason`` of a round trip still open at the end of the run.
+2 -2
View File
@@ -87,14 +87,14 @@ def _cmd_activate(args: argparse.Namespace) -> None:
sys.exit(1)
tier, email = bt.license_info()
if tier == "Pro":
if tier in ("Pro", "Researcher"):
print(f"Activated: {tier}" + (f" ({email})" if email else ""))
# A trial activation is the one moment the user is certainly reading:
# say when it ends here, not only in the import banner.
try:
expiry = bt._license_expiry()
if expiry:
print(f"Trial license: Pro features stop on {expiry[:10]}.")
print(f"Trial license: {tier} features stop on {expiry[:10]}.")
except Exception:
pass
else:
+68
View File
@@ -1073,6 +1073,66 @@ class AccountRules:
}
@dataclass
class Liquidation:
"""How the venue liquidates a leveraged position, enforced DURING the run.
The account is cross-margined: one balance carries every position, and
the book is closed the moment equity falls to the maintenance margin,
``rate * |position| * price`` summed over the symbols held. Without it a
3x long survives every wick that would have closed it on the venue, and
the curve reads a recovery the account never traded.
import manifoldbt as mbt
cfg.execution.max_position_pct = 3.0 # 3x
cfg.liquidation = mbt.Liquidation(
maintenance_margin_rate=0.005, # 0.5 % of notional
fee_bps=50, # liquidation fee
)
Inside a bar the rule walks every position from its open toward its
adverse extreme (the low for a long, the high for a short) and closes the
book where equity meets the margin: on one symbol, at the venue's
liquidation price exactly; on several, as if their adverse extremes
coincided. A bar that opens past that point fills at the open. A stop
placed before the liquidation price fires first; one placed beyond it
never does. A gap through the bankruptcy price leaves the account at zero
rather than in debt, as a venue's insurance fund does.
Liquidations land in the trade log with ``exit_reason == 7``
("liquidation") and are counted in ``result.warnings``. The full run and
the lite sweep apply the same rule; the fast kernels and the GPU sweep
refuse the configuration by name, as does ``fill_resolution="ticks"``.
Option positions keep their own margin call (``option_margin_model``).
"""
maintenance_margin_rate: float = 0.005
"""Maintenance margin as a fraction of notional, in ``[0, 1)``."""
per_symbol: Dict[Any, float] = field(default_factory=dict)
"""Per-symbol overrides of ``maintenance_margin_rate``, keyed by symbol
name or integer id: ``{"BTCUSDT": 0.004, "DOGEUSDT": 0.01}``."""
fee_bps: float = 0.0
"""Liquidation fee in basis points of the notional closed. It replaces the
trading fee on a liquidation, as venues charge it."""
def to_json_dict(self) -> dict:
d: dict = {"maintenance_margin_rate": float(self.maintenance_margin_rate)}
if self.per_symbol:
for key in self.per_symbol:
if not isinstance(key, int):
raise ValueError(
f"Liquidation.per_symbol key {key!r} is not a symbol id: "
"names are resolved by mbt.run against the store; pass "
"integer ids when serialising the config yourself."
)
d["per_symbol"] = {
str(sid): float(r) for sid, r in self.per_symbol.items()
}
if self.fee_bps:
d["fee_bps"] = float(self.fee_bps)
return d
@dataclass
class BacktestConfig:
universe: List[int] = field(default_factory=lambda: [1])
@@ -1161,6 +1221,9 @@ class BacktestConfig:
sells an option. ``"deribit"`` applies the venue's published per-contract
formula, refuses a short that does not fit initial margin, and force-closes
the book when maintenance margin passes equity."""
liquidation: Any = None
"""Liquidation of leveraged positions (see :class:`Liquidation`).
``None`` == nothing is ever liquidated, the default."""
account_rules: Any = None
"""Prop-firm limits enforced *during* the run (see :class:`AccountRules`).
``None`` == no limits.
@@ -1266,6 +1329,11 @@ class BacktestConfig:
d["account_rules"] = (
rules.to_json_dict() if hasattr(rules, "to_json_dict") else rules
)
if self.liquidation is not None:
liq = self.liquidation
d["liquidation"] = (
liq.to_json_dict() if hasattr(liq, "to_json_dict") else liq
)
if self.book_levels is not None:
d["book_levels"] = int(self.book_levels)
# Deprecated fields (backward compat)
+1 -1
View File
@@ -13,7 +13,7 @@ import manifoldbt as bt
# Safety diagnostics are a Pro feature.
pytestmark = pytest.mark.skipif(
bt.license_info()[0] != "Pro",
bt.license_info()[0] not in ("Pro", "Researcher"),
reason="requires Pro: skipped on a Community wheel",
)
+44
View File
@@ -0,0 +1,44 @@
"""license_info() names the tier actually granted.
A Researcher licence used to report "Pro", so the one call a user makes to
check their tier could not tell the two apart. These hold on any machine,
whatever licence it carries: the tier is read against the gates themselves.
"""
import manifoldbt as bt
from manifoldbt import _grant_couvre
def test_the_tier_is_one_of_the_three():
tier, email = bt.license_info()
assert tier in ("Community", "Pro", "Researcher")
if tier == "Community":
assert email is None
def test_an_open_tick_layer_reports_researcher():
# The tick layer opens on the Researcher grant alone: no Pro licence and
# no a-la-carte grant carries it. When it is open, the tier has to say so.
if _grant_couvre("ticks"):
assert bt.license_info()[0] == "Researcher"
else:
assert bt.license_info()[0] in ("Community", "Pro")
def test_the_banner_names_the_tier():
# The banner swallows every exception and falls back to "Community": a
# name it cannot resolve at import used to print Community on a paid
# licence. It runs once, at import, so it is read from a fresh process.
import subprocess
import sys
out = subprocess.run(
[sys.executable, "-c", "import manifoldbt"],
capture_output=True, text=True, check=True,
).stdout
assert bt.license_info()[0] in out, out
def test_pro_gates_open_for_every_paid_tier():
# Researcher carries all of Pro: a Pro gate that read the tier string
# would have shut on the higher tier.
assert bt._is_pro() == (bt.license_info()[0] in ("Pro", "Researcher"))
+97
View File
@@ -0,0 +1,97 @@
"""cfg.liquidation from Python: the config reaches the engine, symbol names
resolve, and a leveraged position is closed where the venue would close it."""
import pytest
import manifoldbt as bt
from manifoldbt.expr import lit
from manifoldbt.helpers import Interval, Slippage
pd = pytest.importorskip("pandas")
LIQUIDATION = 7
def _frame():
# Flat at 100, then one bar wicks to 60 and closes at 95.
n = 48
ts = pd.date_range("2022-01-01", periods=n, freq="1h", tz="UTC")
o = [100.0] * n
h = [100.0] * n
lo = [100.0] * n
c = [100.0] * n
o[30], h[30], lo[30], c[30] = 98.0, 99.0, 60.0, 95.0
for i in range(31, n):
o[i] = h[i] = lo[i] = c[i] = 95.0
return pd.DataFrame({"timestamp": ts, "open": o, "high": h, "low": lo,
"close": c, "volume": [1e9] * n})
@pytest.fixture
def store(tmp_path):
return bt.import_dataframe(_frame(), symbol="AAA", symbol_id=1, interval="1h",
data_root=str(tmp_path / "data"),
metadata_db=str(tmp_path / "meta.sqlite"))
def _cfg(liquidation=None):
cfg = bt.BacktestConfig(
universe=["AAA"], time_range_start=0, time_range_end=4_102_444_800_000_000_000,
bar_interval=Interval.hours(1), initial_capital=1000.0,
execution=bt.ExecutionConfig(execution_price="AtClose", max_position_pct=3.0),
slippage=Slippage.fixed_bps(0.0),
)
cfg.liquidation = liquidation
return cfg
def _hold_3x():
return bt.Strategy.create("hold3x").signal("w", lit(3.0)).size(lit(3.0))
def _reasons(result):
return list(result.trades_df(backend="pandas")["exit_reason"])
def test_a_3x_long_is_liquidated_on_the_wick(store):
result = bt.run(_hold_3x(), _cfg(bt.Liquidation(maintenance_margin_rate=0.005)), store)
trades = result.trades_df(backend="pandas")
liq = trades[trades["exit_reason"] == LIQUIDATION]
assert len(liq) == 1
entry = trades.iloc[0]
# The engine's liquidation price for the position it actually opened.
cash = 1000.0 - entry["quantity"] * entry["fill_price"] - entry["fees"]
expected = -cash / (entry["quantity"] * (1.0 - 0.005))
assert liq["fill_price"].iloc[0] == pytest.approx(expected, rel=1e-9)
assert any("liquidated" in w for w in result.warnings)
def test_without_liquidation_the_wick_is_survived(store):
result = bt.run(_hold_3x(), _cfg(None), store)
assert LIQUIDATION not in _reasons(result)
def test_per_symbol_rates_accept_symbol_names(store):
# 30 % maintenance margin on AAA alone: liquidated far above the 0.5 % price.
liq = bt.Liquidation(maintenance_margin_rate=0.005, per_symbol={"AAA": 0.3})
result = bt.run(_hold_3x(), _cfg(liq), store)
trades = result.trades_df(backend="pandas")
price = trades[trades["exit_reason"] == LIQUIDATION]["fill_price"].iloc[0]
assert price > 90.0
def test_the_lite_sweep_agrees_with_the_run(store):
cfg = _cfg(bt.Liquidation(maintenance_margin_rate=0.005, fee_bps=20))
full = bt.run(_hold_3x(), cfg, store)
strat = bt.Strategy.create("sw").signal("w", lit(3.0)).size(lit(3.0) * bt.param("k"))
lite = bt.run_sweep_lite(strat, {"k": [1.0]}, cfg, store, device="cpu")
assert lite[0].final_equity == pytest.approx(full.equity_df(backend="pandas").iloc[-1, -1], rel=1e-12)
def test_an_impossible_rate_is_refused(store):
with pytest.raises(Exception, match="maintenance_margin_rate"):
bt.run(_hold_3x(), _cfg(bt.Liquidation(maintenance_margin_rate=1.5)), store)
def test_liquidation_is_labelled_in_round_trips():
from manifoldbt._trades import EXIT_REASON_LABELS
assert EXIT_REASON_LABELS[LIQUIDATION] == "liquidation"
@@ -120,7 +120,7 @@ def test_a_parameter_baked_at_research_time_flatters_the_result(tmp_path):
# two run plain backtests on daily data and measure the same leak without
# it, which is exactly what a Community wheel can still verify.
@pytest.mark.skipif(
bt.license_info()[0] != "Pro",
bt.license_info()[0] not in ("Pro", "Researcher"),
reason="requires Pro: the look-ahead detector is gated",
)
def test_re_running_alone_returns_a_clean_verdict(tmp_path):
+1 -1
View File
@@ -34,7 +34,7 @@ from manifoldbt.helpers import ExecutionPrice, Interval, Slippage # noqa: E402
# assertions count sub-daily bars: coarsened to the Community daily
# floor they compare 4 bars and conclude nothing.
pytestmark = pytest.mark.skipif(
bt.license_info()[0] != "Pro",
bt.license_info()[0] not in ("Pro", "Researcher"),
reason="requires Pro: skipped on a Community wheel",
)
+1 -1
View File
@@ -13,7 +13,7 @@ import manifoldbt as bt
# Safety diagnostics are a Pro feature.
pytestmark = pytest.mark.skipif(
bt.license_info()[0] != "Pro",
bt.license_info()[0] not in ("Pro", "Researcher"),
reason="requires Pro: skipped on a Community wheel",
)
+1 -1
View File
@@ -20,7 +20,7 @@ from manifoldbt.diagnostics import detect_lookahead
from manifoldbt.indicators import close
pro_only = pytest.mark.skipif(
bt.license_info()[0] != "Pro",
bt.license_info()[0] not in ("Pro", "Researcher"),
reason="requires Pro: the detector is a Pro feature",
)
+1 -1
View File
@@ -74,7 +74,7 @@ def test_un_param_declare_mais_lu_par_rien_ne_bloque_pas(monte):
def test_un_balayage_fixe_les_valeurs_et_passe(monte):
store, cfg = monte
if bt.license_info()[0] != "Pro":
if bt.license_info()[0] not in ("Pro", "Researcher"):
pytest.skip("requires Pro beyond the Community combination limit")
b = bt.run_sweep_lite(_croisement(bt.param("fast"), bt.param("slow")),
{"fast": [8, 12], "slow": [26]}, cfg, store)
+1 -1
View File
@@ -20,7 +20,7 @@ import pytest
import manifoldbt as bt
IS_PRO = bt.license_info()[0] == "Pro"
IS_PRO = bt.license_info()[0] in ("Pro", "Researcher")
N_BARS = 3 * 3600 # three hours of one-second bars
+1 -1
View File
@@ -212,7 +212,7 @@ def _wf_config(metric, train_days, test_days):
}
@pytest.mark.skipif(bt.license_info()[0] != "Pro",
@pytest.mark.skipif(bt.license_info()[0] not in ("Pro", "Researcher"),
reason="walk-forward: skipped on a Community wheel")
def test_walk_forward_on_windows_under_two_days(tmp_path):
# Four days of minutes; one-day training windows, one-day tests.
+1 -1
View File
@@ -13,7 +13,7 @@ from manifoldbt import run_sweep, run_with_parquet
# comparison passes vacuously. Sub-daily output is a Pro feature, so the suite
# skips rather than assert on an empty result.
pytestmark = pytest.mark.skipif(
bt.license_info()[0] != "Pro",
bt.license_info()[0] not in ("Pro", "Researcher"),
reason="requires Pro: these assertions are only meaningful at sub-daily resolution",
)
+1 -1
View File
@@ -24,7 +24,7 @@ import manifoldbt as bt
from manifoldbt.expr import col
from manifoldbt.helpers import Interval, Slippage
IS_PRO = bt.license_info()[0] == "Pro"
IS_PRO = bt.license_info()[0] in ("Pro", "Researcher")
def _locked():
+1 -1
View File
@@ -22,7 +22,7 @@ import manifoldbt as bt
# Walk-forward optimization is a Pro feature: on a Community wheel the
# gate raises before the exogenous columns are ever loaded.
pytestmark = pytest.mark.skipif(
bt.license_info()[0] != "Pro",
bt.license_info()[0] not in ("Pro", "Researcher"),
reason="requires Pro: skipped on a Community wheel",
)
@@ -12,7 +12,7 @@ import pytest
import manifoldbt as bt
pytestmark = pytest.mark.skipif(
bt.license_info()[0] != "Pro",
bt.license_info()[0] not in ("Pro", "Researcher"),
reason="requires Pro: skipped on a Community wheel",
)
+1 -1
View File
@@ -26,7 +26,7 @@ import manifoldbt as bt
# every assertion below about folds and warmup would fail on the gate,
# not on what it tests. The suite skips, and names why.
pytestmark = pytest.mark.skipif(
bt.license_info()[0] != "Pro",
bt.license_info()[0] not in ("Pro", "Researcher"),
reason="requires Pro: skipped on a Community wheel",
)
+1 -1
View File
@@ -18,7 +18,7 @@ import pytest
import manifoldbt as bt
pytestmark = pytest.mark.skipif(
bt.license_info()[0] != "Pro",
bt.license_info()[0] not in ("Pro", "Researcher"),
reason="requires Pro: skipped on a Community wheel",
)