Files
easy_tdx_max/tests/unit/test_portfolio_walkforward.py
Justin Gu e374a0da28 release: v1.32.6 — 两周改动深度审查全面修复(回测口径三件套/LLM 安全加固/涨停价舍入/时区统一/缓存与竞态等 58 处)
对 v1.21→v1.32.5 的 249 文件 4.2 万行改动做六路专项审查,本轮落地全部发现:

回测正确性:组合收益 fillna(0) 虚增、轮动停牌日过期价成交、单标的 WF 逐窗指标
被预热区稀释(三件套均带先红后绿回归);worst_drawdown 方向、grading 容错、
组合体检品种费率、寻优端点费率透传。

安全:LLM api_url 仅 http/https 且禁 userinfo(封死 file:// 读取与 Key 外送链)、
错误响应不回显原始 body、响应体 2MB 上限、配置原子写、坏配置字段级防御。

数据:涨跌停价整数分币舍入(67/318/90 个价位错 1 分漏判清零)、交易时段/采样/
provisional 统一沪时区、warehouse 增量缺口自动全量重拉、provisional 定点转正、
baostock 真故障抛错 + W/M 去 tradestatus(实测服务端报错,周月兜底此前从未工作)
+ 指数 vol 股→手(实测锚定)、ccpm 结构变更抛错。

Web API:缓存键补 count/vipdoc、NaN 清洗先于缓存、count>800 分页取全量、
submit 透传真实状态、pending 不再被淘汰成幽灵、watchlist/server 入参约束。

公式:FILTER 去副作用、0-1 值域误判收严、递归深度上限、REF 负移位显式禁止。

前端:4 处请求竞态序号守卫、Sparkline viewBox、北交所 market=2 映射、
空数据缓存死角、AI 弹窗卸载中止轮询、量能/资金日历口径修正。

CLI/CI:warehouse sync 失败 exit 1、参数校验干净报错、release 真实发布 SHA256、
CI 超时与缓存、spec 补 baostock 前提。

约 60 条回归测试先红后绿;pytest 1820 全过,ruff/mypy/vue-tsc/node --test 全绿。
2026-09-06 22:16:48 +08:00

212 lines
8.1 KiB
Python
Raw Permalink 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 引擎(PortfolioWalkForwardEnginev1.31)。"""
from __future__ import annotations
import json
from typing import Any
import numpy as np
import pandas as pd
from easy_tdx.backtest.portfolio_engine import StockData
from easy_tdx.backtest.strategy import Strategy
from easy_tdx.backtest.walkforward import PortfolioWalkForwardEngine
class PeriodicStrategy(Strategy):
"""每 10 根切换一次持仓,保证窗口内有成交(与单标的 WF 测试同思路)。"""
def init(self) -> None:
self._holding = False
def next(self) -> None:
if self._bar_index % 10 == 0 and not self._holding:
self.buy(size=0)
self._holding = True
elif self._bar_index % 10 == 5 and self._holding:
self.sell(size=0)
self._holding = False
def _make_df(n: int = 400, seed: int = 42, start: str = "2023-01-01") -> pd.DataFrame:
rng = np.random.default_rng(seed)
close = 100.0 + np.cumsum(rng.normal(0, 1, n))
high = close + rng.uniform(0, 1, n)
low = close - rng.uniform(0, 1, n)
open_ = low + rng.uniform(0, high - low, n)
vol = rng.integers(1_000_000, 10_000_000, n).astype(float)
return pd.DataFrame(
{
"datetime": pd.date_range(start, periods=n, freq="D"),
"open": open_,
"high": high,
"low": low,
"close": close,
"vol": vol,
"amount": vol * close,
}
)
def _stocks() -> list[StockData]:
return [
StockData("000001", "SZ", _make_df(400, seed=42)),
StockData("600000", "SH", _make_df(400, seed=99)),
]
class TestPortfolioWalkForward:
def test_basic_structure(self) -> None:
"""切窗数量、窗口字段与聚合指标齐全。"""
wf = PortfolioWalkForwardEngine(
strategy=PeriodicStrategy, stocks=_stocks(), n_windows=4, total_cash=200_000
).run()
assert len(wf.windows) == 4
for i, w in enumerate(wf.windows):
assert w.index == i
assert w.start <= w.end
assert w.bars > 0
# 窗口时间升序且不重叠
starts = [pd.Timestamp(w.start) for w in wf.windows]
assert starts == sorted(starts)
assert wf.total_trades > 0
def test_aggregates_consistency_and_chained(self) -> None:
"""consistency = 盈利窗占比,chained = 各窗连乘 - 1。"""
wf = PortfolioWalkForwardEngine(
strategy=PeriodicStrategy, stocks=_stocks(), n_windows=5
).run()
rets = [w.total_return for w in wf.windows]
assert wf.consistency == sum(1 for r in rets if r > 0) / len(rets)
chained = float(np.prod([1.0 + r for r in rets]) - 1.0)
assert wf.chained_return == pd.Series([chained]).iloc[0]
def test_insufficient_data_returns_empty(self) -> None:
"""数据不足以切窗时返回空结果(windows 为空、聚合指标为 0)。"""
stocks = [StockData("000001", "SZ", _make_df(50, seed=1))]
wf = PortfolioWalkForwardEngine(strategy=PeriodicStrategy, stocks=stocks, n_windows=7).run()
assert wf.windows == []
assert wf.consistency == 0.0
def test_empty_stocks_returns_empty(self) -> None:
wf = PortfolioWalkForwardEngine(strategy=PeriodicStrategy, stocks=[], n_windows=3).run()
assert wf.windows == []
def test_late_listing_stock_tolerated(self) -> None:
"""晚上市的标的不该拖垮整窗(该窗跳过它,其余照常)。"""
stocks = [
StockData("000001", "SZ", _make_df(400, seed=42)),
StockData("688981", "SH", _make_df(100, seed=7, start="2024-02-01")),
]
wf = PortfolioWalkForwardEngine(strategy=PeriodicStrategy, stocks=stocks, n_windows=4).run()
assert len(wf.windows) == 4
assert all(w.total_trades > 0 for w in wf.windows)
def test_window_independent_opening(self) -> None:
"""每窗独立开仓:窗口总交易数应等于窗内各标的回合数(无跨窗结转)。"""
stocks = _stocks()
n_windows = 4
wf = PortfolioWalkForwardEngine(
strategy=PeriodicStrategy, stocks=stocks, n_windows=n_windows
).run()
# PeriodicStrategy 每 10 根一个回合,窗长约 56 根 → 每标的每窗 5 回合上下,
# 总交易数应为正且与窗口长度量级一致(防止持仓跨窗导致的重复/丢失计数)。
assert wf.total_trades > 0
assert wf.total_trades == sum(w.total_trades for w in wf.windows)
def test_to_dict_serializable(self) -> None:
wf = PortfolioWalkForwardEngine(
strategy=PeriodicStrategy, stocks=_stocks(), n_windows=3
).run()
d = wf.to_dict()
assert len(d["windows"]) == len(wf.windows)
# JSON 兼容(numpy 标量已清洗)
json.dumps(d)
# 每窗 performance 为完整指标 dict(含 SQN 等深度指标)
assert "sqn" in d["windows"][0]["performance"]
assert "max_consecutive_wins" in d["windows"][0]["performance"]
def test_min_windows_guard(self) -> None:
"""n_windows < 2 至少取 2(与单标的 WF 同保护)。"""
wf = PortfolioWalkForwardEngine(
strategy=PeriodicStrategy, stocks=_stocks(), n_windows=0
).run()
assert wf.n_windows == 2
# ── MultiStrategyWalkForwardEnginev1.31.1:多策略组合槽位 WF)───────────────
def _slots() -> list[Any]:
from easy_tdx.backtest.multi_strategy_engine import StrategySlot
return [
StrategySlot(
label="双均线交叉",
symbol="SH:601088",
strategy=PeriodicStrategy(),
df=_make_df(400, seed=42),
),
StrategySlot(
label="RSI反转",
symbol="SZ:000001",
strategy=PeriodicStrategy(),
df=_make_df(400, seed=99),
),
]
def test_multi_strategy_wf_basic_structure() -> None:
from easy_tdx.backtest.walkforward import MultiStrategyWalkForwardEngine
wf = MultiStrategyWalkForwardEngine(strategies=_slots(), n_windows=4, total_cash=200_000).run()
assert len(wf.windows) == 4
assert wf.total_trades > 0
assert wf.total_trades == sum(w.total_trades for w in wf.windows)
# 窗口时间升序
starts = [pd.Timestamp(w.start) for w in wf.windows]
assert starts == sorted(starts)
def test_multi_strategy_wf_matches_portfolio_structure() -> None:
"""与 PortfolioWalkForwardEngine 输出同构(前端面板可复用)。"""
from easy_tdx.backtest.walkforward import MultiStrategyWalkForwardEngine
wf = MultiStrategyWalkForwardEngine(strategies=_slots(), n_windows=3).run()
d = wf.to_dict()
json.dumps(d)
assert "sqn" in d["windows"][0]["performance"]
assert "max_consecutive_wins" in d["windows"][0]["performance"]
def test_multi_strategy_wf_empty_slots() -> None:
from easy_tdx.backtest.walkforward import MultiStrategyWalkForwardEngine
wf = MultiStrategyWalkForwardEngine(strategies=[], n_windows=3).run()
assert wf.windows == []
def test_combo_slot_failure_logs_warning(caplog):
"""单槽位回测异常记 warning(含槽位标识),不拖垮整窗(旧码静默 continue)。"""
import logging
from easy_tdx.backtest.multi_strategy_engine import StrategySlot
from easy_tdx.backtest.walkforward import MultiStrategyWalkForwardEngine
class Boom(Strategy):
def init(self) -> None:
raise RuntimeError("slot-boom")
def next(self) -> None:
pass
slots = [
StrategySlot(
label="正常", symbol="SH:601088", strategy=PeriodicStrategy(), df=_make_df(400, seed=42)
),
StrategySlot(label="炸裂", symbol="SZ:000001", strategy=Boom(), df=_make_df(400, seed=99)),
]
with caplog.at_level(logging.WARNING, logger="easy_tdx.backtest.walkforward"):
wf = MultiStrategyWalkForwardEngine(strategies=slots, n_windows=3).run()
assert len(wf.windows) == 3 # 正常槽位照常出窗
assert wf.total_trades > 0
assert any("炸裂" in r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING)