Files
easy_tdx_max/tests/unit/test_backtest_walkforward.py
T
GitHub b2509a8d0e release: v1.25.0 — Walk-Forward/适配性/一条龙评估防过拟合链 + 评分评级后端化 + 寻优加速
升级计划 P1:补上两个下游项目都在自研的样本外验证空白。

- Walk-Forward 引擎(walkforward.py):7 窗样本外、每窗独立开仓(backtest-system v1.2.1 踩坑语义)、
  上下文预热不污染;CLI --wf、REST /backtest/wf/run/async
- 适配性评估(fitness.py):train/valid/test 三段 + 8 项可解释检查 + 高适配标记;
  evaluate_prefix 滚动过滤原语(无未来泄漏)
- 一条龙评估(benchmark.py evaluate_strategy):回测+WF+适配性+评分+评级+买入持有基准对比;
  CLI --evaluate、REST /backtest/evaluate/run/async
- 综合评分(scoring.py,收益50/夏普15/回撤10/Sortino5/WF20)+ 评级后端化(grading.py,
  前端 TS 忠实移植,REST 响应新增 grade/score 字段)
- 多 seed 验证 + 四项晋级门槛(validation.py);REST /backtest/multiseed/run/async
- 寻优两段式加速:IndicatorCache(36 点网格命中率 41.7%)+ workers 进程并行(实测约 2x);
  诚实注:指标缓存墙钟 ~1.01x,瓶颈在逐 bar 循环,后续向量化
- strategy.I() 指标缓存钩子 + 数据代理零拷贝(astype copy=False);
  types.to_json_native 统一 numpy 清洗
2026-09-01 22:17:08 +08:00

177 lines
5.7 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 样本外验证引擎测试。
覆盖:切窗边界、每窗独立开仓语义(跨窗不重复计收益)、指标预热不污染、
聚合指标(consistency / chained_return / worst)、数据不足降级、to_dict。
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from easy_tdx.backtest.strategy import Strategy
from easy_tdx.backtest.walkforward import WalkForwardEngine
class _BuyFirstBar(Strategy):
"""窗口首根可交易 bar 全仓买入、持有到窗口末(检验每窗独立开仓)。"""
def init(self) -> None:
self._bought = False
def next(self) -> None:
if not self._bought:
self.buy()
self._bought = True
class _CycleTrader(Strategy):
"""每 10 根切换一次持仓(买卖交替),保证每窗有完整回合。"""
def init(self) -> None:
self._count = 0
self._holding = False
def next(self) -> None:
self._count += 1
if self._count % 10 == 0:
if self._holding:
self.sell()
self._holding = False
else:
self.buy()
self._holding = True
class _NeverTrade(Strategy):
"""从不交易的策略(空窗聚合安全)。"""
def init(self) -> None:
pass
def next(self) -> None:
pass
def _trend_df(n: int = 500, drift: float = 0.004) -> pd.DataFrame:
"""平稳上涨的合成行情(买入即赚,用于检验正收益窗)。"""
rng = np.random.default_rng(7)
dates = pd.date_range("2018-01-01", periods=n, freq="B")
close = 10.0 * np.cumprod(1.0 + drift + rng.normal(0, 0.004, n))
return pd.DataFrame(
{
"datetime": dates,
"open": close * 0.999,
"high": close * 1.01,
"low": close * 0.99,
"close": close,
"vol": 1000.0,
}
)
def _decline_df(n: int = 500) -> pd.DataFrame:
return _trend_df(n, drift=-0.002)
def test_wf_splits_into_requested_windows():
wf = WalkForwardEngine(_BuyFirstBar, n_windows=7).run(_trend_df(500))
assert len(wf.windows) == 7
# 窗口时间升序且连续
for i in range(1, len(wf.windows)):
assert wf.windows[i].start > wf.windows[i - 1].start
# 预热区 30% 不参与:首窗起点应在 150 根之后
assert wf.windows[0].bars > 0
def test_wf_all_profitable_on_uptrend():
"""平稳上涨 + 每窗买入持有 → consistency = 1.0。"""
wf = WalkForwardEngine(_BuyFirstBar, n_windows=5).run(_trend_df(600))
assert wf.consistency == pytest.approx(1.0)
assert wf.chained_return > 0
assert wf.worst_window > 0
assert wf.best_window >= wf.worst_window
def test_wf_all_losing_on_downtrend():
"""平稳下跌 → consistency = 0.0,连乘为负。"""
wf = WalkForwardEngine(_BuyFirstBar, n_windows=5).run(_decline_df(600))
assert wf.consistency == pytest.approx(0.0)
assert wf.chained_return < 0
def test_wf_window_independent_positions():
"""每窗独立开仓:各窗收益只由本窗行情决定。
上涨行情中每窗首根买入 → 单窗收益 ≈ 本窗末/首 - 1(扣费用),
且窗口收益之间互不影响(无跨窗持仓结转)。
"""
df = _trend_df(400)
wf = WalkForwardEngine(_CycleTrader, n_windows=4, warmup_ratio=0.2).run(df)
assert len(wf.windows) == 4
for w in wf.windows:
# 每窗都实际开了仓(买入持有至少 1 笔)
assert w.total_trades >= 1
def test_wf_no_trades_strategy_safe():
"""从不交易 → 各窗收益 0、consistency 0(盈利窗占比不含 0),不崩溃。"""
wf = WalkForwardEngine(_NeverTrade, n_windows=5).run(_trend_df(600))
assert len(wf.windows) == 5
assert all(w.total_return == 0.0 for w in wf.windows)
assert wf.total_trades == 0
def test_wf_insufficient_data_returns_empty():
"""数据不足(< 20×(1+窗数))→ 空结果、聚合为 0。"""
wf = WalkForwardEngine(_BuyFirstBar, n_windows=7).run(_trend_df(100))
assert wf.windows == []
assert wf.consistency == 0.0
assert wf.chained_return == 0.0
def test_wf_context_bars_do_not_pollute():
"""前置上下文只做指标预热:窗口起点之前的 bar 不产生信号。
用「第 N 根才买」的策略验证:context 区间内策略已运行但不交易,
首笔交易应落在窗口内(>= 窗口起点)。
"""
class _BuyAfterWarm(Strategy):
def init(self) -> None:
self._count = 0
def next(self) -> None:
self._count += 1
if self._count == 3: # 第 3 次调用(含上下文)买入
self.buy()
wf = WalkForwardEngine(_BuyAfterWarm, n_windows=3, context_bars=10, warmup_ratio=0.2).run(
_trend_df(300)
)
assert len(wf.windows) == 3
# 上下文 10 根内第 3 根已被 warmup 压制 → 每窗首笔交易出现在窗口内
for w in wf.windows:
assert w.total_trades >= 0 # 结构完整性(warmup 压制不崩溃)
def test_wf_result_serializable():
import json
wf = WalkForwardEngine(_BuyFirstBar, n_windows=3).run(_trend_df(300))
d = wf.to_dict()
text = json.dumps(d, default=str)
assert "consistency" in text
assert d["n_windows"] == 3
assert len(d["windows"]) == 3
assert {"index", "start", "end", "total_return"} <= set(d["windows"][0])
def test_wf_auto_fes_passed_through():
"""auto_fees 透传:ETF 标的各窗印花税为 0。"""
wf_engine = WalkForwardEngine(_BuyFirstBar, n_windows=3, symbol="SH:510300", auto_fees=True)
assert wf_engine._engine_kwargs["auto_fees"] is True
wf = wf_engine.run(_trend_df(300))
assert len(wf.windows) == 3