release: v0.23.0

This commit is contained in:
github-actions[bot]
2026-10-23 23:06:12 +02:00
committed by Jimmy7892
parent 64f88e0af0
commit 5c60e10a5f
9 changed files with 403 additions and 44 deletions
+19 -3
View File
@@ -363,17 +363,33 @@ Slippage.spread_based(0.5) # spread-based
## Orders (SL/TP/Trailing)
Each of the three takes `side="both"` (default), `"long"` or `"short"`: an
order armed on one side leaves the other bare, so a strategy that trades
both directions can stop its shorts and let its longs run. A distance swept
from a grid (`stop_loss` as a sweep parameter) keeps the side the strategy
set.
```python
strategy = (
mbt.Strategy.create("my_strat")
.signal(...)
.size(...)
.stop_loss(pct=3.0) # 3% stop-loss
.take_profit(pct=5.0) # 5% take-profit
.trailing_stop(pct=2.0) # 2% trailing stop
.stop_loss(pct=3.0, side="short") # 3% stop-loss, on the shorts only
.take_profit(pct=5.0) # 5% take-profit, both sides
.trailing_stop(pct=2.0) # 2% trailing stop, both sides
)
```
Orders travel with the strategy, so they apply wherever it runs: `run`,
`run_batch`, `run_sweep` and `run_portfolio`.
In a portfolio each leg is an independent run on `initial_capital * weight`
and the portfolio equity is the sum of the legs. Two consequences:
`max_position_pct` clamps on the leg's equity, not the portfolio's, and with
the default `FractionOfEquity` sizing each leg compounds on its own. Set
`position_sizing_mode="FractionOfInitialCapital"` when the portfolio must
equal the sum of its legs run separately.
---
## Entry Orders
+1 -1
View File
@@ -5,7 +5,7 @@
[project]
name = "manifoldbt"
version = "0.22.0"
version = "0.23.0"
description = "Rust-powered backtesting engine for quantitative research"
readme = "README.md"
requires-python = ">=3.9"
+4
View File
@@ -1817,6 +1817,10 @@ def run_portfolio(
config: Backtest configuration (shared across all strategies).
store: Data store.
Per-strategy ``stop_loss``/``take_profit``/``trailing_stop`` are honored:
each leg runs with the orders its own JSON carries. See
:meth:`Portfolio.strategy` for what else the split into legs implies.
Returns:
A :class:`Result` with combined portfolio metrics. Access per-strategy
breakdown via ``result.per_strategy``.
+35 -7
View File
@@ -31,6 +31,27 @@ def entry_price(
return {"Signal": signal}
_ORDER_SIDES = {"both": None, "long": "Long", "short": "Short"}
def exit_order(spec: dict, side: str, where: str) -> dict:
"""``spec`` with the engine's name for ``side`` added when it is not the default.
``side`` is ``"both"`` (the default: the order arms on longs and shorts
alike), ``"long"`` or ``"short"``. The JSON carries the key only when it
departs from the default, so a strategy that never asked for a side
serializes exactly as it did before the field existed.
"""
key = side.lower() if isinstance(side, str) else side
if key not in _ORDER_SIDES:
raise ValueError(
f"{where}: side must be 'both', 'long' or 'short', got {side!r}."
)
if _ORDER_SIDES[key] is not None:
spec = {**spec, "side": _ORDER_SIDES[key]}
return spec
@dataclass
class OrderConfig:
"""Order management configuration for conditional entries, stop-loss,
@@ -48,6 +69,8 @@ class OrderConfig:
stop_loss: {"stop_pct": 2.0} — % from entry price
take_profit: {"profit_pct": 5.0} — % from entry price
trailing_stop: {"trail_pct": 3.0, "use_high": true}
Each of the three also takes "side": "Both" (default), "Long" or
"Short"; an order armed on one side leaves the other bare.
Note that a conditional entry runs on the general simulation loop, not the
fast kernel, so sweeps over one are slower than sweeps over a market entry.
@@ -59,22 +82,27 @@ class OrderConfig:
trailing_stop: Optional[dict] = None
@classmethod
def bracket(cls, stop_pct: float, profit_pct: float) -> "OrderConfig":
def bracket(
cls, stop_pct: float, profit_pct: float, side: str = "both"
) -> "OrderConfig":
"""Convenience: create a bracket order (SL + TP)."""
return cls(
stop_loss={"stop_pct": stop_pct},
take_profit={"profit_pct": profit_pct},
stop_loss=exit_order({"stop_pct": stop_pct}, side, "bracket"),
take_profit=exit_order({"profit_pct": profit_pct}, side, "bracket"),
)
@classmethod
def stop_loss_only(cls, stop_pct: float) -> "OrderConfig":
def stop_loss_only(cls, stop_pct: float, side: str = "both") -> "OrderConfig":
"""Convenience: stop-loss only."""
return cls(stop_loss={"stop_pct": stop_pct})
return cls(stop_loss=exit_order({"stop_pct": stop_pct}, side, "stop_loss_only"))
@classmethod
def trailing(cls, trail_pct: float, use_high: bool = True) -> "OrderConfig":
def trailing(
cls, trail_pct: float, use_high: bool = True, side: str = "both"
) -> "OrderConfig":
"""Convenience: trailing stop only."""
return cls(trailing_stop={"trail_pct": trail_pct, "use_high": use_high})
return cls(trailing_stop=exit_order(
{"trail_pct": trail_pct, "use_high": use_high}, side, "trailing"))
@classmethod
def limit_entry_at(
+18 -10
View File
@@ -33,19 +33,27 @@ class Portfolio:
def strategy(self, strategy: Strategy, weight: float = 1.0) -> "Portfolio":
"""Add a strategy with its capital allocation weight.
Each strategy runs as an independent backtest on ``initial_capital *
weight``; the portfolio equity is the sum of the legs. What that means
for per-strategy settings:
* ``stop_loss`` / ``take_profit`` / ``trailing_stop`` set on the
strategy are honored, exactly as in :func:`run`. A stop that must
apply to one direction only is written by splitting the legs into
two strategies and setting the stop on one of them.
* ``max_position_pct`` clamps on the LEG's equity, not the
portfolio's: a leg at weight 0.5 with ``max_position_pct=1.0`` can
never hold more than half the portfolio.
* With the default ``FractionOfEquity`` sizing each leg compounds on
its own equity. For the portfolio to equal the sum of the legs run
separately (or one strategy trading both legs), use
``position_sizing_mode="FractionOfInitialCapital"``.
Args:
strategy: A Strategy instance.
weight: Fraction of total capital (0.0 to 1.0).
weight: Fraction of total capital (0.0 to 1.0). Weights must sum
to at most 1.0.
"""
if getattr(strategy, "_orders", None):
import warnings
warnings.warn(
f"Strategy '{strategy.name}' defines stop_loss/take_profit/"
"trailing_stop orders, but portfolio mode does not support "
"per-strategy orders yet: they are IGNORED in run_portfolio().",
UserWarning,
stacklevel=2,
)
self._strategies.append({
"name": strategy.name,
"strategy_json": strategy.to_json(),
+24 -18
View File
@@ -157,42 +157,48 @@ class Strategy:
self._json_cache = None
return self
def stop_loss(self, pct: float) -> "Strategy":
def _exit_order(self, key: str, spec: Dict[str, Any], side: str) -> "Strategy":
from .config import exit_order
if self._orders is None:
self._orders = {}
self._orders[key] = exit_order(spec, side, key)
self._json_cache = None
return self
def stop_loss(self, pct: float, side: str = "both") -> "Strategy":
"""Convenience: attach a stop-loss order (returns self for chaining).
Args:
pct: Distance from entry as percentage (e.g. ``2.0`` = 2%).
side: ``"both"`` (default), ``"long"`` or ``"short"``: which
positions the stop arms on. A stop on the shorts only leaves
the longs without one.
"""
if self._orders is None:
self._orders = {}
self._orders["stop_loss"] = {"stop_pct": pct}
self._json_cache = None
return self
return self._exit_order("stop_loss", {"stop_pct": pct}, side)
def take_profit(self, pct: float) -> "Strategy":
def take_profit(self, pct: float, side: str = "both") -> "Strategy":
"""Convenience: attach a take-profit order (returns self for chaining).
Args:
pct: Distance from entry as percentage (e.g. ``5.0`` = 5%).
side: ``"both"`` (default), ``"long"`` or ``"short"``.
"""
if self._orders is None:
self._orders = {}
self._orders["take_profit"] = {"profit_pct": pct}
self._json_cache = None
return self
return self._exit_order("take_profit", {"profit_pct": pct}, side)
def trailing_stop(self, pct: float, use_high: bool = True) -> "Strategy":
def trailing_stop(
self, pct: float, use_high: bool = True, side: str = "both"
) -> "Strategy":
"""Convenience: attach a trailing stop (returns self for chaining).
Args:
pct: Trail distance as percentage (e.g. ``3.0`` = 3%).
use_high: Track bar high/low (True) or close (False).
side: ``"both"`` (default), ``"long"`` or ``"short"``.
"""
if self._orders is None:
self._orders = {}
self._orders["trailing_stop"] = {"trail_pct": pct, "use_high": use_high}
self._json_cache = None
return self
return self._exit_order(
"trailing_stop", {"trail_pct": pct, "use_high": use_high}, side
)
def _entry(
self,
-5
View File
@@ -130,8 +130,3 @@ def test_run_batch_carries_sl_tp(captured):
assert _config_orders(cfg) is None
assert all(_sl_of(s) == 1.5 for s in sent)
assert _names(sent) == ["x", "y"]
def test_portfolio_warns_on_ignored_orders():
with pytest.warns(UserWarning, match="IGNORED"):
bt.Portfolio().strategy(_strategy("p", sl=2.0), weight=1.0)
+151
View File
@@ -0,0 +1,151 @@
"""Exit orders armed on one side only.
A strategy that trades both directions could not stop its shorts without
stopping its longs: ``StopLossConfig`` carried a distance and nothing else,
and the bracket mirrored it on every position. ``side="short"`` (or
``"long"``) now arms the order on that side and leaves the other bare, on
every path that reads the distance. These tests pin the Python surface and
the general loop on the real engine; the lite path and the GPU kernel are
pinned against the general loop by the Rust parity suites.
"""
import numpy as np
import pandas as pd
import pytest
import manifoldbt as bt
from manifoldbt.config import OrderConfig
from manifoldbt.expr import col, when
from manifoldbt.helpers import Interval, Slippage
from manifoldbt.indicators import sma
from manifoldbt._trades import round_trips
N_BARS = 3000
CAPITAL = 10_000.0
EXIT_STOP_LOSS = 1 # ExitReason::StopLoss
SIDE_LONG, SIDE_SHORT = 1, 2
def _bars(seed: int = 11) -> pd.DataFrame:
rng = np.random.default_rng(seed)
close = 100.0 * np.exp(np.cumsum(rng.normal(0.0, 0.006, N_BARS)))
ts = pd.date_range("2023-01-01", periods=N_BARS, freq="1h", tz="UTC")
return pd.DataFrame({
"timestamp": ts, "open": close, "high": close * 1.004,
"low": close * 0.996, "close": close, "volume": 1000.0,
})
_DF = _bars()
_END_NS = int(_DF["timestamp"].iloc[-1].value) + 86_400_000_000_000
@pytest.fixture(scope="module")
def store(tmp_path_factory):
root = tmp_path_factory.mktemp("exit_orders_side")
return bt.import_dataframe(
_DF, symbol="TEST", symbol_id=1, interval="1h",
data_root=str(root / "data"), metadata_db=str(root / "meta.sqlite"),
)
def _config() -> bt.BacktestConfig:
# Sized off initial capital, so what a stop does to the equity on one
# side cannot change the size, hence the trades, of the other.
return bt.BacktestConfig(
universe=[1], time_range_start=0, time_range_end=_END_NS,
bar_interval=Interval.hours(1), initial_capital=CAPITAL,
execution=bt.ExecutionConfig(
signal_delay=1, execution_price="AtOpen", max_position_pct=10.0,
allow_short=True, position_sizing_mode="FractionOfInitialCapital",
),
fees=bt.FeeConfig(taker_fee_bps=5.0, maker_fee_bps=5.0),
slippage=Slippage.none(), warmup_bars=45,
)
_FAST, _SLOW = sma(col("close"), 8), sma(col("close"), 40)
def _both_ways(name: str) -> bt.Strategy:
"""Always in the market: long above the slow average, short below."""
return (bt.Strategy.create(name).signal("d", col("close"))
.size(when(_FAST > _SLOW, 0.3, -0.3)))
def _trips(result, side):
rt = round_trips(result, include_open=False)
keep = rt["side"] == side
return {k: v[keep] for k, v in rt.items()}
def _same_trades(a, b):
"""Same bars, same prices.
Fees are not compared: a flip books the closing fees on the closed leg, a
fresh entry books its own. Prices are compared to 1e-12: the pairing
averages the entry price of a position opened by a flip (``price * qty /
qty``), which lands an ulp away from the plain fill price now and then.
"""
for key in ("entry_timestamp", "exit_timestamp"):
np.testing.assert_array_equal(a[key], b[key])
for key in ("entry_price", "exit_price"):
np.testing.assert_allclose(a[key], b[key], rtol=1e-12)
def test_default_side_keeps_the_json_as_it_was():
orders = bt.Strategy.create("s").stop_loss(pct=2.0).to_json_dict()["orders"]
assert orders == {"stop_loss": {"stop_pct": 2.0}}
orders = (
bt.Strategy.create("s")
.stop_loss(pct=2.0, side="short")
.take_profit(pct=4.0, side="Long")
.trailing_stop(pct=3.0, side="both")
.to_json_dict()["orders"]
)
assert orders == {
"stop_loss": {"stop_pct": 2.0, "side": "Short"},
"take_profit": {"profit_pct": 4.0, "side": "Long"},
"trailing_stop": {"trail_pct": 3.0, "use_high": True},
}
assert OrderConfig.stop_loss_only(2.0, side="long").stop_loss == {
"stop_pct": 2.0, "side": "Long",
}
with pytest.raises(ValueError, match="side must be"):
bt.Strategy.create("s").stop_loss(pct=2.0, side="shorts")
@pytest.mark.parametrize(
"side, stopped, bare",
[("short", SIDE_SHORT, SIDE_LONG), ("long", SIDE_LONG, SIDE_SHORT)],
)
def test_stop_on_one_side_leaves_the_other_bare(store, side, stopped, bare):
free = bt.run(_both_ways("free"), _config(), store)
one_side = bt.run(
_both_ways("one_side").stop_loss(pct=2.0, side=side), _config(), store)
both = bt.run(_both_ways("both").stop_loss(pct=2.0), _config(), store)
# The stopped side has stop exits; the bare side has none and trades
# exactly as it did without any stop.
assert (_trips(one_side, stopped)["exit_reason"] == EXIT_STOP_LOSS).sum() > 0
assert (_trips(one_side, bare)["exit_reason"] == EXIT_STOP_LOSS).sum() == 0
_same_trades(_trips(one_side, bare), _trips(free, bare))
# The stopped side trades exactly as it does under a two-sided stop.
_same_trades(_trips(one_side, stopped), _trips(both, stopped))
# A two-sided stop does stop the other side: the bare side is a choice.
assert (_trips(both, bare)["exit_reason"] == EXIT_STOP_LOSS).sum() > 0
def test_swept_stop_distance_keeps_the_side(store):
"""A grid over ``stop_loss`` replaces the distance, not the side."""
base = _both_ways("swept").stop_loss(pct=1.0, side="short")
[swept] = bt.run_sweep_lite(
base, {"stop_loss": [2.0]}, _config(), store, device="cpu")
direct = bt.run(
_both_ways("direct").stop_loss(pct=2.0, side="short"), _config(), store)
two_sided = bt.run(_both_ways("two").stop_loss(pct=2.0), _config(), store)
assert swept.metrics["total_return"] == pytest.approx(
direct.metrics["total_return"], rel=1e-12)
assert swept.metrics["total_return"] != pytest.approx(
two_sided.metrics["total_return"], rel=1e-9)
+151
View File
@@ -0,0 +1,151 @@
"""run_portfolio honors the orders each strategy carries.
Orders once lived on the shared execution config, and portfolio mode could
not apply them per strategy: ``Portfolio.strategy()`` warned that they were
ignored. They now travel inside the strategy JSON (``StrategyDef.orders``) and
the portfolio runner resolves them per strategy the same way ``run`` does. The
warning outlived the fix and sent a user down a dead end (a long+short
strategy that wanted a stop on its shorts only), so the behaviour is pinned
here on the real engine: no warning, and a stop on one leg changes that leg
and only that leg.
Each leg runs on ``initial_capital * weight``; sized off initial capital, the
portfolio equals the sum of the two legs run alone, bit for bit. That
equality is what lets the per-leg exit reasons be read from separate runs:
``round_trips`` on the combined result pairs fills by symbol and would mix
the legs.
"""
import warnings
import numpy as np
import pandas as pd
import pytest
import manifoldbt as bt
from manifoldbt.expr import col, when
from manifoldbt.helpers import Interval, Slippage
from manifoldbt.indicators import sma
from manifoldbt._trades import round_trips
N_BARS = 3000
CAPITAL = 10_000.0
EXIT_STOP_LOSS = 1 # ExitReason::StopLoss
SIDE_LONG, SIDE_SHORT = 1, 2
def _bars(seed: int = 11) -> pd.DataFrame:
rng = np.random.default_rng(seed)
close = 100.0 * np.exp(np.cumsum(rng.normal(0.0, 0.006, N_BARS)))
ts = pd.date_range("2023-01-01", periods=N_BARS, freq="1h", tz="UTC")
return pd.DataFrame({
"timestamp": ts, "open": close, "high": close * 1.004,
"low": close * 0.996, "close": close, "volume": 1000.0,
})
_DF = _bars()
_END_NS = int(_DF["timestamp"].iloc[-1].value) + 86_400_000_000_000
@pytest.fixture(scope="module")
def store(tmp_path_factory):
root = tmp_path_factory.mktemp("portfolio_orders")
return bt.import_dataframe(
_DF, symbol="TEST", symbol_id=1, interval="1h",
data_root=str(root / "data"), metadata_db=str(root / "meta.sqlite"),
)
def _config(capital: float, *, sizing: str = "FractionOfInitialCapital",
max_position_pct: float = 10.0) -> bt.BacktestConfig:
return bt.BacktestConfig(
universe=[1], time_range_start=0, time_range_end=_END_NS,
bar_interval=Interval.hours(1), initial_capital=capital,
execution=bt.ExecutionConfig(
signal_delay=1, execution_price="AtOpen",
max_position_pct=max_position_pct, allow_short=True,
position_sizing_mode=sizing,
),
fees=bt.FeeConfig(taker_fee_bps=5.0, maker_fee_bps=5.0),
slippage=Slippage.none(), warmup_bars=45,
)
_FAST, _SLOW = sma(col("close"), 8), sma(col("close"), 40)
def _leg(name: str, size, stop_pct=None) -> bt.Strategy:
s = bt.Strategy.create(name).signal("d", col("close")).size(size)
return s.stop_loss(pct=stop_pct) if stop_pct is not None else s
def _long_leg(size: float = 0.6, stop_pct=None):
return _leg("long_leg", when(_FAST > _SLOW, size, 0.0), stop_pct)
def _short_leg(size: float = 0.6, stop_pct=None):
return _leg("short_leg", when(_FAST > _SLOW, 0.0, -size), stop_pct)
def _equity(result) -> np.ndarray:
return np.asarray(result.equity_curve, dtype=np.float64)
def test_strategy_with_orders_does_not_warn():
with warnings.catch_warnings():
warnings.simplefilter("error")
bt.Portfolio().strategy(_short_leg(stop_pct=2.0), weight=0.5)
def test_stop_on_one_leg_reaches_that_leg_only(store):
half = _config(CAPITAL * 0.5)
long_alone = bt.run(_long_leg(), half, store)
short_stopped = bt.run(_short_leg(stop_pct=2.0), half, store)
short_free = bt.run(_short_leg(), half, store)
portfolio = bt.run_portfolio(
bt.Portfolio()
.strategy(_long_leg(), weight=0.5)
.strategy(_short_leg(stop_pct=2.0), weight=0.5),
_config(CAPITAL), store,
)
# The portfolio is the two legs, each run with its own orders.
np.testing.assert_array_equal(
_equity(portfolio), _equity(long_alone) + _equity(short_stopped))
# The stop did something: the stopped short leg is not the free one.
assert not np.array_equal(_equity(short_stopped), _equity(short_free))
# Every stop exit sits on the short leg; the long leg has none.
rt_short = round_trips(short_stopped, include_open=False)
assert (rt_short["side"] == SIDE_SHORT).all()
assert (rt_short["exit_reason"] == EXIT_STOP_LOSS).sum() > 0
rt_long = round_trips(long_alone, include_open=False)
assert (rt_long["side"] == SIDE_LONG).all()
assert (rt_long["exit_reason"] == EXIT_STOP_LOSS).sum() == 0
def test_portfolio_equals_single_strategy_without_compounding(store):
"""The split into legs is exact when nothing depends on a leg's equity.
Weights of 0.5 give each leg half the capital, so the legs size 0.6 to
hold what the single strategy holds at 0.3; ``max_position_pct`` is
raised because it clamps on the leg's equity.
"""
both = _leg("both", when(_FAST > _SLOW, 0.3, -0.3))
split = (bt.Portfolio()
.strategy(_long_leg(0.6), weight=0.5)
.strategy(_short_leg(0.6), weight=0.5))
single = bt.run(both, _config(CAPITAL), store)
portfolio = bt.run_portfolio(split, _config(CAPITAL), store)
# Same fills, same P&L; only the rounding order differs (two legs of
# 5000 + x and 5000 + y summed, against one accumulation of 10000 + x + y),
# which shows as a last-ulp gap on a few bars.
np.testing.assert_allclose(_equity(portfolio), _equity(single), rtol=1e-12)
# Compounding breaks it: each leg then compounds on its own equity.
single_c = bt.run(both, _config(CAPITAL, sizing="FractionOfEquity"), store)
portfolio_c = bt.run_portfolio(
split, _config(CAPITAL, sizing="FractionOfEquity"), store)
assert not np.allclose(_equity(portfolio_c), _equity(single_c), rtol=1e-9)