Files
easy_tdx_max/tests/unit/test_backtest_engine_vector.py

315 lines
13 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.
"""向量化快速路径(v1.28)对拍与约束检测单测。
核心保证:**同一 df + 同参数下,向量化路径与逐 bar 路径的输出逐位一致**
performance / trades / equity_curve / positions 全比对)。
- 对拍覆盖:全部内置策略(默认参数,当前 54 个)+ ma_cross/macd/boll/rsi 的非默认
参数组合 + warmup / 极低资金(买不足 1 手的退化路径)/ 非默认费率与成交价模式;
- 约束检测:``_vectorize_eligibility`` 的显式约束(无掩码 / 缠论注入)与
``signal_path`` 的 auto/vector/loop 语义。
"""
from __future__ import annotations
import math
from typing import Any
import numpy as np
import pandas as pd
import pytest
from easy_tdx.backtest.engine import BacktestEngine
from easy_tdx.backtest.strategies import get_registry
from easy_tdx.backtest.strategy import Strategy
from easy_tdx.MyTT import MA
# ── 合成行情(确定性) ───────────────────────────────────────────────────────
def _synthetic_ohlcv(n: int = 800, seed: int = 42, base: float = 20.0) -> pd.DataFrame:
"""确定性随机游走 OHLCV(对拍两路径用同一份 df)。"""
rng = np.random.default_rng(seed)
rets = rng.normal(0.0005, 0.02, n)
close = base * np.cumprod(1.0 + rets)
open_ = np.concatenate([[base], close[:-1]])
high = np.maximum(open_, close) * (1.0 + np.abs(rng.normal(0.0, 0.008, n)))
low = np.minimum(open_, close) * (1.0 - np.abs(rng.normal(0.0, 0.008, n)))
vol = rng.integers(50_000, 5_000_000, n).astype(float)
dates = pd.bdate_range("2022-01-04", periods=n)
return pd.DataFrame(
{
"datetime": dates,
"open": open_,
"high": high,
"low": low,
"close": close,
"vol": vol,
"amount": vol * close,
}
)
def _oscillating_ohlcv(n: int = 800, seed: int = 8, base: float = 20.0) -> pd.DataFrame:
"""周期振荡 OHLCV(正弦 + 噪声,high/low 恰为 max/min(open, close))。
donchian / wr_reversal 这类「突破 N 日高进 / 跌破 N 日低出」的策略,
其通道窗口包含当根(upper ≥ 当根 high),``close >= upper`` 只有在
close == high == 窗口最大(即收盘即创新高)时才成立——high/low 不放大,
正弦行情每个周期顶/底都会双向触发,覆盖完整的开平仓循环。
"""
rng = np.random.default_rng(seed)
t = np.arange(n)
close = base * (1.0 + 0.15 * np.sin(2 * np.pi * t / 40) + rng.normal(0.0, 0.002, n))
open_ = np.concatenate([[base], close[:-1]])
high = np.maximum(open_, close)
low = np.minimum(open_, close)
vol = rng.integers(50_000, 5_000_000, n).astype(float)
dates = pd.bdate_range("2022-01-04", periods=n)
return pd.DataFrame(
{
"datetime": dates,
"open": open_,
"high": high,
"low": low,
"close": close,
"vol": vol,
"amount": vol * close,
}
)
def _assert_perf_equal(a: dict[str, Any], b: dict[str, Any]) -> None:
"""performance 字典逐位比对(NaN 视为相等——两条路径应产生完全相同的浮点数)。"""
assert set(a.keys()) == set(b.keys()), f"键集不一致: {set(a) ^ set(b)}"
for key in a:
va, vb = a[key], b[key]
if isinstance(va, float) and isinstance(vb, float) and math.isnan(va) and math.isnan(vb):
continue
assert va == vb, f"performance[{key}] 不一致: loop={va!r} vector={vb!r}"
def _assert_results_identical(loop: Any, vec: Any) -> None:
_assert_perf_equal(loop.performance, vec.performance)
pd.testing.assert_frame_equal(loop.trades, vec.trades)
pd.testing.assert_frame_equal(loop.equity_curve, vec.equity_curve)
pd.testing.assert_frame_equal(loop.positions, vec.positions)
assert loop.config == vec.config
def _run_both(
name: str,
df: pd.DataFrame,
params: dict[str, Any] | None = None,
*,
skip_bounds: bool = False,
**engine_kw: Any,
):
"""同一配置下分别跑逐 bar / 自动画两条路径。"""
entry = get_registry().get(name)
loop = BacktestEngine(
entry.build(params, skip_bounds=skip_bounds), signal_path="loop", **engine_kw
).run(df)
vec = BacktestEngine(
entry.build(params, skip_bounds=skip_bounds), signal_path="auto", **engine_kw
).run(df)
return loop, vec
# ── 对拍:全部内置策略 × 默认参数 ───────────────────────────────────────────
#: 已知「默认参数下不会交易」的策略:MyTT 的 WR 是 0~100 刻度(100=超卖),
#: 而 wr_reversal 默认阈值为 -80/-20(通达信 -100~0 惯例),entry 恒 False。
#: 这是策略的既有行为(两路径一致地不交易),语义修正不属于向量化改动范围;
#: 对拍改用非默认参数覆盖(见 test_key_strategies_alternate_params)。
_DEAD_DEFAULT_STRATEGIES = {"wr_reversal"}
@pytest.mark.parametrize("name", get_registry().names())
def test_all_builtin_strategies_default_params(name: str) -> None:
"""全部内置策略:向量化与逐 bar 输出逐位一致(对拍核心保证)。"""
df = _synthetic_ohlcv()
loop, vec = _run_both(name, df)
# 确认确实产生了交易(空交易的对拍没有意义):随机游走无交易换种子,
# 再无交易换振荡行情(donchian/wr 等带状策略只在振荡行情双向触发)
if len(loop.trades) == 0:
loop, vec = _run_both(name, _synthetic_ohlcv(seed=7, base=50.0))
if len(loop.trades) == 0:
loop, vec = _run_both(name, _oscillating_ohlcv())
if name not in _DEAD_DEFAULT_STRATEGIES:
assert len(loop.trades) > 0, f"{name} 在三组行情下均无交易,对拍无效"
_assert_results_identical(loop, vec)
# ── 对拍:关键策略的非默认参数 + 引擎配置变化 ───────────────────────────────
@pytest.mark.parametrize(
("name", "params"),
[
("ma_cross", {"fast": 10, "slow": 60}),
("ma_cross", {"fast": 3, "slow": 8}),
("macd", {"short": 6, "long": 13, "signal": 5}),
("boll_breakout", {"n": 10, "p": 2.5}),
("rsi_reversal", {"n": 7, "oversold": 25, "overbought": 78}),
("donchian", {"n": 20}),
],
)
def test_key_strategies_alternate_params(name: str, params: dict[str, Any]) -> None:
"""任务点名的四个策略(含非默认参数)对拍一致。"""
df = _synthetic_ohlcv(seed=99)
loop, vec = _run_both(name, df, params)
if len(loop.trades) == 0:
loop, vec = _run_both(name, _oscillating_ohlcv(), params)
assert len(loop.trades) > 0, f"{name}{params} 两组行情下均无交易,对拍无效"
_assert_results_identical(loop, vec)
def test_wr_reversal_parity_with_skip_bounds() -> None:
"""wr_reversal 的向量化机制对拍(需 skip_bounds 越过死的默认边界)。
其阈值参数边界为负数区间(-100~-40 / -60~0),而 MyTT 的 WR 是 0~100
刻度——任何合法参数都无法触发交易(策略现状如此,两路径行为一致)。
为了让它的掩码/状态机路径也被对拍覆盖,用 skip_bounds 传 0~100 刻度内
的阈值绕过边界(寻优器同款机制)。
"""
df = _oscillating_ohlcv()
loop, vec = _run_both(
"wr_reversal", df, {"n": 14, "oversold": 40, "overbought": 60}, skip_bounds=True
)
assert len(loop.trades) > 0
_assert_results_identical(loop, vec)
@pytest.mark.parametrize("warmup", [0, 20, 100])
def test_warmup_bars_consistency(warmup: int) -> None:
"""warmup 期不产生信号:两路径一致(向量化按候选 bar 过滤)。"""
df = _synthetic_ohlcv(seed=11)
loop, vec = _run_both("ma_cross", df, warmup_bars=warmup)
_assert_results_identical(loop, vec)
def test_degenerate_low_cash_consistency() -> None:
"""极低资金:BUY 信号买不足 1 手(策略仓位状态不变),两路径仍一致。"""
df = _synthetic_ohlcv(seed=5, base=200.0) # 高价股 + 小资金 → 整手买入失败
loop, vec = _run_both("ma_cross", df, cash=1500.0)
_assert_results_identical(loop, vec)
def test_nondefault_fees_and_execution_consistency() -> None:
"""非默认费率/滑点/成交价模式:信号路径无关下游,但全流程仍应一致。"""
df = _synthetic_ohlcv(seed=13)
kw: dict[str, Any] = {
"commission": 0.0005,
"stamp_tax": 0.0005,
"slippage": 0.01,
"execution": "next_close",
}
loop, vec = _run_both("macd", df, **kw)
_assert_results_identical(loop, vec)
def test_indicator_cache_consistency() -> None:
"""挂载指标缓存(寻优场景)时向量化路径照常工作且一致。"""
from easy_tdx.backtest.indicator_cache import IndicatorCache
df = _synthetic_ohlcv(seed=21)
loop = BacktestEngine(
get_registry().get("ma_cross").build(), signal_path="loop", indicator_cache=IndicatorCache()
).run(df)
cache = IndicatorCache()
vec = BacktestEngine(
get_registry().get("ma_cross").build(), signal_path="auto", indicator_cache=cache
).run(df)
_assert_results_identical(loop, vec)
# ── 约束检测(显式、可测试) ─────────────────────────────────────────────────
class _PlainStrategy(Strategy):
"""未实现 entry_exit_masks 的普通策略(应走逐 bar)。"""
def init(self) -> None:
self.ma = self.I(MA, self.data.close, 5)
def next(self) -> None:
if self.ma[self._bar_index] > 0 and self.position["size"] == 0:
self.buy()
def test_eligibility_requires_masks_hook() -> None:
"""未覆写 entry_exit_masks → 不具备资格(原因可读)。"""
engine = BacktestEngine(_PlainStrategy)
eligible, reason = engine._vectorize_eligibility(_PlainStrategy(), None)
assert eligible is False
assert "entry_exit_masks" in reason
def test_eligibility_rejects_chanlun() -> None:
"""缠论注入(result 或 level)→ 不具备资格。"""
strat = get_registry().get("ma_cross").build()
engine = BacktestEngine(strat, chanlun_level="DAILY")
eligible, reason = engine._vectorize_eligibility(strat, None)
assert eligible is False
assert "缠论" in reason
engine2 = BacktestEngine(strat)
eligible2, reason2 = engine2._vectorize_eligibility(strat, {"fake": "chanlun"})
assert eligible2 is False
assert "缠论" in reason2
def test_eligibility_accepts_builtin() -> None:
"""内置策略(无缠论)→ 具备资格。"""
strat = get_registry().get("ma_cross").build()
engine = BacktestEngine(strat)
eligible, _ = engine._vectorize_eligibility(strat, None)
assert eligible is True
def test_signal_path_vector_forces_or_raises() -> None:
"""signal_path='vector':满足约束时正常,不满足时显式抛错。"""
df = _synthetic_ohlcv(200)
# 满足约束:正常运行且与 loop 一致
loop, vec = _run_both("ma_cross", df)
forced = BacktestEngine(get_registry().get("ma_cross").build(), signal_path="vector").run(df)
_assert_results_identical(loop, forced)
# 不满足约束:显式 ValueError(而非静默回退)
with pytest.raises(ValueError, match="向量化约束"):
BacktestEngine(_PlainStrategy, signal_path="vector").run(df)
def test_signal_path_invalid_rejected() -> None:
with pytest.raises(ValueError, match="signal_path"):
BacktestEngine(_PlainStrategy, signal_path="fast")
def test_auto_falls_back_on_mask_shape_mismatch() -> None:
"""掩码形状错误(策略实现 bug):auto 静默回退逐 bar,结果仍一致。"""
class _BadMaskStrategy(_PlainStrategy):
def entry_exit_masks(self) -> tuple[np.ndarray, np.ndarray]:
return np.zeros(3, dtype=bool), np.zeros(3, dtype=bool)
df = _synthetic_ohlcv(200, seed=3)
loop = BacktestEngine(_PlainStrategy, signal_path="loop").run(df)
fallback = BacktestEngine(_BadMaskStrategy, signal_path="auto").run(df)
_assert_results_identical(loop, fallback)
def test_vector_path_actually_used_for_builtins() -> None:
"""默认 signal_path='auto' 下内置策略确实走了向量化(防止回退被掩盖)。
若某策略信号依赖路径状态(无法用静态掩码等价表达),应在此说明并
考虑引擎走逐 bar 回放的白名单机制(当前无此类策略)。
"""
from easy_tdx.backtest.strategy import Strategy as Base
for name in get_registry().names():
strat_cls = get_registry().get(name).strategy_cls
assert strat_cls.entry_exit_masks is not Base.entry_exit_masks, (
f"{name} 未实现 entry_exit_masksauto 将永远走逐 bar"
)