Files
easy-tdx/tests/unit/test_multi_strategy.py
T
Justin Gu 239ff46ac8 release: v1.17.13 — 修复多策略组合回测最大回撤虚高(分母误用初始值而非峰值)
用户反馈:3 个策略各自最大回撤仅 45.53%/40.16%/16.89%,组合却显示 83.76%。
根因:_build_combined_equity 的 drawdown_pct 分母误用 initial(固定初始资金),
净值大涨后(总收益 545%,峰值≈6.45×初始)绝对回撤额除以小初始值被等比放大。
改为 drawdown / peak(相对当时峰值,0~1),与单标的 PortfolioTracker 一致。
连带修复卡玛比率(年化/最大回撤)。其余指标经核对均正确。加回归守卫。
2026-07-04 21:07:39 +08:00

259 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""单元测试:多策略资金分仓组合回测引擎(MultiStrategyEngine)。
覆盖:
- 基本多策略回测(2~3 个策略,各跑各的 df,合并曲线)
- 资金均分(1/N
- individual_results 的 key 格式 "{label}@{symbol}"
- 合并净值曲线列结构 + 日期并集对齐
- 空策略列表兜底
- 同标的不同策略可区分
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from easy_tdx.backtest.multi_strategy_engine import (
MultiStrategyEngine,
StrategySlot,
)
from easy_tdx.backtest.strategy import Strategy
class SimpleBuyStrategy(Strategy):
"""简单策略:bar 5 买入,bar 30 卖出。"""
def init(self) -> None:
pass
def next(self) -> None:
if self._bar_index == 5 and self.position["size"] == 0:
self.buy(size=0)
elif self._bar_index == 30 and self.position["size"] > 0:
self.sell(size=0)
class HoldStrategy(Strategy):
"""从不交易的策略(净值曲线恒等于初始资金)。"""
def init(self) -> None:
pass
def next(self) -> None:
pass
def _make_df(n: int = 100, seed: int = 42, start: str = "2024-01-01") -> pd.DataFrame:
"""生成随机 OHLCV DataFrame(与 test_portfolio_engine 同构造方式)。"""
rng = np.random.default_rng(seed)
close = 100.0 + np.cumsum(rng.normal(0, 1, n))
high = close + rng.uniform(0, 1, n)
low = close - rng.uniform(0, 1, n)
open_ = low + rng.uniform(0, high - low, n)
vol = rng.integers(1_000_000, 10_000_000, n).astype(float)
return pd.DataFrame(
{
"datetime": pd.date_range(start, periods=n, freq="D"),
"open": open_,
"high": high,
"low": low,
"close": close,
"vol": vol,
"amount": vol * close,
}
)
class TestMultiStrategyEngine:
def test_basic_run_two_strategies(self) -> None:
"""两个策略各跑各的 df,应产出合并结果。"""
slots = [
StrategySlot("双均线", "SH:601088", SimpleBuyStrategy(), _make_df(100, seed=42)),
StrategySlot("RSI", "SZ:000001", SimpleBuyStrategy(), _make_df(100, seed=99)),
]
engine = MultiStrategyEngine(slots, total_cash=1_000_000)
result = engine.run()
# individual_results 的 key 形如 "{label}@{symbol}"
assert set(result.individual_results.keys()) == {
"双均线@SH:601088",
"RSI@SZ:000001",
}
# 整体绩效含基本字段
assert "total_return" in result.total_performance
assert result.total_performance["total_stocks"] == 2
assert result.total_performance["total_cash"] == 1_000_000
def test_total_performance_has_full_metrics(self) -> None:
"""组合整体绩效应含完整 19 项指标(夏普/回撤/胜率/盈亏比等),与单标的同口径。"""
slots = [
StrategySlot("双均线", "SH:601088", SimpleBuyStrategy(), _make_df(100, seed=42)),
StrategySlot("RSI", "SZ:000001", SimpleBuyStrategy(), _make_df(100, seed=99)),
]
perf = MultiStrategyEngine(slots, total_cash=1_000_000).run().total_performance
# 关键指标都应在(来自 PerformanceAnalyzer
for key in [
"total_return",
"annual_return",
"sharpe",
"sortino",
"calmar",
"max_drawdown",
"max_dd_duration",
"volatility",
"total_trades",
"win_trades",
"lose_trades",
"win_rate",
"profit_factor",
"avg_win",
"avg_loss",
"max_win",
"max_loss",
]:
assert key in perf, f"缺少指标 {key}"
# max_drawdown 用正值约定(与单标的一致),介于 0~1
assert 0 <= perf["max_drawdown"] <= 1
# 合并净值曲线的 drawdown 也应是正值
result = MultiStrategyEngine(slots, total_cash=1_000_000).run()
assert (result.combined_equity["drawdown"] >= 0).all()
def test_max_drawdown_relative_to_peak_not_initial(self) -> None:
"""最大回撤必须相对「当时峰值」而非「初始资金」。
回归 v1.17.11/v1.17.12 的 bugdrawdown_pct 分母误用 initial(固定初始值),
导致净值大涨后回撤被严重放大(如峰值 6x 初始时,真实 45% 回撤被算成 290%)。
构造一个大涨后回撤的场景:净值为 1→6→4(即从峰值回撤 33%),验证 max_drawdown
≈ 33%(旧逻辑会算成 200%,超出 1.0)。
"""
# 构造单标的净值序列:前 50 根 close 线性涨到 6 倍,后 50 根跌到 4 倍。
# 用从不交易的 HoldStrategy,使 total ≈ initial_cash(曲线不随 close 变)……
# 不行——HoldStrategy 净值恒为初始资金,无法制造涨跌。改用直接断言合并曲线
# 的 drawdown_pct 计算逻辑:构造两段净值的合成 df 喂给 _build_combined_equity。
from easy_tdx.backtest.types import BacktestResult
# 两根等长净值曲线:均从 1.0 涨到 6.0 再跌到 4.0(各 50 根,峰值在第 50 根)
dates = pd.date_range("2024-01-01", periods=100, freq="D")
up = np.linspace(1.0, 6.0, 50) # 0→50: 1→6
down = np.linspace(6.0, 4.0, 50) # 50→100: 6→4
totals = np.concatenate([up, down]) # 峰值 6.0 在第 50 根,谷底 4.0 在末尾
ec = pd.DataFrame(
{
"datetime": dates,
"total": totals * 100_000, # 缩放到资金量级
"drawdown": np.zeros(100),
"drawdown_pct": np.zeros(100),
}
)
# 造一个空 trades/positions 的 BacktestResult 占位
empty_df = pd.DataFrame()
fake = BacktestResult(
performance={"total_return": 3.0},
equity_curve=ec,
trades=empty_df,
positions=empty_df,
config={},
)
engine = MultiStrategyEngine.__new__(MultiStrategyEngine)
combined = engine._build_combined_equity( # noqa: SLF001 — 直接测内部算法
{"A@SZ:000001": fake}, {"A@SZ:000001": 100_000.0}
)
# 真实最大回撤(相对峰值):峰值 600000,谷底 400000,回撤 = 200000/600000 ≈ 33.3%
dd_pct = combined["drawdown_pct"].to_numpy()
max_dd = float(np.max(dd_pct))
assert 0.30 <= max_dd <= 0.36, f"max_drawdown 应≈33%,实际 {max_dd:.4f}"
# 旧 bug(除以 initial=100000)会算成 200%200000/100000),必然 >1
assert max_dd <= 1.0, "drawdown_pct 相对峰值,绝不可能超过 100%"
def test_capital_split_equal(self) -> None:
"""资金按策略数均分:每个槽位 1/N。"""
slots = [
StrategySlot("A", "SH:601088", SimpleBuyStrategy(), _make_df(50, seed=1)),
StrategySlot("B", "SZ:000001", SimpleBuyStrategy(), _make_df(50, seed=2)),
StrategySlot("C", "SZ:000002", SimpleBuyStrategy(), _make_df(50, seed=3)),
]
engine = MultiStrategyEngine(slots, total_cash=900_000)
allocs = engine._compute_allocations() # noqa: SLF001 — 测试内部均分逻辑
assert len(allocs) == 3
assert all(v == 300_000 for v in allocs.values())
# equity_allocation 是占比,各 1/3
result = engine.run()
assert all(abs(v - 1 / 3) < 1e-9 for v in result.equity_allocation.values())
def test_combined_equity_has_expected_columns(self) -> None:
"""合并净值曲线应有 datetime/total/drawdown/drawdown_pct 列。"""
slots = [
StrategySlot("A", "SH:601088", SimpleBuyStrategy(), _make_df(60, seed=7)),
]
engine = MultiStrategyEngine(slots, total_cash=500_000)
result = engine.run()
cols = set(result.combined_equity.columns)
assert {"datetime", "total", "drawdown", "drawdown_pct"} <= cols
assert len(result.combined_equity) > 0
def test_combined_equity_aligns_disjoint_dates(self) -> None:
"""两个策略日期范围不同时,合并曲线应按并集对齐(ffill)。"""
# 策略 A 跑 2024-01 起 60 根,策略 B 跑 2024-03 起 60 根
df_a = _make_df(60, seed=1, start="2024-01-01")
df_b = _make_df(60, seed=2, start="2024-03-01")
slots = [
StrategySlot("A", "SH:601088", SimpleBuyStrategy(), df_a),
StrategySlot("B", "SZ:000001", SimpleBuyStrategy(), df_b),
]
engine = MultiStrategyEngine(slots, total_cash=1_000_000)
result = engine.run()
# 合并曲线长度应至少覆盖两个范围的最晚结束日(并集)
assert len(result.combined_equity) >= 60
def test_empty_strategies_returns_empty_result(self) -> None:
"""空策略列表应返回空结果,不抛异常。"""
engine = MultiStrategyEngine([], total_cash=1_000_000)
result = engine.run()
assert result.individual_results == {}
assert result.total_performance["total_return"] == 0.0
# combined_equity 为带表头的空 DataFrame
assert len(result.combined_equity) == 0
assert set(result.combined_equity.columns) == {
"datetime",
"total",
"drawdown",
"drawdown_pct",
}
def test_same_symbol_different_strategies_distinguished(self) -> None:
"""同标的不同策略应能区分(key 含 label)。"""
df = _make_df(60, seed=5)
slots = [
StrategySlot("双均线", "SH:601088", SimpleBuyStrategy(), df.copy()),
StrategySlot("RSI", "SH:601088", HoldStrategy(), df.copy()),
]
engine = MultiStrategyEngine(slots, total_cash=1_000_000)
result = engine.run()
# 两个 key 不同,都带同一 symbol
assert "双均线@SH:601088" in result.individual_results
assert "RSI@SH:601088" in result.individual_results
def test_hold_strategy_keeps_initial_capital(self) -> None:
"""从不交易的策略,其净值曲线末值应等于初始分得资金。"""
slots = [
StrategySlot("Hold", "SH:601088", HoldStrategy(), _make_df(40, seed=1)),
]
engine = MultiStrategyEngine(slots, total_cash=1_000_000)
result = engine.run()
ec = result.individual_results["Hold@SH:601088"].equity_curve
# 不交易 → 末值 ≈ 初始资金 1_000_000(单策略拿全部)
assert abs(ec["total"].iloc[-1] - 1_000_000) < 1.0
def test_to_dict_serializable(self) -> None:
"""to_dict 应产出 JSON 兼容结构(含 individual_results / combined_equity)。"""
slots = [
StrategySlot("A", "SH:601088", SimpleBuyStrategy(), _make_df(50, seed=1)),
]
result = MultiStrategyEngine(slots, total_cash=500_000).run()
d = result.to_dict()
assert "total_performance" in d
assert "individual_results" in d
assert "combined_equity" in d
assert isinstance(d["individual_results"]["A@SH:601088"], dict)