Files
easy_tdx_max/tests/unit/test_backtest_fitness_benchmark.py
T
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

335 lines
12 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.
"""适配性评估(fitness+ 一条龙评估(benchmark)测试。"""
from __future__ import annotations
import json
from typing import Any
import numpy as np
import pandas as pd
import pytest
from easy_tdx.backtest.benchmark import evaluate_strategy, run_buy_hold_benchmark
from easy_tdx.backtest.fitness import FitnessEngine, rolling_fitness_scores
from easy_tdx.backtest.strategy import Strategy
class _BuyFirstBar(Strategy):
def init(self) -> None:
self._bought = False
def next(self) -> None:
if not self._bought:
self.buy()
self._bought = True
class _CycleTrader(Strategy):
"""每 10 根切换一次持仓(买卖交替),保证各段有完整回合(total_trades>0)。"""
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
def _df(n: int = 500, drift: float = 0.004) -> pd.DataFrame:
rng = np.random.default_rng(3)
dates = pd.date_range("2018-01-01", periods=n, freq="B")
close = 10.0 * np.cumprod(1.0 + drift + rng.normal(0, 0.006, n))
return pd.DataFrame(
{
"datetime": dates,
"open": close * 0.999,
"high": close * 1.01,
"low": close * 0.99,
"close": close,
"vol": 1000.0,
}
)
# ── FitnessEngine ─────────────────────────────────────────────────────────────
def test_fitness_three_segments_and_checks():
rep = FitnessEngine(_CycleTrader).evaluate(_df(600))
assert [s.name for s in rep.segments] == ["train", "valid", "test"]
assert len(rep.checks) == 8
names = {c.name for c in rep.checks}
assert names == {
"train_profitable",
"valid_profitable",
"test_profitable",
"sign_consistent",
"drawdown_bounded",
"train_enough_trades",
"test_active",
"oos_sharpe_positive",
}
# 上涨行情 + 买入持有 → 大部分检查通过 → 高适配
assert rep.pass_ratio >= 0.75
assert rep.high_fitness
def test_fitness_checks_carry_values():
rep = FitnessEngine(_CycleTrader).evaluate(_df(600))
for c in rep.checks:
assert c.detail # 每条检查附实际值(可解释性)
assert isinstance(c.passed, bool)
def test_fitness_losing_market_fails():
rep = FitnessEngine(_BuyFirstBar).evaluate(_df(600, drift=-0.002))
assert rep.pass_ratio < 0.75
assert not rep.high_fitness
# 三段全亏 → sign_consistent 通过(同号),但盈利检查全挂
by_name = {c.name: c.passed for c in rep.checks}
assert by_name["train_profitable"] is False
assert by_name["valid_profitable"] is False
assert by_name["test_profitable"] is False
def test_fitness_insufficient_data_returns_empty():
rep = FitnessEngine(_BuyFirstBar).evaluate(_df(60)) # valid 段 = 12 根 < 20 → 空报告
assert rep.segments == []
assert rep.checks == []
assert rep.high_fitness is False
def test_fitness_invalid_split_raises():
with pytest.raises(ValueError, match="split"):
FitnessEngine(_BuyFirstBar, split=(0.5, 0.2, 0.2))
def test_fitness_prefix_no_lookahead():
"""evaluate_prefix 只用前缀:末段测试段终点必须早于 end_index。"""
df = _df(600)
rep = FitnessEngine(_BuyFirstBar).evaluate_prefix(df, 400)
assert [s.name for s in rep.segments] == ["train", "valid", "test"]
# 前缀评估的测试段末日期 < 第 400 根的日期
dt_col = "datetime"
cutoff = pd.Timestamp(df[dt_col].iloc[399]).strftime("%Y-%m-%d")
assert rep.segments[-1].end <= cutoff
def test_rolling_fitness_scores_series():
df = _df(700)
scores = rolling_fitness_scores(df, _BuyFirstBar, step=100, min_prefix=300)
assert len(scores) >= 3
assert all(s["index"] < 700 for s in scores)
assert all(0.0 <= s["pass_ratio"] <= 1.0 for s in scores)
# 时间升序
idxs = [s["index"] for s in scores]
assert idxs == sorted(idxs)
def test_fitness_report_serializable():
rep = FitnessEngine(_BuyFirstBar).evaluate(_df(500))
d = rep.to_dict()
json.dumps(d, default=str)
assert d["total_checks"] == 8
assert "high_fitness" in d
# ── benchmark(一条龙评估)────────────────────────────────────────────────────
def test_buy_hold_benchmark_matches_trend():
df = _df(300, drift=0.002)
bh = run_buy_hold_benchmark(df)
total = df["close"].iloc[-1] / df["close"].iloc[0] - 1
assert bh["total_return"] == pytest.approx(total, rel=0.05) # 扣少量费用
def test_evaluate_strategy_full_report_structure():
report = evaluate_strategy(_BuyFirstBar, _df(500))
for key in (
"performance",
"score",
"grade",
"walkforward",
"fitness",
"benchmark",
"config",
):
assert key in report
# 绩效 19 项
assert "total_return" in report["performance"]
assert "sharpe" in report["performance"]
# 评分/评级结构
assert 0 <= report["score"]["total"] <= 100
assert report["grade"]["grade"] in ("S", "A", "B", "C", "D")
# WF
assert report["walkforward"]["n_windows"] == 7
# 适配性
assert report["fitness"]["total_checks"] == 8
# 基准
assert "buy_hold" in report["benchmark"]
assert "excess_return" in report["benchmark"]
def test_evaluate_strategy_excess_return_sign():
"""上涨行情 + 买入持有策略 ≈ 基准本身,excess_return 接近 0(扣费差异)。"""
report = evaluate_strategy(_BuyFirstBar, _df(400))
excess = report["benchmark"]["excess_return"]
assert abs(excess) < 0.05
def test_evaluate_strategy_serializable():
report = evaluate_strategy(_BuyFirstBar, _df(300), n_windows=3)
text = json.dumps(report, default=str)
assert "excess_return" in text
def test_evaluate_strategy_auto_fees_for_etf():
report = evaluate_strategy(_BuyFirstBar, _df(300), symbol="SH:510300", auto_fees=True)
assert report["config"]["symbol"] == "SH:510300"
assert report["config"]["auto_fees"] is True
def test_evaluate_portfolio_auto_fees_fitness_per_stock_symbol():
"""组合体检逐段应按各标的品种解析费率(auto_fees 与主回测同口径)。
回归:evaluate_portfolio 的 per-stock FitnessEngine 漏传 symbol
auto_fees 不生效——ETF 组合的三段体检被按股票口径错收印花税(卖方 0.001),
与组合回测主路径(PortfolioBacktestEngine 逐标的 resolve_fee_model)不一致。
期望:报告里的三段体检与「正确传入 symbol 的独立体检」逐段一致。
"""
from easy_tdx.backtest.benchmark import evaluate_portfolio
from easy_tdx.backtest.portfolio_engine import StockData
df = _df(240, drift=0.002)
stocks = [StockData("159915", "SZ", df)] # ETF:法定免印花税
report = evaluate_portfolio(
_CycleTrader, stocks, total_cash=100_000, auto_fees=True, n_windows=3
)
expected = FitnessEngine(symbol="SZ159915", strategy=_CycleTrader, auto_fees=True).evaluate(df)
actual_returns = [seg["total_return"] for seg in report["fitness"]["segments"]]
expected_returns = [seg.total_return for seg in expected.segments]
assert len(actual_returns) == 3
for actual, exp in zip(actual_returns, expected_returns):
assert actual == pytest.approx(exp, abs=1e-9)
# ── evaluate_portfoliov1.31 组合级一条龙)───────────────────────────────────
def _stocks_for_portfolio() -> list[Any]:
from easy_tdx.backtest.portfolio_engine import StockData
return [
StockData("000001", "SZ", _df(400, drift=0.002)),
StockData("600000", "SH", _df(400, drift=0.003)),
]
def test_evaluate_portfolio_full_report_structure():
"""组合一条龙报告与单标的 evaluate_strategy 同构(前端面板可复用)。"""
from easy_tdx.backtest.benchmark import evaluate_portfolio
report = evaluate_portfolio(_CycleTrader(), _stocks_for_portfolio(), total_cash=500_000)
for key in ("performance", "score", "grade", "walkforward", "fitness", "benchmark", "config"):
assert key in report
# 组合绩效:完整指标 + 组合字段
assert "sqn" in report["performance"]
assert "max_consecutive_losses" in report["performance"]
assert report["performance"]["total_stocks"] == 2
# 评分/评级
assert 0 <= report["score"]["total"] <= 100
assert report["score"]["wf_provided"] is True
assert report["grade"]["grade"] in ("S", "A", "B", "C", "D")
assert report["grade"]["scenario"] == "portfolio"
# 组合 WF
assert len(report["walkforward"]["windows"]) > 0
# 适配性(跨标的聚合)
assert report["fitness"]["total_checks"] == 8
assert "只标的通过" in report["fitness"]["checks"][0]["detail"]
# 基准
assert "buy_hold" in report["benchmark"]
assert "excess_return" in report["benchmark"]
# config 记录标的清单
assert report["config"]["stocks"] == ["SZ000001", "SH600000"]
def test_evaluate_portfolio_buy_hold_excess_near_zero():
"""首根买入持有策略 ≈ 等权买入持有基准,excess_return 接近 0(扣费差异)。"""
from easy_tdx.backtest.benchmark import evaluate_portfolio
report = evaluate_portfolio(_BuyFirstBar(), _stocks_for_portfolio(), total_cash=500_000)
assert abs(report["benchmark"]["excess_return"]) < 0.05
def test_evaluate_portfolio_serializable():
from easy_tdx.backtest.benchmark import evaluate_portfolio
report = evaluate_portfolio(
_CycleTrader(), _stocks_for_portfolio(), total_cash=500_000, n_windows=3
)
text = json.dumps(report, default=str)
assert "excess_return" in text
# ── evaluate_multiv1.31.1:多策略组合一条龙)────────────────────────────────
def _slots_for_multi() -> list[Any]:
from easy_tdx.backtest.multi_strategy_engine import StrategySlot
return [
StrategySlot(
label="动量", symbol="SZ:000001", strategy=_CycleTrader(), df=_df(400, drift=0.002)
),
StrategySlot(
label="反转", symbol="SH:600000", strategy=_CycleTrader(), df=_df(400, drift=0.003)
),
]
def test_evaluate_multi_full_report_structure():
"""多策略组合一条龙报告与 evaluate_portfolio 同构(前端面板可复用)。"""
from easy_tdx.backtest.benchmark import evaluate_multi
report = evaluate_multi(_slots_for_multi(), total_cash=500_000)
for key in ("performance", "score", "grade", "walkforward", "fitness", "benchmark", "config"):
assert key in report
assert "sqn" in report["performance"]
assert "max_consecutive_losses" in report["performance"]
assert report["performance"]["total_stocks"] == 2
assert 0 <= report["score"]["total"] <= 100
assert report["score"]["wf_provided"] is True
assert report["grade"]["grade"] in ("S", "A", "B", "C", "D")
assert report["grade"]["scenario"] == "portfolio"
assert len(report["walkforward"]["windows"]) > 0
assert report["fitness"]["total_checks"] == 8
assert "只标的通过" in report["fitness"]["checks"][0]["detail"]
assert "buy_hold" in report["benchmark"]
assert report["config"]["slots"] == ["动量@SZ:000001", "反转@SH:600000"]
def test_evaluate_multi_buy_hold_excess_near_zero():
"""各槽位换成买入持有后,组合收益 ≈ 等权买入持有基准(excess 近 0)。"""
from easy_tdx.backtest.benchmark import evaluate_multi
from easy_tdx.backtest.multi_strategy_engine import StrategySlot
bh_slots = [
StrategySlot(label=s.label, symbol=s.symbol, strategy=_BuyFirstBar(), df=s.df)
for s in _slots_for_multi()
]
report = evaluate_multi(bh_slots, total_cash=500_000)
assert abs(report["benchmark"]["excess_return"]) < 0.05
def test_evaluate_multi_serializable():
from easy_tdx.backtest.benchmark import evaluate_multi
report = evaluate_multi(_slots_for_multi(), total_cash=500_000, n_windows=3)
text = json.dumps(report, default=str)
assert "excess_return" in text