mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 16:54:20 +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 全绿。
335 lines
12 KiB
Python
335 lines
12 KiB
Python
"""适配性评估(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_portfolio(v1.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_multi(v1.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
|