Files
easy_tdx_max/src/easy_tdx/backtest/combo.py
T
GitHub 155328df8b release: v1.16.2 — 三轮审计质量加固(B6.9→A7.9)
经三轮代码审计后的综合质量加固版本,覆盖协议核心层、数据正确性、
错误处理、测试真实度与可维护性。761 单测全绿(+58),ruff/mypy 全过。

主要修复:
- 离线 .day 写入原子化(fsync + _repair_tail + 读取校验,CQS 守住)
- 回测止损前视偏差(延迟下一根开盘 + 跳空保护)
- VWAP 权重索引 / bar_time fail-fast / 绩效除零保护
- 闭包绑定 / 路径穿越 / naive datetime 跨时区 / ruff UP038

重构:
- 抽 AsyncHeartbeatMixin 收敛 4 处心跳副本(12→1)
- 统一 _RETRY_DELAYS 退避序列 / scanner 失败可观测性

新增 5 个测试文件 + 公共 API 类型契约,CI 加 Windows 矩阵 +
trusted publishing 签名 + 锁文件。

详见 CHANGELOG.md
2026-07-02 03:37:37 +08:00

459 lines
14 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.
"""多因子组合回测引擎。
核心能力:
- extract_factor_signals: 从策略提取买入/卖出信号遮罩
- combine_masks: 合并多个因子的信号(AND / OR / MAJORITY
- CombinationRunner: 批量遍历因子组合,自动寻找最优搭配
用法::
from easy_tdx.backtest.combo import CombinationRunner
runner = CombinationRunner(
strategy_classes=[MACDStrategy, RSIStrategy, BollingerStrategy],
df=df,
cash=100000.0,
)
# 遍历所有 2/3 因子组合
results = runner.screen(combo_sizes=(2, 3), mode="MAJORITY")
for r in results[:5]:
print(f"{r.name}: 收益={r.result.performance['total_return']:.2%}")
"""
from __future__ import annotations
import itertools
import logging
from dataclasses import dataclass
from typing import Any
import numpy as np
import numpy.typing as npt
import pandas as pd
from easy_tdx.backtest.engine import BacktestEngine
from easy_tdx.backtest.strategy import Strategy
from easy_tdx.backtest.types import BacktestResult
logger = logging.getLogger(__name__)
NDArray = np.ndarray
BoolArray = npt.NDArray[np.bool_]
def bool_array(x: Any) -> BoolArray:
"""Ensure the result is a BoolArray (not a scalar bool_)."""
return np.asarray(x, dtype=np.bool_)
# ── 数据结构 ────────────────────────────────────────────────────────────────
@dataclass
class FactorSignals:
"""单个因子的信号遮罩。
Attributes:
name: 因子名称(通常为策略类名)
buy_mask: 每根 bar 是否产生买入信号
sell_mask: 每根 bar 是否产生卖出信号
"""
name: str
buy_mask: BoolArray
sell_mask: BoolArray
@dataclass
class ComboResult:
"""因子组合的回测结果。
Attributes:
name: 组合名称(如 "MACDStrategy + RSIStrategy"
indices: 因子在原始列表中的索引
size: 因子数量
result: 回测结果
"""
name: str
indices: tuple[int, ...]
size: int
result: BacktestResult
# ── 信号提取 ────────────────────────────────────────────────────────────────
def extract_factor_signals(
strategy_cls: type[Strategy],
df: pd.DataFrame,
cash: float = 100_000.0,
commission: float = 0.0003,
) -> FactorSignals:
"""从策略类提取买入/卖出信号遮罩。
运行策略的 bar-by-bar 信号生成,捕获每根 bar 的买卖意图。
复现 BacktestEngine._generate_signals 的仓位跟踪逻辑,
确保信号与实际运行一致。
Args:
strategy_cls: Strategy 子类
df: K线 DataFrame
cash: 初始资金(影响全仓计算)
commission: 佣金率
Returns:
FactorSignals 包含 buy_mask 和 sell_mask
"""
strat = strategy_cls()
strat._bind_data(df)
strat._cash = cash
strat._position_size = 0.0
strat._call_init()
n = len(df)
buy_mask = np.zeros(n, dtype=bool)
sell_mask = np.zeros(n, dtype=bool)
close_arr = df["close"].to_numpy()
for i in range(n):
strat._set_bar_index(i)
strat._call_next()
signals = strat._clear_signals()
for sig in signals:
if sig.direction == "BUY":
buy_mask[i] = True
else:
sell_mask[i] = True
# 跟踪仓位状态(与 BacktestEngine._update_strategy_position 一致)
_update_position(strat, signals, close_arr[i], commission)
return FactorSignals(
name=strategy_cls.__name__,
buy_mask=buy_mask,
sell_mask=sell_mask,
)
def _update_position(
strat: Strategy,
signals: list[Any],
est_price: float,
commission: float,
) -> None:
"""更新策略内部仓位状态。
复现 BacktestEngine._update_strategy_position 的逻辑,
使因子信号提取与实际回测行为一致。
Args:
strat: 策略实例
signals: 当前 bar 的信号列表
est_price: 估算价格(收盘价)
commission: 佣金率
"""
for sig in signals:
price = sig.price or est_price
if sig.direction == "BUY":
if sig.size == 0:
shares = int(strat._cash / (price * (1 + commission)) / 100) * 100
if shares > 0:
strat._position_size += shares
strat._cash -= shares * price
else:
strat._position_size += sig.size
strat._cash -= sig.size * price
elif sig.direction == "SELL":
if sig.size == 0:
strat._cash += strat._position_size * price
strat._position_size = 0.0
else:
strat._cash += sig.size * price
strat._position_size = max(0.0, strat._position_size - sig.size)
# ── 信号合并 ────────────────────────────────────────────────────────────────
def combine_masks(
signals_list: list[FactorSignals],
mode: str = "MAJORITY",
) -> tuple[BoolArray, BoolArray]:
"""合并多个因子的信号遮罩。
Args:
signals_list: 因子信号列表
mode: 合并模式
- "AND": 所有因子都同意才触发
- "OR": 任一因子同意即触发
- "MAJORITY": 过半因子同意才触发
Returns:
(combined_buy_mask, combined_sell_mask)
Raises:
ValueError: 不支持的合并模式
"""
if not signals_list:
raise ValueError("至少需要 1 个因子信号")
buy_arrays = [s.buy_mask for s in signals_list]
sell_arrays = [s.sell_mask for s in signals_list]
buy_stack = np.stack(buy_arrays) # shape: (n_factors, n_bars)
sell_stack = np.stack(sell_arrays)
if mode == "AND":
return bool_array(np.all(buy_stack, axis=0)), bool_array(np.all(sell_stack, axis=0))
elif mode == "OR":
return bool_array(np.any(buy_stack, axis=0)), bool_array(np.any(sell_stack, axis=0))
elif mode == "MAJORITY":
n_factors = len(signals_list)
# n_factors < 3 时 MAJORITY 退化为 AND/ANYn=2 时 threshold=1.0
# 需 >1.0 即两个都要,等价于 AND)。提醒用户明确指定模式(审计 #17)。
if n_factors < 3:
logger.warning(
"MAJORITY 模式在因子数 < 3 时(当前 %d)退化为 AND/ANY"
"建议明确指定 mode='AND' 或 'OR' 以避免语义混淆",
n_factors,
)
threshold = n_factors / 2
return (
bool_array(np.sum(buy_stack, axis=0) > threshold),
bool_array(np.sum(sell_stack, axis=0) > threshold),
)
else:
raise ValueError(f"不支持的合并模式: {mode!r}(可选: AND, OR, MAJORITY")
# ── 组合策略包装 ─────────────────────────────────────────────────────────────
class _ComboStrategy(Strategy):
"""将合并后的信号遮罩包装为 Strategy 子类。
与 dsl_strategy 思路一致,但增加了仓位检查,
避免重复买入和空仓卖出。
"""
_buy_mask: BoolArray
_sell_mask: BoolArray
def init(self) -> None:
pass
def next(self) -> None:
idx = self._bar_index
buy = self._buy_mask
sell = self._sell_mask
if idx < len(buy) and buy[idx] and self.position["size"] == 0:
self.buy(size=0)
elif idx < len(sell) and sell[idx] and self.position["size"] > 0:
self.sell(size=0)
def _make_combo_strategy(
buy_mask: BoolArray,
sell_mask: BoolArray,
) -> type[_ComboStrategy]:
"""将信号遮罩包装为 _ComboStrategy 子类。
Args:
buy_mask: 合并后的买入遮罩
sell_mask: 合并后的卖出遮罩
Returns:
_ComboStrategy 子类
"""
class WrappedComboStrategy(_ComboStrategy):
_buy_mask = buy_mask
_sell_mask = sell_mask
return WrappedComboStrategy
# ── 组合回测运行器 ──────────────────────────────────────────────────────────
class CombinationRunner:
"""多因子组合回测运行器。
用法::
runner = CombinationRunner(
strategy_classes=[MACDStrategy, RSIStrategy, BollingerStrategy],
df=df,
cash=100000.0,
)
# 遍历所有 2 因子组合
results = runner.screen(combo_sizes=(2,), mode="MAJORITY")
"""
def __init__(
self,
strategy_classes: list[type[Strategy]],
df: pd.DataFrame,
cash: float = 100_000.0,
commission: float = 0.0003,
min_commission: float = 5.0,
stamp_tax: float = 0.001,
slippage: float = 0.0,
execution: str = "next_open",
position_mode: str = "full",
reject_policy: str = "reduce",
) -> None:
"""初始化运行器。
Args:
strategy_classes: 参与组合的策略类列表
df: K线数据(所有策略共享)
cash: 初始资金
commission: 佣金率
min_commission: 最低佣金
stamp_tax: 印花税率
slippage: 滑点
execution: 成交价规则
position_mode: 持仓模式
reject_policy: 拒单策略
"""
self._strategy_classes = strategy_classes
self._df = df
self._cash = cash
self._commission = commission
self._min_commission = min_commission
self._stamp_tax = stamp_tax
self._slippage = slippage
self._execution = execution
self._position_mode = position_mode
self._reject_policy = reject_policy
# 缓存:策略类 → FactorSignals(只提取一次)
self._signal_cache: dict[str, FactorSignals] = {}
def _get_factor_signals(self, idx: int) -> FactorSignals:
"""获取指定策略的因子信号(带缓存)。
Args:
idx: 策略在列表中的索引
Returns:
FactorSignals
"""
cls = self._strategy_classes[idx]
key = cls.__name__
if key not in self._signal_cache:
self._signal_cache[key] = extract_factor_signals(
cls, self._df, cash=self._cash, commission=self._commission
)
return self._signal_cache[key]
def _make_engine(self, strategy: type[Strategy]) -> BacktestEngine:
"""创建配置一致的回测引擎。
Args:
strategy: 策略类
Returns:
BacktestEngine 实例
"""
return BacktestEngine(
strategy=strategy,
cash=self._cash,
commission=self._commission,
min_commission=self._min_commission,
stamp_tax=self._stamp_tax,
slippage=self._slippage,
execution=self._execution,
position_mode=self._position_mode,
reject_policy=self._reject_policy,
)
def run_combination(
self,
indices: list[int] | tuple[int, ...],
mode: str = "MAJORITY",
) -> BacktestResult:
"""运行指定因子组合的回测。
Args:
indices: 要组合的因子索引(在 strategy_classes 中的位置)
mode: 信号合并模式(AND / OR / MAJORITY
Returns:
BacktestResult
"""
# 1. 提取因子信号
signals = [self._get_factor_signals(i) for i in indices]
# 2. 合并信号
buy_mask, sell_mask = combine_masks(signals, mode=mode)
# 3. 包装为策略并运行回测
combo_cls = _make_combo_strategy(buy_mask, sell_mask)
engine = self._make_engine(combo_cls)
return engine.run(self._df)
def screen(
self,
combo_sizes: tuple[int, ...] = (2, 3),
mode: str = "MAJORITY",
filter_zero_trades: bool = True,
top_n: int = 0,
) -> list[ComboResult]:
"""遍历所有因子组合,批量回测并排名。
Args:
combo_sizes: 要尝试的组合大小(如 (2, 3) 表示 2 因子和 3 因子组合)
mode: 信号合并模式(AND / OR / MAJORITY
注意:MAJORITY 模式下 2 因子需要两个都同意(等同 AND),
因为阈值 = 2/2 = 1.0,需 > 1.0 才触发。
filter_zero_trades: 是否过滤零交易组合
top_n: 只返回前 N 名(0 = 全部返回)
Returns:
按 total_return 降序排列的 ComboResult 列表
"""
n_factors = len(self._strategy_classes)
results: list[ComboResult] = []
for size in combo_sizes:
if size > n_factors:
continue
for combo in itertools.combinations(range(n_factors), size):
result = self.run_combination(combo, mode=mode)
signals = [self._get_factor_signals(i) for i in combo]
name = " + ".join(s.name for s in signals)
results.append(
ComboResult(
name=name,
indices=combo,
size=size,
result=result,
)
)
# 过滤零交易
if filter_zero_trades:
results = [r for r in results if r.result.performance["total_trades"] > 0]
# 按总收益率降序排列
results.sort(key=lambda r: r.result.performance["total_return"], reverse=True)
# 截取 top_n
if top_n > 0:
results = results[:top_n]
return results