Files
easy_tdx_max/src/easy_tdx/backtest/walkforward.py
T
GitHub 497ac21e5a fix: 策略库「重跑到今天」补齐组合分析 — 多策略组合级WF/一条龙/AI解读
策略库(/strategies)多策略组合卡片此前只有主回测:本轮把 v1.31.0
的组合分析链路延伸到多策略组合(N 策略 × 各自原标的):

- walkforward:组合 WF 泛化为槽位模型(_ComboSlot/_ComboWalkForwardBase),
  PortfolioWalkForwardEngine 行为不变;新增 MultiStrategyWalkForwardEngine
  (N 个策略各跑各自原标的,key 形如 label@symbol),复用切窗语义与
  WalkForwardResult 结构(前端 WalkForwardPanel 直接渲染)
- benchmark:新增 evaluate_multi 一条龙(MultiStrategyEngine 回测 + 多策略
  组合 WF + 逐槽位三段体检多数口径聚合 + 综合评分 + 组合评级 + 各槽位标的
  等权买入持有基准对比),报告结构与单标的 evaluate_strategy 同构
- Web:新增 POST /backtest/multi-strategy/wf/run/async 与
  /backtest/multi-strategy/evaluate/run/async;多策略组合回测响应附带
  grade/score(与单标的/多标的组合响应同构)
- 前端 StrategiesView:组合结果区新增「WF 样本外验证 / 一条龙评估 / AI 解读」
  按钮与同构面板(按需触发,复用最近一次组合回测的 items/cash);
  绩效指标表补齐 v1.28 深度 6 项(SQN/最大连胜连亏/Ulcer/VaR/CVaR);
  aiPrompt 新增 multi 模式(策略明细语境 + 槽位表现段)
- 测试:多策略 WF 引擎 3 例、evaluate_multi 3 例、新端点 Web 级 2 例、
  aiPrompt multi 模式 1 例(pytest 1611 绿、node --test 5/5、E2E 9/9)
2026-09-03 23:54:29 +08:00

615 lines
23 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.
"""Walk-Forward 样本外验证引擎(v1.25 新增)。
回测全样本收益好 ≠ 策略好——参数可能只是拟合了某一段行情。Walk-Forward
把时间轴切成多个连续窗口,逐窗独立回测,检验策略在**不同时段**是否稳定
盈利(时间维度的样本外验证)。
切窗与执行语义(借鉴 backtest-system 踩坑后的严格定义):
1. **切窗**:前 ``warmup_ratio``(默认 30%)作为初始预热区不参与评估,
其余样本均分为 ``n_windows``(默认 7)个连续测试窗。
2. **每窗独立开仓**:每个窗口从**空仓**开始、窗口结束强制了结评估——
持仓不跨窗结转。若把窗口首尾直接拼起来,跨窗持仓会被「期初买入期末
卖出」重复计收益(backtest-system v1.2.1 修复的经典坑)。
3. **指标预热不污染**:窗口开始前带 ``context_bars``(默认 60)根上下文
K 线供指标计算,用引擎的 ``warmup_bars`` 压制该区间的信号生成——指标
有历史、信号只属于窗口内。
聚合口径:
- ``window_returns``:各窗收益率列表(时间升序);
- ``consistency``:盈利窗占比(0~1,WF 稳定性的核心指标);
- ``chained_return``:各窗收益连乘(每窗独立、窗口间现金复利的近似);
- ``worst_window`` / ``best_window``:最差/最好窗收益(尾部风险直觉)。
不做什么:本引擎**不做逐窗重寻参**(经典 anchored/rolling 优化式 WF),
只做「同参数跨时段稳定性」检验——参数寻优由
:class:`~easy_tdx.backtest.optimizer.ParamGridOptimizer` 负责,两者组合
(每窗内寻参、窗外评估)留待后续版本。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import numpy as np
import pandas as pd
from easy_tdx.backtest.engine import BacktestEngine
from easy_tdx.backtest.strategy import Strategy
from easy_tdx.backtest.types import to_json_native
__all__ = [
"WalkForwardWindow",
"WalkForwardResult",
"WalkForwardEngine",
"PortfolioWalkForwardEngine",
"MultiStrategyWalkForwardEngine",
]
@dataclass
class WalkForwardWindow:
"""单个测试窗的独立回测结果。"""
index: int # 窗序号(0 起,时间升序)
start: str # 窗口首根 K 线日期(YYYY-MM-DD
end: str # 窗口末根 K 线日期
bars: int # 窗口 K 线数
total_return: float
sharpe: float
max_drawdown: float
total_trades: int
win_rate: float
performance: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return dict(
to_json_native(
{
"index": self.index,
"start": self.start,
"end": self.end,
"bars": self.bars,
"total_return": self.total_return,
"sharpe": self.sharpe,
"max_drawdown": self.max_drawdown,
"total_trades": self.total_trades,
"win_rate": self.win_rate,
"performance": self.performance,
}
)
)
@dataclass
class WalkForwardResult:
"""Walk-Forward 验证汇总。"""
n_windows: int
warmup_ratio: float
windows: list[WalkForwardWindow] = field(default_factory=list)
# 聚合指标(windows 为空时为 0/NaN 安全值)
consistency: float = 0.0 # 盈利窗占比
chained_return: float = 0.0 # 各窗收益连乘 - 1
mean_window_return: float = 0.0
median_window_return: float = 0.0
worst_window: float = 0.0
best_window: float = 0.0
mean_sharpe: float = 0.0
worst_drawdown: float = 0.0
total_trades: int = 0
@property
def window_returns(self) -> list[float]:
"""各窗收益率(时间升序)。"""
return [w.total_return for w in self.windows]
def to_dict(self) -> dict[str, Any]:
return {
"n_windows": self.n_windows,
"warmup_ratio": self.warmup_ratio,
"windows": [w.to_dict() for w in self.windows],
"consistency": self.consistency,
"chained_return": self.chained_return,
"mean_window_return": self.mean_window_return,
"median_window_return": self.median_window_return,
"worst_window": self.worst_window,
"best_window": self.best_window,
"mean_sharpe": self.mean_sharpe,
"worst_drawdown": self.worst_drawdown,
"total_trades": self.total_trades,
}
class WalkForwardEngine:
"""Walk-Forward 样本外验证:切窗、逐窗独立回测、聚合稳定性指标。
Example:
>>> wf = WalkForwardEngine(strategy=MyStrategy, n_windows=7)
>>> result = wf.run(df)
>>> result.consistency # 盈利窗占比
0.71
"""
def __init__(
self,
strategy: type[Strategy] | Strategy,
n_windows: int = 7,
warmup_ratio: float = 0.3,
context_bars: int = 60,
cash: float = 100000.0,
commission: float = 0.0003,
min_commission: float = 5.0,
stamp_tax: float = 0.001,
slippage: float = 0.0,
execution: str = "next_open",
symbol: str | None = None,
auto_fees: bool = False,
) -> None:
"""Initialize.
Args:
strategy: 策略类或实例(各窗共用同一策略与参数)。
n_windows: 测试窗数量(默认 7)。
warmup_ratio: 初始预热区占比(默认 0.3,不参与评估)。
context_bars: 每窗前置上下文 K 线数(指标预热,默认 60)。
cash / commission / min_commission / stamp_tax / slippage /
execution: 透传给各窗的 :class:`BacktestEngine`。
symbol / auto_fees: 品种感知费率(同 ``BacktestEngine``)。
"""
self._strategy = strategy
self._n_windows = max(int(n_windows), 2)
self._warmup_ratio = min(max(float(warmup_ratio), 0.0), 0.8)
self._context_bars = max(int(context_bars), 0)
self._engine_kwargs: dict[str, Any] = {
"cash": cash,
"commission": commission,
"min_commission": min_commission,
"stamp_tax": stamp_tax,
"slippage": slippage,
"execution": execution,
"symbol": symbol,
"auto_fees": auto_fees,
}
def run(self, df: pd.DataFrame) -> WalkForwardResult:
"""执行 Walk-Forward 验证。
Args:
df: 完整 K 线(datetime/open/high/low/close,时间升序)。
Returns:
:class:`WalkForwardResult`。数据不足以切窗时返回空结果
``windows`` 为空,聚合指标为 0)。
"""
result = WalkForwardResult(n_windows=self._n_windows, warmup_ratio=self._warmup_ratio)
n = len(df)
# 最少数据:每窗 ≥ 20 根 + 预热区 ≥ 20 根
min_bars = 20 * (1 + self._n_windows)
if n < min_bars:
return result
eval_start = int(n * self._warmup_ratio)
eval_len = n - eval_start
window_len = eval_len // self._n_windows
for i in range(self._n_windows):
s = eval_start + i * window_len
e = s + window_len if i < self._n_windows - 1 else n # 末窗吃到尾部
if e - s < 5:
continue
win = self._run_window(df, s, e, i)
if win is not None:
result.windows.append(win)
self._aggregate(result)
return result
def _run_window(self, df: pd.DataFrame, s: int, e: int, index: int) -> WalkForwardWindow | None:
"""独立回测单个窗口 [s, e)。
带前置上下文(指标预热),用 warmup_bars 压制上下文区间的信号;
窗口起点空仓(每窗独立开仓语义)。
"""
ctx_s = max(0, s - self._context_bars)
lead = s - ctx_s # 上下文 bar 数 = 需压制的信号数
sub = df.iloc[ctx_s:e].reset_index(drop=True)
if len(sub) < lead + 5:
return None
engine = BacktestEngine(
strategy=self._strategy,
warmup_bars=lead,
**self._engine_kwargs,
)
try:
bt = engine.run(sub)
except Exception: # noqa: BLE001 — 单窗失败不拖垮整组,跳过该窗
return None
perf = bt.performance
dt = self._dates(sub, lead)
return WalkForwardWindow(
index=index,
start=dt[0],
end=dt[1],
bars=int(e - s),
total_return=float(perf.get("total_return", 0.0)),
sharpe=float(perf.get("sharpe", 0.0)),
max_drawdown=float(perf.get("max_drawdown", 0.0)),
total_trades=int(perf.get("total_trades", 0)),
win_rate=float(perf.get("win_rate", 0.0)),
performance={k: v for k, v in perf.items()},
)
@staticmethod
def _dates(sub: pd.DataFrame, lead: int) -> tuple[str, str]:
"""取窗口起止日期(跳过 lead 根上下文)。"""
col = "datetime" if "datetime" in sub.columns else "date"
vals = sub[col].iloc[lead:]
if len(vals) == 0:
return "", ""
return (
pd.Timestamp(vals.iloc[0]).strftime("%Y-%m-%d"),
pd.Timestamp(vals.iloc[-1]).strftime("%Y-%m-%d"),
)
@staticmethod
def _aggregate(result: WalkForwardResult) -> None:
"""聚合各窗指标(空列表安全)。"""
ws = result.windows
if not ws:
return
rets = np.array([w.total_return for w in ws], dtype=float)
result.consistency = float(np.mean(rets > 0))
result.chained_return = float(np.prod(1.0 + rets) - 1.0)
result.mean_window_return = float(np.mean(rets))
result.median_window_return = float(np.median(rets))
result.worst_window = float(np.min(rets))
result.best_window = float(np.max(rets))
result.mean_sharpe = float(np.mean([w.sharpe for w in ws]))
result.worst_drawdown = float(min(w.max_drawdown for w in ws))
result.total_trades = int(sum(w.total_trades for w in ws))
class _ComboSlot:
"""组合 WF 的一个回测槽位(内部结构,由各公开引擎组装)。
Attributes:
key: 槽位标识(组合成交表的 symbol 列值)。
strategy: 策略类或实例。
df: 该槽位的 K 线。
cash: 等权分配到的资金。
symbol: 品种感知费率标识(auto_fees 用;不感知则 None)。
auto_fees: 是否按品种解析费率。
"""
__slots__ = ("key", "strategy", "df", "cash", "symbol", "auto_fees")
def __init__(
self,
key: str,
strategy: type[Strategy] | Strategy,
df: pd.DataFrame,
cash: float,
symbol: str | None = None,
auto_fees: bool = False,
) -> None:
self.key = key
self.strategy = strategy
self.df = df
self.cash = cash
self.symbol = symbol
self.auto_fees = auto_fees
class _ComboWalkForwardBase:
"""组合级 WF 共用实现:按参考时间轴切窗,逐槽位独立回测后合成组合净值。
切窗语义与 :class:`WalkForwardEngine`(单标的)一致,差异仅在「回测单元」
从单只标的换成 N 个槽位(标的或策略×标的)。
"""
def __init__(
self,
slots: list[_ComboSlot],
n_windows: int,
warmup_ratio: float,
context_bars: int,
engine_kwargs: dict[str, Any],
) -> None:
self._slots = list(slots)
self._n_windows = max(int(n_windows), 2)
self._warmup_ratio = min(max(float(warmup_ratio), 0.0), 0.8)
self._context_bars = max(int(context_bars), 0)
self._engine_kwargs = dict(engine_kwargs)
def run(self) -> WalkForwardResult:
"""执行组合 Walk-Forward 验证。
Returns:
:class:`WalkForwardResult`。数据不足以切窗时返回空结果
``windows`` 为空,聚合指标为 0)。
"""
result = WalkForwardResult(n_windows=self._n_windows, warmup_ratio=self._warmup_ratio)
if not self._slots:
return result
# 参考时间轴:全部标的 datetime 的并集(升序)
timeline = self._reference_timeline()
n = len(timeline)
# 最少数据:每窗 ≥ 20 根 + 预热区 ≥ 20 根
min_bars = 20 * (1 + self._n_windows)
if n < min_bars:
return result
eval_start = int(n * self._warmup_ratio)
eval_len = n - eval_start
window_len = eval_len // self._n_windows
for i in range(self._n_windows):
s = eval_start + i * window_len
e = s + window_len if i < self._n_windows - 1 else n # 末窗吃到尾部
if e - s < 5:
continue
win = self._run_window(timeline, s, e, i)
if win is not None:
result.windows.append(win)
WalkForwardEngine._aggregate(result)
return result
def _reference_timeline(self) -> pd.DatetimeIndex:
"""全部槽位 datetime 的并集(升序,Timestamp 化)。"""
all_dt: list[pd.Timestamp] = []
for slot in self._slots:
s = self._dt_series(slot.df)
if len(s) > 0:
all_dt.append(s)
if not all_dt:
return pd.DatetimeIndex([])
return pd.DatetimeIndex(sorted(pd.unique(pd.concat(all_dt))))
@staticmethod
def _dt_series(df: pd.DataFrame) -> pd.Series:
"""标的 K 线的 datetime 列统一转 Timestampint YYYYMMDD 兼容)。"""
col = "datetime" if "datetime" in df.columns else "date"
dt = df[col]
if dt.dtype.kind in "iu":
return pd.to_datetime(dt.astype(str), format="%Y%m%d")
if not pd.api.types.is_datetime64_any_dtype(dt):
return pd.to_datetime(dt)
return pd.Series(pd.to_datetime(dt), index=df.index)
def _run_window(
self, timeline: pd.DatetimeIndex, s: int, e: int, index: int
) -> WalkForwardWindow | None:
"""独立回测单个窗口 [s, e)(参考时间轴下标),合成组合窗内净值。"""
window_start = timeline[s]
window_end = timeline[e - 1]
ctx_start = timeline[max(0, s - self._context_bars)]
equity_series: list[pd.Series] = []
trade_frames: list[pd.DataFrame] = []
for slot in self._slots:
dt = self._dt_series(slot.df)
mask = (dt >= ctx_start) & (dt <= window_end)
sub = slot.df.loc[mask].reset_index(drop=True)
dt_sub = dt.loc[mask].reset_index(drop=True)
# 上下文 bar 数 = 窗口起点之前保留的 bar 数(warmup 压制其信号)
lead = int((dt_sub < window_start).sum())
if len(sub) < lead + 5:
continue # 该槽位数据不足(晚上市/停牌过多),本窗跳过
engine = BacktestEngine(
strategy=slot.strategy,
cash=slot.cash,
warmup_bars=lead,
symbol=slot.symbol,
auto_fees=slot.auto_fees,
**self._engine_kwargs,
)
try:
bt = engine.run(sub)
except Exception: # noqa: BLE001 — 单槽位失败不拖垮整窗
continue
# 只取窗内净值点(上下文区恒为现金,不参与窗指标,避免稀释波动率)
ec = bt.equity_curve
if len(ec) > lead:
eq = ec.iloc[lead:]
equity_series.append(
pd.Series(eq["total"].to_numpy(), index=self._dt_series(eq), name=slot.key)
)
if len(bt.trades) > 0:
t = bt.trades.copy()
t["symbol"] = slot.key
trade_frames.append(t)
if not equity_series:
return None # 所有槽位都跑不了,跳过该窗
# 合成组合窗内净值:日期并集对齐,ffill 持有不动,上市晚于窗口起点的
# 标的其前导缺口用首值回填(首值即其初始资金——还没开仓,持有现金)
aligned = pd.concat(equity_series, axis=1).sort_index()
aligned = aligned.ffill().bfill()
total = aligned.sum(axis=1)
peak = total.cummax()
drawdown = peak - total
peak_safe = peak.where(peak != 0, 1.0)
window_equity = pd.DataFrame(
{
"datetime": total.index,
"total": total.to_numpy(),
"drawdown": drawdown.to_numpy(),
"drawdown_pct": (drawdown / peak_safe).to_numpy(),
}
)
all_trades = (
pd.concat(trade_frames, ignore_index=True)
if trade_frames
else pd.DataFrame(columns=["symbol", "direction", "pnl", "rejected"])
)
from easy_tdx.backtest.performance import PerformanceAnalyzer
perf = PerformanceAnalyzer(equity_curve=window_equity, trades=all_trades).compute()
return WalkForwardWindow(
index=index,
start=window_start.strftime("%Y-%m-%d"),
end=window_end.strftime("%Y-%m-%d"),
bars=int(e - s),
total_return=float(perf.get("total_return", 0.0)),
sharpe=float(perf.get("sharpe", 0.0)),
max_drawdown=float(perf.get("max_drawdown", 0.0)),
total_trades=int(perf.get("total_trades", 0)),
win_rate=float(perf.get("win_rate", 0.0)),
performance={k: v for k, v in perf.items()},
)
class PortfolioWalkForwardEngine(_ComboWalkForwardBase):
"""组合级 Walk-Forward:一个策略 × 多只标的,逐窗独立回测并合成组合净值。
与 :class:`WalkForwardEngine`(单标的)共用切窗语义与
:class:`WalkForwardWindow` / :class:`WalkForwardResult` 结构——前端
WalkForwardPanel 无需改动即可渲染组合 WF:
1. **参考时间轴**:取全部标的 datetime 的并集(升序),按单标的同样的
规则切预热区 + ``n_windows`` 个连续测试窗;
2. **每窗独立开仓**:窗内每只标的带 ``context_bars`` 前置上下文
``warmup_bars`` 压制上下文信号),从空仓开始、窗口结束强制了结,
持仓不跨窗;
3. **组合净值合成**:各标的窗内净值按等权资金(``total_cash / N``
对齐求合成组合窗内净值,再喂 :class:`~easy_tdx.backtest.performance.PerformanceAnalyzer`
(汇总成交附 symbol 列)得到与单标的同口径的窗指标;
4. **容错**:某标的数据不足(如晚上市)则该窗跳过该标的;某窗所有
标的都跑不了则跳过该窗。
Example:
>>> wf = PortfolioWalkForwardEngine(strategy=MyStrategy, stocks=stocks, n_windows=7)
>>> result = wf.run()
>>> result.consistency # 组合盈利窗占比
0.71
"""
def __init__(
self,
strategy: type[Strategy] | Strategy,
stocks: list[Any],
n_windows: int = 7,
warmup_ratio: float = 0.3,
context_bars: int = 60,
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",
chanlun_level: str | None = None,
auto_fees: bool = False,
) -> None:
"""Initialize.
Args:
strategy: 策略类或实例(各窗各标的共用同一策略与参数)。
stocks: :class:`~easy_tdx.backtest.portfolio_engine.StockData` 列表。
n_windows / warmup_ratio / context_bars: 切窗参数(同单标的 WF)。
total_cash: 组合总资金(各标的等权分 1/N)。
其余参数: 透传给各窗各标的的 :class:`BacktestEngine`。
"""
n = max(len(list(stocks)), 1)
slots = [
_ComboSlot(
key=f"{s.market}{s.code}",
strategy=strategy,
df=s.df,
cash=total_cash / n,
symbol=f"{s.market}{s.code}",
auto_fees=auto_fees,
)
for s in stocks
]
super().__init__(
slots,
n_windows=n_windows,
warmup_ratio=warmup_ratio,
context_bars=context_bars,
engine_kwargs={
"commission": commission,
"min_commission": min_commission,
"stamp_tax": stamp_tax,
"slippage": slippage,
"execution": execution,
"chanlun_level": chanlun_level,
},
)
class MultiStrategyWalkForwardEngine(_ComboWalkForwardBase):
"""多策略组合级 Walk-Forward:N 个策略各跑各自的原标的,逐窗独立回测。
与 :class:`PortfolioWalkForwardEngine` 共用切窗语义、组合窗内净值合成与
:class:`WalkForwardResult` 输出结构(前端 WalkForwardPanel 直接复用),
唯一差异是槽位划分:每个槽位是「一个策略 × 它自己的标的」
key 形如 ``"{label}@{symbol}"``,与
:class:`~easy_tdx.backtest.multi_strategy_engine.MultiStrategyEngine` 的
``individual_results`` key 一致)。
Example:
>>> wf = MultiStrategyWalkForwardEngine(strategies=slots, n_windows=5)
>>> result = wf.run()
>>> result.consistency # 组合盈利窗占比
0.6
"""
def __init__(
self,
strategies: list[Any],
n_windows: int = 7,
warmup_ratio: float = 0.3,
context_bars: int = 60,
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:
"""Initialize.
Args:
strategies: :class:`~easy_tdx.backtest.multi_strategy_engine.StrategySlot`
列表(每个槽位已绑定策略实例与 K 线)。
n_windows / warmup_ratio / context_bars: 切窗参数(同单标的 WF)。
total_cash: 组合总资金(各槽位等权分 1/N)。
其余参数: 透传给各窗各槽位的 :class:`BacktestEngine`
(与 MultiStrategyEngine 同口径,不含 auto_fees/chanlun_level)。
"""
n = max(len(list(strategies)), 1)
slots = [
_ComboSlot(
key=f"{s.label}@{s.symbol}",
strategy=s.strategy,
df=s.df,
cash=total_cash / n,
)
for s in strategies
]
super().__init__(
slots,
n_windows=n_windows,
warmup_ratio=warmup_ratio,
context_bars=context_bars,
engine_kwargs={
"commission": commission,
"min_commission": min_commission,
"stamp_tax": stamp_tax,
"slippage": slippage,
"execution": execution,
},
)