Files
easy_tdx_max/src/easy_tdx/backtest/orders.py
T
GitHub 36ea8497ae perf(engine): 信号管线提速 ×12.6 — 向量化快速路径 + 日期查找表 + 去 strftime
基准先行(scripts/bench_engine.py):ma_cross 800 根全流程基线 93.6ms。
profile 打破预期:85% 墙钟在 OrderSimulator._find_bar_index(每信号全列
strftime),而非计划认为的逐 bar 信号循环。三处优化(行为逐位一致):

- Strategy 新增 entry_exit_masks() 显式钩子,19 个内置策略全实现;引擎按
  掩码 + 候选事件 bar 状态机一次产出信号,持仓估算逐行复刻
  _update_strategy_position(含买不足 1 手退化路径);约束检测
  _vectorize_eligibility 显式可测,signal_path=auto/vector/loop 可指定;
  不满足约束(无钩子/缠论注入/掩码形状错)自动回退逐 bar
- OrderSimulator._build_dt_lookup:O(信号×bar) 全列扫描 → 每 simulate 一次
  O(bar) 查找表(重复日期取首个、未命中 None、object 恒不匹配语义对齐)
- _datetime_to_int 去 strftime:year*10000+month*100+day 整数算术
  (NaT→NaN 行为一致),_bind_data 与查找表共用

实测:ma_cross 全流程 93.6→7.4ms(×12.6),信号层 ×1.39~1.72(四策略);
32 点网格寻优 ~3.0s→231ms。对拍 39 例:19 策略×参数变体×warmup/低资金/
费率/缓存,performance/trades/equity/positions 逐位一致。
顺带记录:wr_reversal 默认阈值与 MyTT WR 刻度不匹配(策略恒不交易,既有问题未改)
2026-09-01 23:47:06 +08:00

545 lines
19 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.
"""订单撮合模拟器。
将策略信号转换为成交记录,支持多种执行模式、仓位管理和拒绝策略。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
import numpy as np
import pandas as pd
from easy_tdx.backtest.types import Signal, Trade
if TYPE_CHECKING:
from easy_tdx.backtest.slippage import SlippageModel
@dataclass
class OrderSimulator:
"""订单撮合模拟器。
将策略信号(Signal)转换为成交记录(Trade),支持多种执行模式、
仓位管理和拒绝策略。
Attributes:
df: K线数据 DataFrame
execution: 成交价规则 (next_open/next_close)
position_mode: 仓位模式 (full/fixed/percent)
reject_policy: 拒绝策略 (reduce/skip)
commission: 佣金费率
min_commission: 最低佣金
stamp_tax: 印花税率(仅卖出)
slippage: 滑点(每股)
"""
df: pd.DataFrame
execution: str = "next_open"
position_mode: str = "full"
reject_policy: str = "reduce"
commission: float = 0.0003
min_commission: float = 5.0
stamp_tax: float = 0.001
slippage: float = 0.0
slippage_model: SlippageModel | None = None
def __post_init__(self) -> None:
# datetime → 行号查找表(惰性构建一次)。v1.28 之前每个信号都对整列
# datetime 做 strftime 扫描(O(信号数×bar 数),占全流程 ~85% 墙钟),
# 网格寻优每点都跑一遍 simulate,是实测最大瓶颈。
self._dt_lookup: dict[int, int] | None = None
def simulate(
self,
signals: list[Signal],
cash: float,
position: float,
position_mode: str | None = None,
) -> list[Trade]:
"""模拟订单撮合过程。
Args:
signals: 交易信号列表
cash: 初始现金
position: 初始持仓(股数)
position_mode: 仓位模式(覆盖初始化参数)
Returns:
成交记录列表
"""
if position_mode is None:
position_mode = self.position_mode
trades: list[Trade] = []
current_cash = cash
current_position = position
for signal in signals:
# 找到信号对应的 K 线
bar_idx = self._find_bar_index(signal.datetime)
if bar_idx is None:
continue
# 信号成交时点分三类:
# - source="stop"(止损/止盈触发):延迟到下一根开盘成交,消除"用当根
# intrabar 触发价精确成交"的前视偏差;若下一根跳空,取对持仓者更不利的价。
# - price is not None 且非 stop(限价单):在信号 bar 当根以信号价成交。
# - 其他(市价策略信号):按 execution 配置(默认 next_open)在下一根成交。
if signal.source == "stop":
exec_idx_raw = self._resolve_exec_index(bar_idx)
# 下一根不可用时(信号在最后一根 bar 触发),回退到当根收盘成交,
# 避免止损信号被静默丢弃(审计 #4:不能因延迟成交而漏平仓)。
next_price: float
if exec_idx_raw is None or exec_idx_raw >= len(self.df):
exec_idx = bar_idx
row = self.df.iloc[bar_idx] if bar_idx < len(self.df) else None
if row is None:
continue
next_price = float(row["close"])
else:
exec_idx = exec_idx_raw
price_raw = self._get_price(exec_idx, signal.direction)
if price_raw is None:
continue
next_price = price_raw
# 跳空保护:对 SELL(平仓),若下一根开盘比触发价更不利(更低),
# 取实际开盘价;否则按触发价(止损已生效)。
trigger = signal.price if signal.price is not None else next_price
if signal.direction == "SELL":
price: float = min(next_price, trigger)
else:
price = max(next_price, trigger)
elif signal.price is not None:
# 限价单:在信号所在 bar 以信号价格成交
exec_idx = bar_idx
price = signal.price
else:
# 确定成交的 K 线索引
exec_idx_raw = self._resolve_exec_index(bar_idx)
if exec_idx_raw is None or exec_idx_raw >= len(self.df):
continue
exec_idx = exec_idx_raw
# 获取成交价
price_raw = self._get_price(exec_idx, signal.direction)
if price_raw is None:
continue
price = price_raw
# 执行交易
if signal.direction == "BUY":
trade = self._execute_buy(
signal=signal,
bar_idx=bar_idx,
exec_idx=exec_idx,
price=price,
cash=current_cash,
position=current_position,
position_mode=position_mode,
)
if trade is not None:
trades.append(trade)
if not trade.rejected:
current_cash -= trade.size * trade.price + trade.commission + trade.slippage
current_position += trade.size
elif signal.direction == "SELL":
trade = self._execute_sell(
signal=signal,
bar_idx=bar_idx,
exec_idx=exec_idx,
price=price,
cash=current_cash,
position=current_position,
position_mode=position_mode,
)
if trade is not None:
trades.append(trade)
if not trade.rejected:
current_cash += trade.size * trade.price - trade.commission - trade.slippage
current_position -= trade.size
return trades
def _find_bar_index(self, datetime_val: int) -> int | None:
"""查找 datetime 对应的 K 线索引。
Args:
datetime_val: 信号时间(int 格式 YYYYMMDD
Returns:
K 线索引,未找到返回 None
性能(v1.28):查找表在首次调用时构建一次(O(bar 数)),此后每个
信号 O(1) 查询;语义与逐信号全列扫描完全一致——重复日期取首个匹配
(等价于原来的 ``mask.argmax()``),未命中返回 None。
"""
if self._dt_lookup is None:
self._dt_lookup = self._build_dt_lookup()
return self._dt_lookup.get(int(datetime_val))
def _build_dt_lookup(self) -> dict[int, int]:
"""构建 datetime → 行号查找表(重复日期保留首个出现)。
与历史行为的对应关系:
- datetime64 列:向量化转 YYYYMMDD int 后建表(原来每个信号都
``strftime`` 全列扫描一遍,是 O(信号数×bar 数) 的热点);
- 数值列(int/float):整数值等价于原 ``dt_col == datetime_val`` 的
直接比较(非整数 float 不会命中,与原来一致);
- 其他列(object 等):原来直接比较恒不命中、返回 None——这里同样
产出空表(保持行为不变,包括 object-Timestamp 列不匹配的既有行为)。
"""
from easy_tdx.backtest.strategy import _datetime_to_int
dt_col = self.df["datetime"]
lookup: dict[int, int] = {}
if pd.api.types.is_datetime64_any_dtype(dt_col):
ints = _datetime_to_int(dt_col.to_numpy())
for i, v in enumerate(ints):
if v == v: # NaT → NaN,跳过(原 strftime 同样不产出该行)
lookup.setdefault(int(v), i)
return lookup
arr = dt_col.to_numpy()
if arr.dtype.kind in "iuf":
for i, v in enumerate(arr):
# 浮点列只有整数值(20240104.0)才可能与 int 信号相等,
# 与原 == 比较语义一致
if float(v).is_integer():
lookup.setdefault(int(v), i)
return lookup
# object / 字符串等:原实现 (dt_col == int) 恒为 False → 恒 None
return lookup
def _resolve_exec_index(self, bar_idx: int) -> int | None:
"""根据执行模式确定成交的 K 线索引。
开盘价 / 收盘价模式均在信号后一根 K 线成交(next_open 取次根开盘价,
next_close 取次根收盘价),避免使用信号当根的未完成/未来数据。
Args:
bar_idx: 信号对应的 K 线索引
Returns:
成交 K 线索引
"""
return bar_idx + 1
def _get_price(self, exec_idx: int, direction: str) -> float | None:
"""根据执行模式获取成交价。
Args:
exec_idx: 成交 K 线索引
direction: 交易方向(仅作保留,当前两种模式均不依赖方向)
Returns:
成交价格
"""
if exec_idx >= len(self.df):
return None
row = self.df.iloc[exec_idx]
if self.execution == "next_open":
return float(row["open"])
elif self.execution == "next_close":
return float(row["close"])
else:
return None
def _calculate_buy_size(
self,
signal_size: float,
price: float,
cash: float,
position_mode: str,
) -> float:
"""计算买入数量。
Args:
signal_size: 信号指定的数量
price: 成交价格
cash: 可用现金
position_mode: 仓位模式
Returns:
买入数量(股)
"""
if position_mode == "full" or signal_size == 0:
# 全仓:计算可用现金能买多少(100股整手)
# 先计算最大股数,然后向下取整到100的倍数
max_cost_per_share = price * (1 + self.commission) + self.slippage
max_shares_raw = cash / max_cost_per_share
max_shares = int(max_shares_raw / 100) * 100
return float(max_shares)
elif position_mode == "fixed":
# 固定股数
return signal_size
elif position_mode == "percent":
# 总资产的百分比
total_value = cash # 简化:假设现金=总资产
target_value = total_value * signal_size
max_shares = int(target_value / price / 100) * 100
return float(max_shares)
else:
return signal_size
def _calculate_sell_size(
self,
signal_size: float,
position: float,
position_mode: str,
) -> float:
"""计算卖出数量。
Args:
signal_size: 信号指定的数量
position: 当前持仓
position_mode: 仓位模式
Returns:
卖出数量(股)
"""
if position_mode == "full" or signal_size == 0:
# 全部卖出
return position
elif position_mode == "fixed":
# 固定股数
return signal_size
elif position_mode == "percent":
# 持仓的百分比
return position * signal_size
else:
return signal_size
def _calculate_commission(self, size: float, price: float, is_sell: bool = False) -> float:
"""计算手续费。
Args:
size: 成交数量
price: 成交价格
is_sell: 是否为卖出
Returns:
手续费总额
"""
# 佣金
commission = max(size * price * self.commission, self.min_commission)
# 印花税(仅卖出)
if is_sell:
stamp = size * price * self.stamp_tax
commission += stamp
return commission
def _compute_slippage(self, size: float, price: float, is_sell: bool) -> float:
"""计算滑点成本。"""
if self.slippage_model is not None:
volume = self._get_current_volume()
volatility = self._estimate_volatility()
return self.slippage_model.compute(
price=price,
size=size,
volume=volume,
volatility=volatility,
direction="SELL" if is_sell else "BUY",
)
return size * self.slippage
def _get_current_volume(self) -> float:
"""获取最后一根K线的成交量,兼容 vol/volume 列名。"""
if len(self.df) == 0:
return 0.0
for col in ("vol", "volume"):
if col in self.df.columns:
return float(self.df[col].iloc[-1])
return 0.0
def _estimate_volatility(self) -> float:
"""从收盘价估计年化波动率。"""
if "close" not in self.df.columns or len(self.df) < 2:
return 0.0
close = self.df["close"].to_numpy()
returns = np.diff(close) / close[:-1]
if len(returns) < 2:
return 0.0
daily_vol = float(np.std(returns))
return float(daily_vol * np.sqrt(252))
def _execute_buy(
self,
signal: Signal,
bar_idx: int,
exec_idx: int,
price: float,
cash: float,
position: float,
position_mode: str,
) -> Trade | None:
"""执行买入。
Args:
signal: 交易信号
bar_idx: 信号 K 线索引
exec_idx: 成交 K 线索引
price: 成交价格
cash: 可用现金
position: 当前持仓
position_mode: 仓位模式
Returns:
成交记录
"""
# 保存原始信号数量
original_size = signal.size
# 计算买入数量
size = self._calculate_buy_size(signal.size, price, cash, position_mode)
if size <= 0:
# 资金不足或计算结果为0
if self.reject_policy == "skip":
# 对于 percent 模式,original_size 是百分比(如 0.5),不是股数
# 对于 fixed/full 模式,original_size 就是股数
display_size = original_size if position_mode == "fixed" else 100
return Trade(
datetime=self.df.iloc[exec_idx]["datetime"],
direction="BUY",
size=display_size,
price=price,
commission=0.0,
slippage=0.0,
pnl=0.0,
rejected=True,
)
return None
# 计算费用
commission = self._calculate_commission(size, price, is_sell=False)
slippage = self._compute_slippage(size, price, is_sell=False)
# 检查资金是否足够
total_cost = size * price + commission + slippage
if total_cost > cash:
if self.reject_policy == "skip":
return Trade(
datetime=self.df.iloc[exec_idx]["datetime"],
direction="BUY",
size=original_size,
price=price,
commission=commission,
slippage=slippage,
pnl=0.0,
rejected=True,
)
elif self.reject_policy == "reduce":
# reduce 模式:重新计算可买数量
available_cash = cash - self.min_commission - slippage
if available_cash > price:
reduced_size = int(available_cash / price / 100) * 100
if reduced_size > 0:
commission = self._calculate_commission(reduced_size, price, is_sell=False)
slippage = self._compute_slippage(reduced_size, price, is_sell=False)
return Trade(
datetime=self.df.iloc[exec_idx]["datetime"],
direction="BUY",
size=reduced_size,
price=price,
commission=commission,
slippage=slippage,
pnl=0.0,
rejected=False,
)
# 无法买任何数量
return None
return Trade(
datetime=self.df.iloc[exec_idx]["datetime"],
direction="BUY",
size=size,
price=price,
commission=commission,
slippage=slippage,
pnl=0.0,
rejected=False,
)
def _execute_sell(
self,
signal: Signal,
bar_idx: int,
exec_idx: int,
price: float,
cash: float,
position: float,
position_mode: str,
) -> Trade | None:
"""执行卖出。
Args:
signal: 交易信号
bar_idx: 信号 K 线索引
exec_idx: 成交 K 线索引
price: 成交价格
cash: 可用现金
position: 当前持仓
position_mode: 仓位模式
Returns:
成交记录
"""
# 计算卖出数量
size = self._calculate_sell_size(signal.size, position, position_mode)
if size <= 0:
# 无持仓
if self.reject_policy == "skip":
return Trade(
datetime=self.df.iloc[exec_idx]["datetime"],
direction="SELL",
size=0,
price=price,
commission=0.0,
slippage=0.0,
pnl=0.0,
rejected=True,
)
return None
# 检查持仓是否足够
if size > position:
if self.reject_policy == "skip":
return Trade(
datetime=self.df.iloc[exec_idx]["datetime"],
direction="SELL",
size=size,
price=price,
commission=0.0,
slippage=0.0,
pnl=0.0,
rejected=True,
)
# reduce 模式:减少到实际持仓
size = position
# 计算费用
commission = self._calculate_commission(size, price, is_sell=True)
slippage = self._compute_slippage(size, price, is_sell=True)
return Trade(
datetime=self.df.iloc[exec_idx]["datetime"],
direction="SELL",
size=size,
price=price,
commission=commission,
slippage=slippage,
pnl=0.0,
rejected=False,
)