Files
easy_tdx_max/tests/unit/test_golden_backtest.py
T

293 lines
11 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.
"""黄金测试(golden tests):回测引擎指标快照回归(v1.28 新增)。
借鉴 akquant 的 golden 测试机制:把「内置策略在固定随机种子合成数据上的
全部绩效指标」与「交易规则场景(止损/止盈/移动止损/OCO/费率)的成交明细」
锁定为 JSON 基线(``tests/golden/backtest_metrics.json``),每次引擎改动后
跑一遍比对——撮合、费率、信号时序任何静默漂移都会在这里爆出来。
生成/更新基线::
EASY_TDX_REGEN_GOLDEN=1 python -m pytest tests/unit/test_golden_backtest.py
比对容差:rel=1e-6 / abs=1e-6——紧到能抓住费率或成交时点级别的逻辑漂移
(通常引起 >0.001 的变动),松到容忍跨平台浮点求和顺序的尾数噪声。
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
import pytest
from easy_tdx.backtest.benchmark import (
compute_benchmark_comparison,
run_buy_hold_benchmark,
)
from easy_tdx.backtest.engine import BacktestEngine
from easy_tdx.backtest.strategies import builtin # noqa: F401 # 触发注册
from easy_tdx.backtest.strategies.registry import _REGISTRY
from easy_tdx.backtest.strategy import Strategy
GOLDEN_PATH = Path(__file__).resolve().parents[1] / "golden" / "backtest_metrics.json"
REGEN = os.environ.get("EASY_TDX_REGEN_GOLDEN", "") == "1"
# 与基线 meta 一致的固定参数
SEED = 20260902
BARS = 400
CASH = 100000.0
# 内置策略锁定的指标子集(全部为确定性数值;int 与 float 分开比对)
STRATEGY_METRICS_FLOAT = (
"total_return",
"max_drawdown",
"sharpe",
"win_rate",
"ulcer_index",
"var_95",
"cvar_95",
"sqn",
)
STRATEGY_METRICS_INT = (
"total_trades",
"max_consecutive_wins",
"max_consecutive_losses",
)
def _golden_df() -> pd.DataFrame:
"""固定种子的合成日线(几何随机游走 + 温和上行漂移)。"""
rng = np.random.default_rng(SEED)
close = 20.0 * np.exp(np.cumsum(rng.normal(0.0004, 0.018, BARS)))
high = close * (1 + np.abs(rng.normal(0, 0.008, BARS)))
low = close * (1 - np.abs(rng.normal(0, 0.008, BARS)))
open_ = low + (high - low) * rng.uniform(0, 1, BARS)
vol = rng.uniform(5e5, 5e6, BARS)
return pd.DataFrame(
{
"datetime": pd.date_range("2023-01-02", periods=BARS, freq="B"),
"open": open_,
"high": high,
"low": low,
"close": close,
"vol": vol,
"amount": close * vol,
}
)
# ── 规则场景策略(手工构造行情路径,锁定触发语义本身) ───────────────────────
class _BuyOnce(Strategy):
"""首根买入(可携带 bracket 参数),不再主动交易;无参数时即买入持有。"""
def __init__(self, **bracket: Any) -> None:
super().__init__()
self._bracket: dict[str, Any] = bracket
self._bought = False
def init(self) -> None:
pass
def next(self) -> None:
if not self._bought:
self.buy(**self._bracket)
self._bought = True
def _rule_df(closes: list[float]) -> pd.DataFrame:
"""按收盘价序列构造无随机因素的 OHLChigh/low = close ±1%)。"""
arr = np.asarray(closes, dtype=float)
n = len(arr)
return pd.DataFrame(
{
"datetime": pd.date_range("2024-01-01", periods=n, freq="B"),
"open": arr,
"high": arr * 1.01,
"low": arr * 0.99,
"close": arr,
"vol": [1000.0] * n,
"amount": arr * 1000,
}
)
def _run_rule(closes: list[float], **bracket: Any) -> dict[str, Any]:
"""跑规则场景,返回待锁定的摘要(成交明细 + 关键指标)。"""
result = BacktestEngine(_BuyOnce(**bracket), cash=CASH).run(_rule_df(closes))
trades = [
[t.direction, round(float(t.price), 4), int(pd.Timestamp(t.datetime).strftime("%Y%m%d"))]
for t in result.trades.itertuples()
if not t.rejected
]
return {
"trades": trades,
"total_return": float(result.performance["total_return"]),
"total_trades": int(result.performance["total_trades"]),
}
RULE_SCENARIOS: dict[str, dict[str, Any]] = {
# 跌破固定止损 9.5 → 触发 SELL@9.5,延迟下一根成交
"stop_loss": {
"closes": [10, 10.2, 10.1, 9.8, 9.3, 9.0, 8.8, 8.6, 8.4, 8.2],
"bracket": {"stop_loss": 9.5},
},
# 触及固定止盈 11.0 → OCO 使止损线失效
"take_profit": {
"closes": [10, 10.3, 10.8, 11.2, 11.5, 11.8, 12.0, 12.2, 12.4, 12.6],
"bracket": {"stop_loss": 9.0, "take_profit": 11.0},
},
# 自最高收盘 12 回撤 8% → 11.04 触发移动止损
"trailing_stop": {
"closes": [10, 10.2, 10.5, 11, 11.5, 12, 11.9, 11.5, 11.0, 10.5, 10.0, 9.5],
"bracket": {"trail_stop": 0.08},
},
# 百分比 bracket5% 止损 / 10% 止盈(基准价 = 信号根收盘 10)
"bracket_pct": {
"closes": [10, 10.3, 10.8, 11.2, 11.5, 11.8, 12.0, 12.2, 12.4, 12.6],
"bracket": {"stop_loss_pct": 0.05, "take_profit_pct": 0.10},
},
}
def _build_golden() -> dict[str, Any]:
"""重新计算并返回完整黄金基线。"""
df = _golden_df()
strategies: dict[str, dict[str, Any]] = {}
for name in sorted(_REGISTRY.names()):
reg = _REGISTRY.get(name)
cls = reg.strategy_cls
perf = BacktestEngine(cls, cash=CASH).run(df).performance
entry: dict[str, Any] = {k: float(perf[k]) for k in STRATEGY_METRICS_FLOAT}
entry.update({k: int(perf[k]) for k in STRATEGY_METRICS_INT})
strategies[name] = entry
rules = {
key: _run_rule(spec["closes"], **spec["bracket"]) for key, spec in RULE_SCENARIOS.items()
}
# 买入持有基准 + CAPM 对比(用 ma_cross 做策略侧)
bh = run_buy_hold_benchmark(df, cash=CASH)
ma = _REGISTRY.get("ma_cross").strategy_cls
ma_result = BacktestEngine(ma, cash=CASH).run(df)
comparison = compute_benchmark_comparison(
ma_result.equity_curve,
BacktestEngine(_BuyOnce(), cash=CASH).run(df).equity_curve,
)
return {
"meta": {
"seed": SEED,
"bars": BARS,
"cash": CASH,
"tolerance": {"rel": 1e-6, "abs": 1e-6},
"note": "regen: EASY_TDX_REGEN_GOLDEN=1 pytest tests/unit/test_golden_backtest.py",
},
"strategies": strategies,
"rules": rules,
"buy_hold": {k: float(v) for k, v in bh.items()},
"benchmark_comparison": {k: float(v) for k, v in comparison.items()},
}
def _load_golden() -> dict[str, Any]:
if not GOLDEN_PATH.exists():
pytest.fail(
f"黄金基线缺失: {GOLDEN_PATH}\n"
"首次生成请运行: EASY_TDX_REGEN_GOLDEN=1 python -m pytest "
"tests/unit/test_golden_backtest.py"
)
data = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
assert isinstance(data, dict)
return data
def _save_golden(data: dict[str, Any]) -> None:
GOLDEN_PATH.parent.mkdir(parents=True, exist_ok=True)
GOLDEN_PATH.write_text(
json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
@pytest.fixture(scope="module")
def golden() -> dict[str, Any]:
"""加载基线;REGEN=1 时重新计算并写盘后返回。"""
if REGEN:
data = _build_golden()
_save_golden(data)
return data
return _load_golden()
def _assert_metric(actual: Any, expected: Any, label: str) -> None:
"""int 精确比对;float 按 rel=abs=1e-6 容差比对。"""
if isinstance(expected, int) and not isinstance(expected, bool):
assert actual == expected, f"{label}: {actual} != {expected}"
else:
assert float(actual) == pytest.approx(float(expected), rel=1e-6, abs=1e-6), (
f"{label}: {actual} != {expected}"
)
# ── 测试入口 ─────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("name", sorted(_REGISTRY.names()))
def test_golden_builtin_strategies(golden: dict[str, Any], name: str) -> None:
"""全部内置策略在固定数据上的绩效指标与基线一致。"""
perf = BacktestEngine(_REGISTRY.get(name).strategy_cls, cash=CASH).run(_golden_df()).performance
baseline = golden["strategies"][name]
for key in STRATEGY_METRICS_FLOAT:
_assert_metric(perf[key], baseline[key], f"{name}.{key}")
for key in STRATEGY_METRICS_INT:
_assert_metric(perf[key], baseline[key], f"{name}.{key}")
@pytest.mark.parametrize("scenario", sorted(RULE_SCENARIOS))
def test_golden_rule_scenarios(golden: dict[str, Any], scenario: str) -> None:
"""止损/止盈/移动止损/OCO 触发语义(成交价与时点)与基线一致。"""
spec = RULE_SCENARIOS[scenario]
actual = _run_rule(spec["closes"], **spec["bracket"])
baseline = golden["rules"][scenario]
assert actual["total_trades"] == baseline["total_trades"], scenario
assert len(actual["trades"]) == len(baseline["trades"]), f"{scenario}: 成交笔数漂移"
for i, (a, b) in enumerate(zip(actual["trades"], baseline["trades"])):
assert a[0] == b[0], f"{scenario}{i}笔方向漂移: {a} vs {b}"
_assert_metric(a[1], b[1], f"{scenario}.trades[{i}].price")
assert a[2] == b[2], f"{scenario}{i}笔成交日漂移: {a} vs {b}"
_assert_metric(actual["total_return"], baseline["total_return"], f"{scenario}.total_return")
def test_golden_buy_hold(golden: dict[str, Any]) -> None:
"""买入持有基准指标与基线一致。"""
bh = run_buy_hold_benchmark(_golden_df(), cash=CASH)
for key, expected in golden["buy_hold"].items():
_assert_metric(bh[key], expected, f"buy_hold.{key}")
def test_golden_benchmark_comparison(golden: dict[str, Any]) -> None:
"""Alpha/Beta/IR/TE 基准对比指标与基线一致。"""
df = _golden_df()
ma = _REGISTRY.get("ma_cross").strategy_cls
strategy_curve = BacktestEngine(ma, cash=CASH).run(df).equity_curve
bh_curve = BacktestEngine(_BuyOnce(), cash=CASH).run(df).equity_curve
comparison = compute_benchmark_comparison(strategy_curve, bh_curve)
for key, expected in golden["benchmark_comparison"].items():
_assert_metric(comparison[key], expected, f"benchmark.{key}")
def test_golden_meta_frozen(golden: dict[str, Any]) -> None:
"""基线 meta 与测试常量一致(防止改数据参数后忘记重建基线)。"""
meta = golden["meta"]
assert meta["seed"] == SEED
assert meta["bars"] == BARS
assert meta["cash"] == CASH