mirror of
https://github.com/manifoldbt/manifoldbt.git
synced 2026-11-02 07:56:11 +00:00
201 lines
8.0 KiB
Python
201 lines
8.0 KiB
Python
"""Round-trip pairing and the two trade-level charts.
|
|
|
|
The pairing is pinned to the engine's own ``trade_stats`` rather than to
|
|
hand-written expectations: if ``round_trips()`` ever drifts from
|
|
the engine's own round-trip pairing, the count, win rate and expectancy stop
|
|
agreeing and these fail. The chart tests assert at the figure-spec level,
|
|
the same way ``test_plot_charts.py`` does.
|
|
"""
|
|
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 EXIT_REASON_OPEN, round_trips
|
|
|
|
N_BARS = 600
|
|
CAPITAL = 10_000.0
|
|
|
|
|
|
def _bars(seed: int = 0) -> pd.DataFrame:
|
|
rng = np.random.default_rng(seed)
|
|
close = 100 + np.cumsum(rng.normal(0, 1, 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 + 0.5, "low": close - 0.5,
|
|
"close": close, "volume": 1000.0,
|
|
})
|
|
|
|
|
|
def _run(tmp_path, name, strat, *, allow_short=False, fees=None):
|
|
df = _bars()
|
|
root = tmp_path / name
|
|
root.mkdir()
|
|
store = bt.import_dataframe(
|
|
df, symbol="TEST", symbol_id=1, interval="1h",
|
|
data_root=str(root / "data"), metadata_db=str(root / "meta.sqlite"),
|
|
)
|
|
cfg = bt.BacktestConfig(
|
|
universe=[1], time_range_start=0,
|
|
time_range_end=int(df["timestamp"].iloc[-1].value) + 86_400_000_000_000,
|
|
bar_interval=Interval.hours(1), initial_capital=CAPITAL,
|
|
execution=bt.ExecutionConfig(
|
|
signal_delay=1, execution_price="AtOpen", max_position_pct=1.0,
|
|
allow_short=allow_short,
|
|
),
|
|
fees=fees if fees is not None else bt.FeeConfig(taker_fee_bps=5.0, maker_fee_bps=5.0),
|
|
slippage=Slippage.none(), warmup_bars=20,
|
|
)
|
|
return bt.run(strat, cfg, store)
|
|
|
|
|
|
@pytest.fixture
|
|
def bracket_result(tmp_path):
|
|
"""Long-only SMA cross with a stop and a take-profit: three exit reasons."""
|
|
fast, slow = sma(col("close"), 5), sma(col("close"), 20)
|
|
strat = (bt.Strategy.create("rt_bracket").signal("d", col("close"))
|
|
.size(when(fast > slow, 1.0, 0.0)).stop_loss(pct=2.0).take_profit(pct=4.0))
|
|
return _run(tmp_path, "bracket", strat)
|
|
|
|
|
|
@pytest.fixture
|
|
def flip_result(tmp_path):
|
|
"""Always in the market, long or short: every signal change is a flip."""
|
|
fast, slow = sma(col("close"), 5), sma(col("close"), 20)
|
|
strat = (bt.Strategy.create("rt_flip").signal("d", col("close"))
|
|
.size(when(fast > slow, 1.0, -1.0)))
|
|
return _run(tmp_path, "flip", strat, allow_short=True)
|
|
|
|
|
|
def _stats(result):
|
|
ts = result.metrics["trade_stats"]
|
|
return ts["round_trips"], ts["win_rate"], ts["expectancy"], ts["avg_win"], ts["avg_loss"]
|
|
|
|
|
|
def _assert_matches_trade_stats(result):
|
|
n_rt, win_rate, expectancy, avg_win, avg_loss = _stats(result)
|
|
rt = round_trips(result, include_open=False)
|
|
pnl = rt["pnl"]
|
|
assert len(pnl) == n_rt
|
|
assert not rt["is_open"].any()
|
|
wins, losses = pnl[pnl > 0], pnl[pnl <= 0]
|
|
assert win_rate == pytest.approx(len(wins) / n_rt, abs=1e-12)
|
|
assert expectancy == pytest.approx(pnl.sum() / n_rt, rel=1e-9)
|
|
if len(wins):
|
|
assert avg_win == pytest.approx(wins.mean(), rel=1e-9)
|
|
if len(losses):
|
|
assert avg_loss == pytest.approx(losses.mean(), rel=1e-9)
|
|
return rt
|
|
|
|
|
|
def test_round_trips_match_trade_stats_with_brackets(bracket_result):
|
|
rt = _assert_matches_trade_stats(bracket_result)
|
|
assert bracket_result.trade_count > 0
|
|
# Fees are attributed: with 5 bps both ways no trade is fee-free.
|
|
assert (rt["fees"] > 0).all()
|
|
# The exit code is the exit fill's, so the bracket reasons come through.
|
|
assert set(np.unique(rt["exit_reason"])) <= {0, 1, 2, 3}
|
|
assert 1 in rt["exit_reason"] or 2 in rt["exit_reason"]
|
|
# Rows point back into the fill log and are ordered.
|
|
assert (rt["exit_row"] > rt["entry_row"]).all()
|
|
assert (rt["holding_seconds"] > 0).all()
|
|
assert (rt["side"] == 1).all()
|
|
|
|
|
|
def test_round_trips_match_trade_stats_through_flips(flip_result):
|
|
rt = _assert_matches_trade_stats(flip_result)
|
|
assert {1, 2} <= set(np.unique(rt["side"]))
|
|
# A flip closes at the flip fill and reopens there: consecutive round
|
|
# trips on one symbol share the boundary timestamp.
|
|
boundary = rt["exit_timestamp"][:-1] == rt["entry_timestamp"][1:]
|
|
assert boundary.all()
|
|
|
|
|
|
def test_open_position_is_marked_to_the_last_bar(flip_result):
|
|
closed = round_trips(flip_result, include_open=False)
|
|
with_open = round_trips(flip_result, include_open=True)
|
|
assert len(with_open) == len(closed)
|
|
n_open = int(with_open["is_open"].sum())
|
|
assert len(with_open["pnl"]) == len(closed["pnl"]) + n_open
|
|
if n_open:
|
|
last = with_open["is_open"]
|
|
assert (with_open["exit_reason"][last] == EXIT_REASON_OPEN).all()
|
|
assert (with_open["exit_row"][last] == -1).all()
|
|
pos = flip_result.positions_df()
|
|
assert with_open["exit_price"][last][0] == pytest.approx(float(pos["close"].iloc[-1]))
|
|
|
|
|
|
def test_round_trips_df_backends(bracket_result):
|
|
df = bracket_result.round_trips_df(backend="pandas")
|
|
assert list(df.columns)[:4] == ["symbol_id", "entry_timestamp", "exit_timestamp", "side"]
|
|
assert len(df) == len(round_trips(bracket_result)["pnl"])
|
|
pl = pytest.importorskip("polars")
|
|
pdf = bracket_result.round_trips_df(backend="polars")
|
|
assert isinstance(pdf, pl.DataFrame) and pdf.shape == df.shape
|
|
|
|
|
|
# ── Charts ───────────────────────────────────────────────────────────────────
|
|
|
|
pytest.importorskip("plotly")
|
|
|
|
|
|
def _traces(fig):
|
|
return {t.name: t for t in fig.data}
|
|
|
|
|
|
def test_trades_chart_draws_entries_and_outcome_coloured_exits(bracket_result):
|
|
from manifoldbt.plot import trades
|
|
from manifoldbt.plot._theme import GREEN, RED
|
|
|
|
fig = trades(bracket_result, show=False)
|
|
tr = _traces(fig)
|
|
assert "Close" in tr and "Entry" in tr
|
|
rt = round_trips(bracket_result)
|
|
assert len(tr["Entry"].x) == len(rt["pnl"])
|
|
n_win = int(((rt["pnl"] > 0) & ~rt["is_open"]).sum())
|
|
n_loss = int(((rt["pnl"] <= 0) & ~rt["is_open"]).sum())
|
|
if n_win:
|
|
assert tr["Exit (profit)"].marker.color == GREEN
|
|
assert len(tr["Exit (profit)"].x) == n_win
|
|
if n_loss:
|
|
assert tr["Exit (loss)"].marker.color == RED
|
|
assert len(tr["Exit (loss)"].x) == n_loss
|
|
# Every trace in the legend carries a name.
|
|
assert all(t.name for t in fig.data)
|
|
# Zones are on by default at this size: one vrect per closed round trip.
|
|
assert len(fig.layout.shapes) == n_win + n_loss + int(rt["is_open"].sum())
|
|
|
|
|
|
def test_trades_chart_zones_can_be_disabled(bracket_result):
|
|
from manifoldbt.plot import trades
|
|
|
|
fig = trades(bracket_result, zones=False, show=False)
|
|
assert len(fig.layout.shapes) == 0
|
|
|
|
|
|
def test_trade_pnl_scatter_scales_markers_and_switches_axis(bracket_result):
|
|
from manifoldbt.plot import trade_pnl
|
|
|
|
rt = round_trips(bracket_result)
|
|
fig = trade_pnl(bracket_result, show=False)
|
|
ys = np.concatenate([np.asarray(t.y, dtype=float) for t in fig.data])
|
|
assert np.allclose(np.sort(ys), np.sort(rt["pnl"]))
|
|
sizes = np.concatenate([np.asarray(t.marker.size, dtype=float) for t in fig.data])
|
|
assert sizes.min() >= 6.0 - 1e-9 and sizes.max() <= 14.0 + 1e-9
|
|
assert sizes.max() == pytest.approx(14.0)
|
|
assert fig.layout.yaxis.title.text.startswith("PnL")
|
|
|
|
fig_pct = trade_pnl(bracket_result, pct_scale=True, show=False)
|
|
ys = np.concatenate([np.asarray(t.y, dtype=float) for t in fig_pct.data])
|
|
assert np.allclose(np.sort(ys), np.sort(rt["return_pct"]))
|
|
assert fig_pct.layout.yaxis.title.text == "Return"
|
|
assert fig_pct.layout.yaxis.tickformat.endswith("%")
|
|
|
|
|
|
def test_result_plot_dispatch_knows_the_trade_charts(bracket_result):
|
|
assert bracket_result.plot("trades", show=False) is not None
|
|
assert bracket_result.plot("trade_pnl", show=False, pct_scale=True) is not None
|