Files
easy_tdx_max/src/easy_tdx/backtest/multi_strategy_engine.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

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.
"""多策略组合回测引擎(资金分仓 / 并行制)。
与 :class:`~easy_tdx.backtest.portfolio_engine.PortfolioBacktestEngine` 的区别:
- 后者是「**一个**策略 × **多只**股票」,资金按股票均分。
- 本引擎是「**多个**策略 × **各自**原标的」,资金按策略均分,每个策略独立回测,
各自的净值曲线按日期对齐后求和,得到组合整体净值曲线。
典型场景:用户在策略库勾选若干「好策略」,各跑在它保存时的标的上,看综合表现。
用法::
engine = MultiStrategyEngine(
strategies=[
StrategySlot(label="双均线交叉", symbol="SH:601088", strategy=strat_a, df=df_a),
StrategySlot(label="RSI反转", symbol="SZ:000001", strategy=strat_b, df=df_b),
],
total_cash=1_000_000,
)
result = engine.run()
print(result.total_performance)
输出结构与 :class:`~easy_tdx.backtest.portfolio_engine.PortfolioResult` 一致,便于
前端复用组合页的净值曲线 / 对比表 / 叠加图组件。``individual_results`` 的 key 形如
``"双均线交叉@SH:601088"``(既能区分同标的不同策略,又一眼看清跑哪个票)。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import pandas as pd
from easy_tdx.backtest.engine import BacktestEngine
from easy_tdx.backtest.strategy import Strategy
from easy_tdx.backtest.types import BacktestResult
@dataclass
class StrategySlot:
"""单个策略槽位:一个已构造的策略实例 + 它要跑的标的标识与 K 线。
Attributes:
label: 策略展示名(如 "双均线交叉"),用于拼 individual_results 的 key。
symbol: 标的完整代码(如 "SH:601088"),仅用于标识与展示。
strategy: 已构造(带参数)的策略实例。
df: 该标的的 K 线 DataFrame。
"""
label: str
symbol: str
strategy: Strategy
df: pd.DataFrame
@dataclass
class MultiStrategyResult:
"""多策略组合回测结果(字段语义与 PortfolioResult 对齐,便于前端复用)。
Attributes:
total_performance: 组合整体绩效(资金加权收益率 + 策略数 + 总资金)。
individual_results: 每个策略槽位的独立回测结果,key 形如 "{label}@{symbol}"。
equity_allocation: 每个槽位的资金分配比例(均分时各 1/N)。
combined_equity: 组合整体净值曲线(各槽位按日期并集 ffill 对齐后求和,
晚起步槽位的前导缺口按首个净值=初始资金回填),
列: datetime / total / drawdown / drawdown_pct。
"""
total_performance: dict[str, float]
individual_results: dict[str, BacktestResult]
equity_allocation: dict[str, float]
combined_equity: pd.DataFrame
def to_dict(self) -> dict[str, Any]:
return {
"total_performance": self.total_performance,
"individual_results": {k: v.to_dict() for k, v in self.individual_results.items()},
"equity_allocation": self.equity_allocation,
"combined_equity": self.combined_equity.to_dict(orient="records"),
}
class MultiStrategyEngine:
"""多策略资金分仓组合回测引擎。
把总资金按策略数均分,每个策略在各自的 K 线上独立回测(各跑各的),
再把各净值曲线按日期对齐求和,得到组合整体净值。资金分配方式固定为
"equal"(均分)——多策略组合的目标是"看综合表现",均分是最直接的基线。
参数与 :class:`~easy_tdx.backtest.portfolio_engine.PortfolioBacktestEngine`
对齐(``strategy``/``stocks`` 换成 ``strategies``),便于复用资金/成本配置。
"""
def __init__(
self,
strategies: list[StrategySlot],
total_cash: float = 1_000_000.0,
commission: float = 0.0003,
min_commission: float = 5.0,
stamp_tax: float = 0.001,
slippage: float = 0.0,
execution: str = "next_open",
) -> None:
self._strategies = strategies
self._total_cash = total_cash
self._commission = commission
self._min_commission = min_commission
self._stamp_tax = stamp_tax
self._slippage = slippage
self._execution = execution
def _compute_allocations(self) -> dict[str, float]:
"""资金均分:每个策略槽位拿 total_cash / N。"""
n = len(self._strategies)
if n == 0:
return {}
per = self._total_cash / n
return {self._key(s): per for s in self._strategies}
@staticmethod
def _key(s: StrategySlot) -> str:
"""individual_results / allocation 的统一 key"{label}@{symbol}"。"""
return f"{s.label}@{s.symbol}"
def run(self) -> MultiStrategyResult:
"""逐策略独立回测,再汇总成组合整体绩效与合并净值曲线。"""
allocations = self._compute_allocations()
individual_results: dict[str, BacktestResult] = {}
for slot in self._strategies:
key = self._key(slot)
cash = allocations.get(key, 0)
engine = BacktestEngine(
strategy=slot.strategy,
cash=cash,
commission=self._commission,
min_commission=self._min_commission,
stamp_tax=self._stamp_tax,
slippage=self._slippage,
execution=self._execution,
)
individual_results[key] = engine.run(slot.df)
total_alloc = sum(allocations.values())
equity_pct = {k: v / total_alloc if total_alloc > 0 else 0 for k, v in allocations.items()}
combined_equity = self._build_combined_equity(individual_results, allocations)
total_perf = self._aggregate_performance(individual_results, allocations, combined_equity)
return MultiStrategyResult(
total_performance=total_perf,
individual_results=individual_results,
equity_allocation=equity_pct,
combined_equity=combined_equity,
)
def _aggregate_performance(
self,
results: dict[str, BacktestResult],
allocations: dict[str, float],
combined_equity: pd.DataFrame,
) -> dict[str, float]:
"""组合整体绩效:基于合并净值曲线 + 汇总成交算完整 19 项指标。
与 PortfolioBacktestEngine 仅给 4 个字段不同,这里把合并净值曲线和所有
槽位的成交汇总,喂给 PerformanceAnalyzer,得到与单标的回测同口径的完整
指标(夏普/回撤/胜率/盈亏比等),便于前端复用 MetricTable 展示。
"""
from easy_tdx.backtest.performance import PerformanceAnalyzer
total_cash = sum(allocations.values())
base: dict[str, float] = {
"total_stocks": float(len(results)), # 字段名沿用 PortfolioResult
"total_cash": total_cash,
}
if not results or len(combined_equity) < 2:
base.update({"total_return": 0.0, "annual_return": 0.0})
return base
# 汇总所有槽位的成交(concat 成一张表,PerformanceAnalyzer 据此算
# 胜率/盈亏比/平均盈亏等交易类指标)。所有策略均无成交时给空表兜底。
trade_frames = [r.trades for r in results.values() if len(r.trades) > 0]
all_trades = (
pd.concat(trade_frames, ignore_index=True)
if trade_frames
else pd.DataFrame(columns=["direction", "pnl", "rejected"])
)
analyzer = PerformanceAnalyzer(equity_curve=combined_equity, trades=all_trades)
metrics = analyzer.compute()
metrics["total_stocks"] = float(len(results))
metrics["total_cash"] = total_cash
return metrics
def _build_combined_equity(
self,
results: dict[str, BacktestResult],
allocations: dict[str, float],
) -> pd.DataFrame:
"""把各策略独立净值曲线按日期并集 ffill 对齐后求和。
算法与 ``PortfolioBacktestEngine._build_combined_equity`` 一致:
各策略回测日期范围可能不同(取数差异、停牌),取 datetime 并集,
每个策略的 total 列 forward-fill 对齐到并集后求和得组合总净值,
再算回撤。前导缺口(晚起步槽位)按其首个净值(=初始资金)回填
(bfill),保证合并曲线首值等于总投入资金。
"""
del allocations # 资金分配不参与曲线形状(各策略独立 full cash 回测,
# 合并的是 normalized 的净值贡献;保持签名与 Portfolio 版一致便于对照)
empty = pd.DataFrame(columns=["datetime", "total", "drawdown", "drawdown_pct"])
if not results:
return empty
series_list: list[pd.Series] = []
for key, result in results.items():
ec = result.equity_curve
if len(ec) == 0:
continue
dt = ec["datetime"]
if dt.dtype.kind in "iu": # int YYYYMMDD
dt = pd.to_datetime(dt.astype(str), format="%Y%m%d")
elif dt.dtype != "datetime64[ns]":
dt = pd.to_datetime(dt)
s = pd.Series(ec["total"].to_numpy(), index=dt, name=key)
series_list.append(s)
if not series_list:
return empty
# 外连接对齐(并集日期):各槽位在缺失日期 forward-fill(持有不动);
# 前导缺口(晚起步槽位)用每列首个有效值回填(bfill)——资金在组合
# 起点即已分配,建仓前按初始资金趴账,与 PortfolioBacktestEngine 及
# 组合 Walk-Forward 的口径一致。此前前导缺口填 0:合并曲线首值会小于
# 总投入资金,total_return 被系统性虚增。
# 退化兜底:整列全 NaN(理论不可达——无数据的槽位不会进入 series_list
# 显式落 0,避免 sum 传播 NaN。
aligned = pd.concat(series_list, axis=1).sort_index()
aligned = aligned.ffill().bfill().fillna(0.0)
total = aligned.sum(axis=1)
# 回撤:drawdown 为绝对回撤额(峰值-当前,正值),drawdown_pct 为相对当时
# 峰值的回撤比例(drawdown / peak0~1)。分母必须用逐点 peak 而非固定初始值:
# 净值大涨后 peak 是初始值的好几倍,若除以 initial 会把回撤百分比严重放大
# (如峰值 6.45x 初始时,45% 的真实回撤会被算成 293%)。与单标的
# PortfolioTracker.equity_curve 的 drawdown/drawdown_pct 定义保持一致,
# PerformanceAnalyzer 直接读 drawdown_pct 列算 max_drawdown。
peak = total.cummax()
drawdown = peak - total
peak_safe = peak.where(peak != 0, 1.0)
drawdown_pct = drawdown / peak_safe
return pd.DataFrame(
{
"datetime": total.index,
"total": total.to_numpy(),
"drawdown": drawdown.to_numpy(),
"drawdown_pct": drawdown_pct.to_numpy(),
}
).reset_index(drop=True)