mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 19:14:19 +08:00
对 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 全绿。
212 lines
8.1 KiB
Python
212 lines
8.1 KiB
Python
"""单元测试:组合级 Walk-Forward 引擎(PortfolioWalkForwardEngine,v1.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
|
||
|
||
|
||
# ── MultiStrategyWalkForwardEngine(v1.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)
|