Files
easy_tdx_max/src/easy_tdx/backtest/portfolio_engine.py
T
GitHub c49ba4c4b5 feat: 组合回测分析体系对齐单标的 — 组合级WF/一条龙/完整25项绩效/AI解读
组合回测(一策略×多标的)此前只能看 4 个数字,本轮把单标的的整条
分析链路在组合端补齐(WebUI/REST 双端):

- portfolio_engine:合并净值+汇总成交喂 PerformanceAnalyzer,输出
  完整 25 项指标(SQN/最大连胜连亏/Ulcer/VaR/CVaR 等)+ 组合层
  trades(symbol 列);修复假年化与回撤口径(负值+固定分母 →
  逐点峰值,与单标的/多策略一致)
- walkforward:新增 PortfolioWalkForwardEngine,按标的日期并集切窗、
  每窗独立开仓、合成组合窗内净值,复用 WalkForwardResult 结构
- benchmark:新增 evaluate_portfolio 一条龙(组合回测+组合WF+
  跨标的多数口径适配性体检+综合评分+组合评级+等权买入持有基准对比),
  报告结构与单标的 evaluate_strategy 同构
- performance:FIFO 持仓天数配对支持 symbol 分组
- Web:新增 POST /backtest/portfolio/wf/run/async 与
  /backtest/portfolio/evaluate/run/async;组合回测响应附带
  grade(组合净值口径)与 score;新增 _normalize_bars_dt 修复
  按标的取数路径的字符串日期/遗留 date 列崩溃(E2E 揭露)
- 前端:组合页新增附加分析勾选区与组合绩效指标/WF/一条龙/成交明细
  区块;buildPortfolioAiPrompt 组合版 Prompt;抽通用
  AiInterpretModal(回测页迁移共用,行为不变);TradeTable 支持
  showSymbol;EvaluatePanel 支持 gradeOverride
- 测试:后端 +17 例(pytest 1603 绿)、aiPrompt 组合版 2 例、
  Playwright 组合页 E2E 2 例(9/9 绿)
2026-09-03 23:22:48 +08:00

321 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.
"""多标的组合回测引擎。
支持同时回测多只股票,共享资金池,按策略信号分配资金。
每只标的独立产生信号,引擎统一管理仓位和资金。
"""
from __future__ import annotations
from dataclasses import dataclass, field
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 StockData:
"""单只标的的数据和标识。
Attributes:
code: 股票代码(如 "000001"
market: 市场(如 "SZ"
df: K线 DataFrame
"""
code: str
market: str
df: pd.DataFrame
@dataclass
class PortfolioResult:
"""组合回测结果。
Attributes:
total_performance: 组合整体绩效指标——与单标的回测同口径的完整
25 项(夏普/回撤/胜率/盈亏比/SQN/最大连胜连亏等,由合并净值
曲线 + 汇总成交喂 :class:`PerformanceAnalyzer` 计算),另附
``total_stocks`` / ``total_cash`` 两个组合字段。
individual_results: 每只标的的独立回测结果
equity_allocation: 每只标的的资金分配比例
combined_equity: 组合整体净值曲线(按日期对齐各标的求和),
列: datetime/total/drawdown/drawdown_pct。各标的独立回测日期范围
可能不同,此处按日期并集 forward-fill 对齐后求和。
trades: 组合层汇总成交(各标的 concat + ``symbol`` 列标注来源标的),
供组合级绩效统计(逐标的 FIFO 配对持仓天数)与前端明细表使用。
"""
total_performance: dict[str, float]
individual_results: dict[str, BacktestResult]
equity_allocation: dict[str, float]
combined_equity: pd.DataFrame
trades: pd.DataFrame = field(default_factory=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"),
"trades": self.trades.to_dict(orient="records"),
}
class PortfolioBacktestEngine:
"""多标的组合回测引擎。
管理多只股票的共享资金池,独立运行策略,
按均等或自定义比例分配资金。
用法::
engine = PortfolioBacktestEngine(
strategy=MyStrategy,
stocks=[
StockData("000001", "SZ", df1),
StockData("600000", "SH", df2),
],
total_cash=200000,
)
result = engine.run()
print(result.total_performance)
"""
def __init__(
self,
strategy: Strategy | type[Strategy],
stocks: list[StockData],
total_cash: float = 200_000.0,
allocation: str = "equal",
commission: float = 0.0003,
min_commission: float = 5.0,
stamp_tax: float = 0.001,
slippage: float = 0.0,
execution: str = "next_open",
chanlun_level: str | None = None,
auto_fees: bool = False,
) -> None:
"""初始化组合回测引擎。
Args:
strategy: 策略类或已构造的策略实例。传实例时(如带参数的
ParametrizedStrategy),参数会被透传到每个标的的回测。
传类时(CLI 用法)用默认参数。
stocks: 标的列表(StockData
total_cash: 总资金
allocation: 资金分配方式(目前仅 "equal" 均等分配)
commission: 佣金率
min_commission: 最低佣金
stamp_tax: 印花税
slippage: 滑点
execution: 执行模式
chanlun_level: 缠论级别(可选)
auto_fees: 为 True 时按各标的代码解析品种费率(ETF/债券免
印花税等),覆盖默认值;显式非默认费率仍优先。
.. versionadded:: 1.24
``auto_fees`` 品种感知费率(按 StockData.market+code 逐标的解析)。
"""
self._strategy = strategy
self._stocks = stocks
self._total_cash = total_cash
self._allocation = allocation
self._commission = commission
self._min_commission = min_commission
self._stamp_tax = stamp_tax
self._slippage = slippage
self._execution = execution
self._chanlun_level = chanlun_level
self._auto_fees = auto_fees
def _compute_allocations(self) -> dict[str, float]:
"""计算每只标的的资金分配。"""
n = len(self._stocks)
if n == 0:
return {}
if self._allocation == "equal":
per_stock_cash = self._total_cash / n
return {f"{s.market}{s.code}": per_stock_cash for s in self._stocks}
# 默认均等分配
per_stock_cash = self._total_cash / n
return {f"{s.market}{s.code}": per_stock_cash for s in self._stocks}
def run(self) -> PortfolioResult:
"""运行组合回测。
对每只标的独立运行回测,按分配的资金量计算收益,
最终汇总为组合整体绩效。
Returns:
PortfolioResult 包含整体绩效和各标的详细结果
"""
allocations = self._compute_allocations()
individual_results: dict[str, BacktestResult] = {}
for stock in self._stocks:
key = f"{stock.market}{stock.code}"
cash = allocations.get(key, 0)
engine = BacktestEngine(
strategy=self._strategy,
cash=cash,
commission=self._commission,
min_commission=self._min_commission,
stamp_tax=self._stamp_tax,
slippage=self._slippage,
execution=self._execution,
chanlun_level=self._chanlun_level,
symbol=key,
auto_fees=self._auto_fees,
)
result = engine.run(stock.df)
individual_results[key] = result
# 组合整体净值曲线(各标的按日期对齐求和)——绩效指标依赖它,先算
combined_equity = self._build_combined_equity(individual_results, allocations)
# 汇总整体绩效(合并净值 + 汇总成交 → PerformanceAnalyzer 完整指标)
all_trades = self._merge_trades(individual_results)
total_perf = self._aggregate_performance(
individual_results, allocations, combined_equity, all_trades
)
# 计算资金占比
total_alloc = sum(allocations.values())
equity_pct = {k: v / total_alloc if total_alloc > 0 else 0 for k, v in allocations.items()}
return PortfolioResult(
total_performance=total_perf,
individual_results=individual_results,
equity_allocation=equity_pct,
combined_equity=combined_equity,
trades=all_trades,
)
@staticmethod
def _merge_trades(results: dict[str, BacktestResult]) -> pd.DataFrame:
"""把各标的成交 concat 成组合层成交表,附 ``symbol`` 列标注来源标的。
``symbol`` 列让 PerformanceAnalyzer 的 FIFO 持仓天数配对按标的分组
(避免 A 股的买入被 B 股的卖出错误配对);无成交时返回空表。
"""
frames: list[pd.DataFrame] = []
for key, result in results.items():
if len(result.trades) > 0:
t = result.trades.copy()
t["symbol"] = key
frames.append(t)
if not frames:
return pd.DataFrame(columns=["symbol", "direction", "pnl", "rejected"])
return pd.concat(frames, ignore_index=True)
def _aggregate_performance(
self,
results: dict[str, BacktestResult],
allocations: dict[str, float],
combined_equity: pd.DataFrame,
all_trades: pd.DataFrame,
) -> dict[str, float]:
"""汇总所有标的的绩效为组合整体绩效。
与多策略引擎 ``MultiStrategyEngine._aggregate_performance`` 同口径:
合并净值曲线 + 汇总成交喂 :class:`PerformanceAnalyzer`,得到
与单标的回测一致的完整指标(夏普/回撤/胜率/盈亏比/SQN/最大连胜连亏
等 25 项),便于前端复用 MetricTable 展示。合并曲线的首个值即总投入
资金,因此 ``total_return`` 天然等于资金加权收益率。
Args:
results: 各标的回测结果
allocations: 各标的资金分配
combined_equity: 组合整体净值曲线(_build_combined_equity 产物)
all_trades: 组合层汇总成交(_merge_trades 产物,含 symbol 列)
Returns:
组合整体绩效指标(另附 total_stocks / total_cash 组合字段)
"""
from easy_tdx.backtest.performance import PerformanceAnalyzer
total_cash = sum(allocations.values())
if not results or len(combined_equity) < 2:
return {
"total_return": 0.0,
"annual_return": 0.0,
"total_stocks": float(len(results)),
"total_cash": total_cash,
}
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:
"""把各标的独立净值曲线按日期对齐求和,生成组合整体净值曲线。
各标的独立回测的日期范围可能不同(取数差异、停牌等),这里取所有标的
datetime 的并集,每个标的的 total 列 forward-fill 对齐到并集后求和。
Returns:
DataFrame: datetime / total / drawdown / drawdown_pct。
空结果时返回带表头的空 DataFrame。
"""
empty = pd.DataFrame(columns=["datetime", "total", "drawdown", "drawdown_pct"])
if not results:
return empty
# 收集各标的的 (datetime, total) 系列,以 datetime 为索引
series_list: list[pd.Series] = []
for key, result in results.items():
ec = result.equity_curve
if len(ec) == 0:
continue
# datetime 列可能是 int(YYYYMMDD) 或 datetime;统一转可比字符串/时间戳
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 各标的在缺失日期的净值(持有不动),
# 再求和得组合总净值。缺失值填 0 是为应对某标的完全无该日期数据的情况。
aligned = pd.concat(series_list, axis=1).sort_index()
aligned = aligned.ffill().fillna(0)
total = aligned.sum(axis=1)
# 回撤:drawdown 为绝对回撤额(峰值-当前,正值),drawdown_pct 为相对
# 当时峰值的回撤比例(drawdown / peak0~1)。分母必须用逐点 peak 而非
# 固定初始值:净值大涨后 peak 是初始值的好几倍,若除以 initial 会把回撤
# 百分比严重放大。与单标的 PortfolioTracker.equity_curve、
# MultiStrategyEngine._build_combined_equity 的定义保持一致,
# 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)