Files
tick-stock-panel/backend/app/backtest/walkforward.py
T
im47cn 32d27a0a6e fix(walkforward): 子代理审查修复 — 折有效性/前视泄漏/方向退化/NaN序列化/净值曲线
三份子代理审查 (核心/API/前端) 发现的真实 bug:

核心正确性 (walkforward.py):
- [高] 折有效性抽象: best_params=None (训练全组失败) 或 OOS error 的折原会用默认
  参数硬跑/按0收益混入复利, 伪装成有效折污染 OOS 净值与退化指标。改为分流 ——
  有效折进 folds, 无效折进 skipped(带原因), 聚合只看有效折。一次修掉 H1/H2/M1。
- [高] 前视泄漏: train_end==test_start 使该日 K 线同时进训练与 OOS 首日。test_start
  后移一天隔断。
- [中] degradation 方向感知: 原 avg_is-avg_oos 对 min 类目标 (avg_holding_days) 符号
  反了。归一到越大越好空间再相减; 每折加 oos_degraded 方向感知标志。
- consistency 改为 OOS 盈利折占比 (与目标方向无关, 更直观)。

API (backtest.py):
- [高] 单折 guard: WF 缺 guard, start=None 默认拉 3 年, 每折训练窗口可能 OOM。按
  单折窗口 (train/test) 而非总区间 guard —— 总区间长本是 WF 正常形态, 按总区间拦会误杀。
- [中] NaN/inf 序列化: json.dumps(default=str) 处理不了 nan/inf, 输出非法 JSON 崩前端。
  加 _json_safe 递归清洗, 优化器与 WF 两处 done 分支都套上。

前端 (StrategyWalkForward.tsx):
- n_folds=0 门控: 全跳过时不再渲染误导性全0卡, 改显示'未产生有效折'+跳过原因。
- 渲染 OOS 拼接净值曲线 (walk-forward 核心产出, 原后端算了前端没画)。
- 每折退化标红改用后端方向感知的 oos_degraded (min 类目标 oos<is 未必退化)。

测试新增: 前视泄漏隔断 / 方向感知退化(min目标) / best_params=None跳过 /
OOS error跳过。后端 173 测试通过; 前端 tsc 无新增错误。
2026-07-11 19:20:31 +08:00

254 lines
9.5 KiB
Python

"""Walk-forward 优化 — 滚动窗口的样本内优化 + 样本外验证。
每折在训练区间用参数网格优化选出最优参数, 再在紧邻的测试区间用该参数做样本外(OOS)
回测。滚动前移。核心产出是 OOS 拼接净值 + 每折 IS-vs-OOS 退化 —— 样本内漂亮、样本外
崩溃即过拟合信号, 单次样本内回测看不到。
依赖 PR2a 的 StrategyOptimizer 做每折训练区间的网格优化。
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from datetime import date, timedelta
logger = logging.getLogger(__name__)
@dataclass
class Fold:
index: int
train_start: date
train_end: date
test_start: date
test_end: date
def generate_folds(
start: date,
end: date,
train_days: int,
test_days: int,
step_days: int,
) -> list[Fold]:
"""滚动窗口 fold 切分: 训练窗口固定长度, 测试窗口紧接其后, 按 step 前移。
测试区间超出 end 即停止。数据区间放不下一折则抛错。
"""
if train_days <= 0 or test_days <= 0 or step_days <= 0:
raise ValueError("train_days / test_days / step_days 必须为正")
folds: list[Fold] = []
i = 0
train_start = start
while True:
train_end = train_start + timedelta(days=train_days)
# 测试区间从训练末日的次日开始: 回测区间是闭区间, 若 test_start==train_end 则
# 该日 K 线同时进训练优化与 OOS 首日, 构成前视泄漏。后移一天隔断。
test_start = train_end + timedelta(days=1)
test_end = test_start + timedelta(days=test_days)
if test_end > end:
break
folds.append(Fold(i, train_start, train_end, test_start, test_end))
i += 1
train_start = train_start + timedelta(days=step_days)
if not folds:
raise ValueError(
f"数据区间不足以切出至少一折 (需 train+test={train_days + test_days}天, "
f"实有 {(end - start).days}天)"
)
return folds
def _norm(v: float, direction: str) -> float:
"""把目标值归一到"越大越好"空间, 以便跨目标一致地算退化 (min 类目标取负)。"""
return -v if direction == "min" else v
def aggregate_oos(fold_records: list[dict], objective: str, direction: str = "max") -> dict:
"""从**有效折** (IS 与 OOS 都成功) 聚合: 复利净值 / IS-OOS 退化 / 一致性。
调用方只传有效折 (best_params 非空且 OOS 未 error), 故此处每折 is_score/oos_objective
均有值, 无需 .get 默认兜底 —— 无效折被伪装成 0 收益混入曾是 H1/H2 的根因。
- compounded_oos_return: 各折 OOS 总收益复利
- degradation: 归一空间下 IS 目标均值 - OOS 目标均值, 正值 = 样本外退化 (过拟合信号),
对"越小越好"目标 (max_drawdown 等) 方向也正确
- consistency: OOS 总收益 > 0 的折占比 (与目标方向无关, 直观)
"""
n = len(fold_records)
if n == 0:
return {
"n_folds": 0,
"compounded_oos_return": 0.0,
"avg_is_objective": None,
"avg_oos_objective": None,
"degradation": None,
"consistency": 0.0,
"oos_equity_curve": [],
}
equity = 1.0
curve: list[dict] = []
n_positive = 0
for f in fold_records:
r = float(f["oos_stats"].get("total_return", 0.0) or 0.0)
equity *= (1 + r)
if r > 0:
n_positive += 1
curve.append({"fold": f["index"], "date": str(f["test_end"]), "value": round(equity, 4)})
is_vals = [f["is_score"] for f in fold_records if f["is_score"] is not None]
oos_vals = [f["oos_objective"] for f in fold_records if f["oos_objective"] is not None]
avg_is = round(float(sum(is_vals) / len(is_vals)), 4) if is_vals else None
avg_oos = round(float(sum(oos_vals) / len(oos_vals)), 4) if oos_vals else None
degradation = (
round(_norm(avg_is, direction) - _norm(avg_oos, direction), 4)
if (avg_is is not None and avg_oos is not None) else None
)
return {
"n_folds": n,
"compounded_oos_return": round(equity - 1.0, 4),
"avg_is_objective": avg_is,
"avg_oos_objective": avg_oos,
"degradation": degradation,
"consistency": round(n_positive / n, 4),
"oos_equity_curve": curve,
}
@dataclass
class WalkForwardConfig:
strategy_id: str
symbols: list[str] | None
start: date
end: date
param_grid: dict
objective: str = "sortino"
train_days: int = 252
test_days: int = 63
step_days: int = 63
direction: str | None = None
max_workers: int = 4
base_params: dict = field(default_factory=dict)
overrides: dict | None = None
backtest_kwargs: dict = field(default_factory=dict)
class WalkForwardService:
"""滚动窗口 walk-forward: 每折训练区间优化 -> 测试区间 OOS 验证 -> 聚合。"""
def __init__(self, optimizer, service, strategy_engine) -> None:
self.optimizer = optimizer
self.service = service
self.strategy_engine = strategy_engine
def run(
self,
cfg: WalkForwardConfig,
progress_cb=None,
cancel_event=None,
) -> dict:
from app.backtest.optimizer import OptimizeConfig, default_direction
from app.backtest.strategy import StrategyBacktestConfig
t0 = time.perf_counter()
direction = cfg.direction or default_direction(cfg.objective)
folds = generate_folds(cfg.start, cfg.end, cfg.train_days, cfg.test_days, cfg.step_days)
n_total = len(folds)
valid_records: list[dict] = [] # IS 与 OOS 都成功, 计入聚合
skipped: list[dict] = [] # 无优化结果 或 OOS 失败, 不计入聚合 (避免伪装成有效折)
done = 0
for f in folds:
if cancel_event is not None and cancel_event.is_set():
break
# 训练区间: 网格优化选最优参数
opt_cfg = OptimizeConfig(
strategy_id=cfg.strategy_id,
symbols=cfg.symbols,
start=f.train_start,
end=f.train_end,
param_grid=cfg.param_grid,
objective=cfg.objective,
direction=cfg.direction,
max_workers=cfg.max_workers,
base_params=cfg.base_params,
overrides=cfg.overrides,
backtest_kwargs=cfg.backtest_kwargs,
)
opt_res = self.optimizer.optimize(opt_cfg, cancel_event=cancel_event)
best_params = opt_res.get("best_params")
is_score = opt_res.get("best_score")
done += 1
base = {
"index": f.index,
"train_start": str(f.train_start),
"train_end": str(f.train_end),
"test_start": str(f.test_start),
"test_end": str(f.test_end),
}
# 训练区间没优化出参数 (全组失败/取消) -> 跳过, 不用默认参数硬跑 OOS 伪装成有效折
if best_params is None:
skipped.append({**base, "reason": "训练区间未优化出参数"})
if progress_cb is not None:
progress_cb({"type": "walkforward_progress", "done": done, "total": n_total, "fold": f.index})
continue
# 测试区间: 用最优参数做样本外回测
merged = {**cfg.base_params, **best_params}
oos_cfg = StrategyBacktestConfig(
strategy_id=cfg.strategy_id,
symbols=cfg.symbols,
start=f.test_start,
end=f.test_end,
params=merged,
overrides=cfg.overrides,
**cfg.backtest_kwargs,
)
oos_res = self.service.run(oos_cfg, cancel_event=cancel_event)
# OOS 失败 (含 cancelled) -> 跳过, 不把空/0 收益混入复利曲线
if oos_res.error:
skipped.append({**base, "best_params": best_params, "reason": f"OOS 回测失败: {oos_res.error}"})
if progress_cb is not None:
progress_cb({"type": "walkforward_progress", "done": done, "total": n_total, "fold": f.index})
continue
oos_objective = oos_res.stats.get(cfg.objective)
# 该折 OOS 是否较 IS 退化 (方向感知: min 类目标数值更大才是退化)
oos_degraded = (
_norm(oos_objective, direction) < _norm(is_score, direction)
if (oos_objective is not None and is_score is not None) else None
)
valid_records.append({
**base,
"best_params": best_params,
"is_score": is_score,
"oos_objective": oos_objective,
"oos_degraded": oos_degraded,
"oos_stats": oos_res.stats,
})
if progress_cb is not None:
progress_cb({"type": "walkforward_progress", "done": done, "total": n_total, "fold": f.index})
summary = aggregate_oos(valid_records, cfg.objective, direction)
return {
"objective": cfg.objective,
"direction": direction,
"n_folds": len(valid_records),
"n_skipped": len(skipped),
"n_planned_folds": n_total,
"folds": valid_records,
"skipped": skipped,
"summary": summary,
"elapsed_ms": round((time.perf_counter() - t0) * 1000, 1),
}