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:
committed by
Jimmy7892
parent
64f88e0af0
commit
5c60e10a5f
@@ -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