mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 21:34:21 +08:00
经三轮代码审计后的综合质量加固版本,覆盖协议核心层、数据正确性、 错误处理、测试真实度与可维护性。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
459 lines
14 KiB
Python
459 lines
14 KiB
Python
"""多因子组合回测引擎。
|
||
|
||
核心能力:
|
||
- 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/ANY(n=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
|