Files
easy_tdx_max/tests/unit/test_multi_strategy.py
T
Justin Gu e374a0da28 release: v1.32.6 — 两周改动深度审查全面修复(回测口径三件套/LLM 安全加固/涨停价舍入/时区统一/缓存与竞态等 58 处)
对 v1.21→v1.32.5 的 249 文件 4.2 万行改动做六路专项审查,本轮落地全部发现:

回测正确性:组合收益 fillna(0) 虚增、轮动停牌日过期价成交、单标的 WF 逐窗指标
被预热区稀释(三件套均带先红后绿回归);worst_drawdown 方向、grading 容错、
组合体检品种费率、寻优端点费率透传。

安全:LLM api_url 仅 http/https 且禁 userinfo(封死 file:// 读取与 Key 外送链)、
错误响应不回显原始 body、响应体 2MB 上限、配置原子写、坏配置字段级防御。

数据:涨跌停价整数分币舍入(67/318/90 个价位错 1 分漏判清零)、交易时段/采样/
provisional 统一沪时区、warehouse 增量缺口自动全量重拉、provisional 定点转正、
baostock 真故障抛错 + W/M 去 tradestatus(实测服务端报错,周月兜底此前从未工作)
+ 指数 vol 股→手(实测锚定)、ccpm 结构变更抛错。

Web API:缓存键补 count/vipdoc、NaN 清洗先于缓存、count>800 分页取全量、
submit 透传真实状态、pending 不再被淘汰成幽灵、watchlist/server 入参约束。

公式:FILTER 去副作用、0-1 值域误判收严、递归深度上限、REF 负移位显式禁止。

前端:4 处请求竞态序号守卫、Sparkline viewBox、北交所 market=2 映射、
空数据缓存死角、AI 弹窗卸载中止轮询、量能/资金日历口径修正。

CLI/CI:warehouse sync 失败 exit 1、参数校验干净报错、release 真实发布 SHA256、
CI 超时与缓存、spec 补 baostock 前提。

约 60 条回归测试先红后绿;pytest 1820 全过,ruff/mypy/vue-tsc/node --test 全绿。
2026-09-06 22:16:48 +08:00

285 lines
12 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
import pytest
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_total_return_capital_weighted_with_disjoint_dates(self) -> None:
"""晚起步槽位建仓前应按初始资金趴账(合并曲线首值=总投入资金)。
回归:旧实现对日期并集的前导缺口填 0——B 槽位起步前贡献 0 而非其
分得的 50 万,合并曲线首值 = 50 万 < 总资金 100 万,total_return 被虚增。
正确口径:前导缺口用每列首个有效值(=初始资金)回填(bfill)。
"""
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),
]
result = MultiStrategyEngine(slots, total_cash=1_000_000).run()
# 合并曲线首值 = 总投入资金(旧实现 = 500000,缺晚起步槽位的资金)
assert result.combined_equity["total"].iloc[0] == pytest.approx(1_000_000.0)
# total_return == 各槽位资金加权真实收益
weighted = sum(
0.5 * res.performance.get("total_return", 0.0)
for res in result.individual_results.values()
)
assert result.total_performance["total_return"] == pytest.approx(weighted, abs=1e-9)
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)