Files
easy-tdx/src/easy_tdx/backtest/orders.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

532 lines
18 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/this_close/worst/best)
position_mode: 仓位模式 (full/fixed/percent)
reject_policy: 拒绝策略 (reduce/skip)
commission: 佣金费率
min_commission: 最低佣金
stamp_tax: 印花税率(仅卖出)
slippage: 滑点(每股)
future_leak_warning: 是否使用了未来数据(this_close 模式)
"""
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
future_leak_warning: bool = False
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
"""
# 检查 df 中的 datetime 列类型
dt_col = self.df["datetime"]
# 尝试直接比较(如果是 int 类型)
# 注意:用 to_numpy().argmax() 取位置索引,而非 idxmax()(返回 label),
# 因为后续 self.df.iloc[...] 按位置取行;若 df.index 非默认 RangeIndex
# label != position 会导致撮合取错 bar。
try:
mask = (dt_col == datetime_val).to_numpy()
if mask.any():
return int(mask.argmax())
except (TypeError, ValueError):
pass
# 如果是 datetime 对象,转为 int 比较
if pd.api.types.is_datetime64_any_dtype(dt_col):
dt_ints = dt_col.dt.strftime("%Y%m%d").astype(int)
mask_arr = (dt_ints == datetime_val).to_numpy()
if mask_arr.any():
return int(mask_arr.argmax())
return None
return None
def _resolve_exec_index(self, bar_idx: int) -> int | None:
"""根据执行模式确定成交的 K 线索引。
Args:
bar_idx: 信号对应的 K 线索引
Returns:
成交 K 线索引
"""
if self.execution == "this_close":
# 当信号 K 线收盘时成交
self.future_leak_warning = True
return bar_idx
else:
# 其他模式在下一根 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"])
elif self.execution == "this_close":
return float(row["close"])
elif self.execution == "worst":
# 买入取最高价,卖出取最低价
return float(row["high"]) if direction == "BUY" else float(row["low"])
elif self.execution == "best":
# 买入取最低价,卖出取最高价
return float(row["low"]) if direction == "BUY" else float(row["high"])
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,
)