mirror of
https://github.com/manifoldbt/manifoldbt.git
synced 2026-11-02 07:56:11 +00:00
release: v0.23.0
This commit is contained in:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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``.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user