mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 22:44:22 +08:00
升级计划 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 清洗
177 lines
5.7 KiB
Python
177 lines
5.7 KiB
Python
"""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
|