Files
easy_tdx_max/src/easy_tdx/backtest/execution.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

656 lines
20 KiB
Python

"""可插拔执行仿真引擎。"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any
import numpy as np
import pandas as pd
from easy_tdx.backtest.types import Trade
if TYPE_CHECKING:
from easy_tdx.backtest.slippage import SlippageModel
from easy_tdx.backtest.types import Signal
def _volume_series(df: pd.DataFrame) -> pd.Series | None:
"""取成交量序列,兼容真实行情的 ``vol`` 列与旧约定/测试的 ``volume`` 列。"""
for col in ("vol", "volume"):
if col in df.columns:
return df[col]
return None
class ExecutionModel(ABC):
"""执行仿真基类。"""
@abstractmethod
def execute(
self,
signal: Signal,
df: pd.DataFrame,
bar_idx: int,
cash: float,
position: float,
position_mode: str,
commission: float,
min_commission: float,
stamp_tax: float,
slippage_model: SlippageModel | None,
) -> list[Trade]:
"""将信号转换为一笔或多笔成交。"""
...
def _calc_commission(
self,
size: float,
price: float,
is_sell: bool,
commission: float,
min_commission: float,
stamp_tax: float,
) -> float:
"""计算手续费。"""
comm = max(size * price * commission, min_commission)
if is_sell:
comm += size * price * stamp_tax
return comm
def _calc_slippage(
self,
size: float,
price: float,
is_sell: bool,
slippage_model: SlippageModel | None,
df: pd.DataFrame,
) -> float:
"""计算滑点。"""
if slippage_model is None:
return 0.0
vol_series = _volume_series(df)
volume = float(vol_series.iloc[-1]) if vol_series is not None else 0.0
volatility = self._estimate_volatility(df)
return slippage_model.compute(
price=price,
size=size,
volume=volume,
volatility=volatility,
direction="SELL" if is_sell else "BUY",
)
def _estimate_volatility(self, df: pd.DataFrame) -> float:
"""从收盘价估计近期年化波动率。"""
if "close" not in df.columns or len(df) < 2:
return 0.0
close = df["close"].to_numpy()
returns = np.diff(close) / close[:-1]
if len(returns) < 2:
return 0.0
return float(float(np.std(returns)) * np.sqrt(252))
def _calc_buy_size(
self,
signal_size: float,
price: float,
cash: float,
position_mode: str,
commission: float,
) -> float:
"""计算买入数量。"""
if position_mode == "full" or signal_size == 0:
max_cost = price * (1 + commission)
max_shares = int(cash / max_cost / 100) * 100
return float(max_shares)
elif position_mode == "percent":
target_value = cash * signal_size
return float(int(target_value / price / 100) * 100)
return signal_size
def _get_datetime(self, df: pd.DataFrame, idx: int) -> Any:
"""获取指定 index 的 datetime 原始值。
返回与 ``df["datetime"]`` 列一致的值(Timestamp 或 int),与
``OrderSimulator`` 保持一致 —— ``PortfolioTracker.apply_trades`` 用
``df["datetime"]`` 作为字典 key 查找 ``trade.datetime``,两者类型必须
相同,否则交易会被静默跳过(权益曲线恒定、收益归零)。
"""
return df["datetime"].iloc[idx]
class ImmediateExecution(ExecutionModel):
"""即时成交(向后兼容,与现有 OrderSimulator 行为一致)。"""
def execute(
self,
signal: Signal,
df: pd.DataFrame,
bar_idx: int,
cash: float,
position: float,
position_mode: str,
commission: float,
min_commission: float,
stamp_tax: float,
slippage_model: SlippageModel | None,
) -> list[Trade]:
exec_idx = bar_idx + 1
if exec_idx >= len(df):
return []
price = float(df["open"].iloc[exec_idx])
if signal.direction == "BUY":
size = self._calc_buy_size(
signal.size,
price,
cash,
position_mode,
commission,
)
if size <= 0:
return []
comm = self._calc_commission(
size,
price,
False,
commission,
min_commission,
stamp_tax,
)
slip = self._calc_slippage(size, price, False, slippage_model, df)
return [
Trade(
datetime=self._get_datetime(df, exec_idx),
direction="BUY",
size=size,
price=price,
commission=comm,
slippage=slip,
)
]
elif signal.direction == "SELL":
size = signal.size if signal.size > 0 else position
if size <= 0:
return []
if size > position:
size = position
comm = self._calc_commission(
size,
price,
True,
commission,
min_commission,
stamp_tax,
)
slip = self._calc_slippage(size, price, True, slippage_model, df)
return [
Trade(
datetime=self._get_datetime(df, exec_idx),
direction="SELL",
size=size,
price=price,
commission=comm,
slippage=slip,
)
]
return []
class TWAPExecution(ExecutionModel):
"""时间加权平均价格执行。
将订单均匀拆分为 n_bars 份,在连续 n_bars 根 K 线上执行。
"""
def __init__(self, n_bars: int = 5) -> None:
self._n_bars = max(1, n_bars)
def execute(
self,
signal: Signal,
df: pd.DataFrame,
bar_idx: int,
cash: float,
position: float,
position_mode: str,
commission: float,
min_commission: float,
stamp_tax: float,
slippage_model: SlippageModel | None,
) -> list[Trade]:
if signal.direction == "BUY":
return self._execute_buy(
signal,
df,
bar_idx,
cash,
position_mode,
commission,
min_commission,
stamp_tax,
slippage_model,
)
return self._execute_sell(
signal,
df,
bar_idx,
position,
commission,
min_commission,
stamp_tax,
slippage_model,
)
def _execute_buy(
self,
signal: Signal,
df: pd.DataFrame,
bar_idx: int,
cash: float,
position_mode: str,
commission: float,
min_commission: float,
stamp_tax: float,
slippage_model: SlippageModel | None,
) -> list[Trade]:
first_price = float(df["open"].iloc[bar_idx + 1]) if bar_idx + 1 < len(df) else 0
if first_price <= 0:
return []
total_size = self._calc_buy_size(
signal.size,
first_price,
cash,
position_mode,
commission,
)
if total_size <= 0:
return []
sub_size = int(total_size / self._n_bars / 100) * 100
if sub_size <= 0:
sub_size = 100
trades: list[Trade] = []
for i in range(self._n_bars):
exec_idx = bar_idx + 1 + i
if exec_idx >= len(df):
break
price = float(df["close"].iloc[exec_idx])
remaining = total_size - sum(t.size for t in trades)
actual_size = min(sub_size, remaining)
actual_size = int(actual_size / 100) * 100
if actual_size <= 0:
break
comm = self._calc_commission(
actual_size,
price,
False,
commission,
min_commission,
stamp_tax,
)
slip = self._calc_slippage(actual_size, price, False, slippage_model, df)
trades.append(
Trade(
datetime=self._get_datetime(df, exec_idx),
direction="BUY",
size=float(actual_size),
price=price,
commission=comm,
slippage=slip,
)
)
return trades
def _execute_sell(
self,
signal: Signal,
df: pd.DataFrame,
bar_idx: int,
position: float,
commission: float,
min_commission: float,
stamp_tax: float,
slippage_model: SlippageModel | None,
) -> list[Trade]:
total_size = signal.size if signal.size > 0 else position
if total_size <= 0:
return []
sub_size = int(total_size / self._n_bars / 100) * 100
if sub_size <= 0:
sub_size = 100
trades: list[Trade] = []
for i in range(self._n_bars):
exec_idx = bar_idx + 1 + i
if exec_idx >= len(df):
break
price = float(df["close"].iloc[exec_idx])
remaining = total_size - sum(t.size for t in trades)
actual_size = min(sub_size, remaining)
actual_size = int(actual_size / 100) * 100
if actual_size <= 0:
break
comm = self._calc_commission(
actual_size,
price,
True,
commission,
min_commission,
stamp_tax,
)
slip = self._calc_slippage(actual_size, price, True, slippage_model, df)
trades.append(
Trade(
datetime=self._get_datetime(df, exec_idx),
direction="SELL",
size=float(actual_size),
price=price,
commission=comm,
slippage=slip,
)
)
return trades
class VWAPExecution(ExecutionModel):
"""成交量加权平均价格执行。
按历史成交量分布比例拆分订单。
"""
def __init__(self, n_bars: int = 5, volume_lookback: int = 20) -> None:
self._n_bars = max(1, n_bars)
self._volume_lookback = max(1, volume_lookback)
def execute(
self,
signal: Signal,
df: pd.DataFrame,
bar_idx: int,
cash: float,
position: float,
position_mode: str,
commission: float,
min_commission: float,
stamp_tax: float,
slippage_model: SlippageModel | None,
) -> list[Trade]:
if signal.direction == "BUY":
return self._execute_buy(
signal,
df,
bar_idx,
cash,
position_mode,
commission,
min_commission,
stamp_tax,
slippage_model,
)
return self._execute_sell(
signal,
df,
bar_idx,
position,
commission,
min_commission,
stamp_tax,
slippage_model,
)
def _get_volume_weights(self, df: pd.DataFrame, bar_idx: int) -> list[float]:
"""获取未来 n_bars 根的成交量权重分布(用于 VWAP 拆单)。
**仅使用 ``bar_idx`` 及之前的成交量(lookback 窗口)估计未来分布,
严格不读未来数据,避免前视偏差**(与 TWAP 等模型只用历史数据的约定一致)。
当 lookback 数据少于 n_bars 时,用 ``np.resize`` 显式平铺到 n_bars 长度,
消除旧的 ``i % len(volumes)`` 取模在 n_bars > lookback 时产生的周期性
循环索引(与真实 VWAP 行为不符)。
"""
start = max(0, bar_idx - self._volume_lookback + 1)
lookback = df.iloc[start : bar_idx + 1]
vol_series = _volume_series(lookback)
if vol_series is None or len(lookback) == 0:
return [1.0 / self._n_bars] * self._n_bars
volumes = vol_series.to_numpy()
if float(volumes.sum()) <= 0:
return [1.0 / self._n_bars] * self._n_bars
# 平铺到 n_bars 长度(不足时重复整个历史序列,作为因果合法的外推近似)
vols = np.resize(volumes, self._n_bars)
total_vol = float(vols.sum())
if total_vol <= 0:
return [1.0 / self._n_bars] * self._n_bars
weights = [float(v) / total_vol for v in vols]
total_w = sum(weights)
if total_w <= 0:
return [1.0 / self._n_bars] * self._n_bars
return [w / total_w for w in weights]
def _execute_buy(
self,
signal: Signal,
df: pd.DataFrame,
bar_idx: int,
cash: float,
position_mode: str,
commission: float,
min_commission: float,
stamp_tax: float,
slippage_model: SlippageModel | None,
) -> list[Trade]:
first_price = float(df["open"].iloc[bar_idx + 1]) if bar_idx + 1 < len(df) else 0
if first_price <= 0:
return []
total_size = self._calc_buy_size(
signal.size,
first_price,
cash,
position_mode,
commission,
)
if total_size <= 0:
return []
weights = self._get_volume_weights(df, bar_idx)
trades: list[Trade] = []
for i in range(self._n_bars):
exec_idx = bar_idx + 1 + i
if exec_idx >= len(df):
break
price = float(df["close"].iloc[exec_idx])
w = weights[i] if i < len(weights) else 1.0 / self._n_bars
target = int(total_size * w / 100) * 100
remaining = total_size - sum(t.size for t in trades)
actual_size = min(target, remaining)
actual_size = int(actual_size / 100) * 100
if actual_size <= 0:
continue
comm = self._calc_commission(
actual_size,
price,
False,
commission,
min_commission,
stamp_tax,
)
slip = self._calc_slippage(actual_size, price, False, slippage_model, df)
trades.append(
Trade(
datetime=self._get_datetime(df, exec_idx),
direction="BUY",
size=float(actual_size),
price=price,
commission=comm,
slippage=slip,
)
)
return trades
def _execute_sell(
self,
signal: Signal,
df: pd.DataFrame,
bar_idx: int,
position: float,
commission: float,
min_commission: float,
stamp_tax: float,
slippage_model: SlippageModel | None,
) -> list[Trade]:
total_size = signal.size if signal.size > 0 else position
if total_size <= 0:
return []
weights = self._get_volume_weights(df, bar_idx)
trades: list[Trade] = []
for i in range(self._n_bars):
exec_idx = bar_idx + 1 + i
if exec_idx >= len(df):
break
price = float(df["close"].iloc[exec_idx])
w = weights[i] if i < len(weights) else 1.0 / self._n_bars
target = int(total_size * w / 100) * 100
remaining = total_size - sum(t.size for t in trades)
actual_size = min(target, remaining)
actual_size = int(actual_size / 100) * 100
if actual_size <= 0:
continue
comm = self._calc_commission(
actual_size,
price,
True,
commission,
min_commission,
stamp_tax,
)
slip = self._calc_slippage(actual_size, price, True, slippage_model, df)
trades.append(
Trade(
datetime=self._get_datetime(df, exec_idx),
direction="SELL",
size=float(actual_size),
price=price,
commission=comm,
slippage=slip,
)
)
return trades
class LimitExecution(ExecutionModel):
"""限价单执行。
在目标价位挂单,仅当 bar_low <= price(买入)或 bar_high >= price(卖出)时成交。
无限价时退化为 ImmediateExecution。
"""
def __init__(self, ttl_bars: int = 5) -> None:
self._ttl_bars = max(1, ttl_bars)
self._fallback = ImmediateExecution()
def execute(
self,
signal: Signal,
df: pd.DataFrame,
bar_idx: int,
cash: float,
position: float,
position_mode: str,
commission: float,
min_commission: float,
stamp_tax: float,
slippage_model: SlippageModel | None,
) -> list[Trade]:
if signal.price is None:
return self._fallback.execute(
signal,
df,
bar_idx,
cash,
position,
position_mode,
commission,
min_commission,
stamp_tax,
slippage_model,
)
target_price = signal.price
for i in range(self._ttl_bars):
exec_idx = bar_idx + 1 + i
if exec_idx >= len(df):
break
row = df.iloc[exec_idx]
triggered = False
if signal.direction == "BUY" and float(row["low"]) <= target_price:
triggered = True
elif signal.direction == "SELL" and float(row["high"]) >= target_price:
triggered = True
if triggered:
if signal.direction == "BUY":
size = self._calc_buy_size(
signal.size,
target_price,
cash,
position_mode,
commission,
)
if size <= 0:
return []
comm = self._calc_commission(
size,
target_price,
False,
commission,
min_commission,
stamp_tax,
)
slip = self._calc_slippage(
size,
target_price,
False,
slippage_model,
df,
)
else:
size = signal.size if signal.size > 0 else position
if size <= 0:
return []
if size > position:
size = position
comm = self._calc_commission(
size,
target_price,
True,
commission,
min_commission,
stamp_tax,
)
slip = self._calc_slippage(
size,
target_price,
True,
slippage_model,
df,
)
return [
Trade(
datetime=self._get_datetime(df, exec_idx),
direction=signal.direction,
size=float(size),
price=target_price,
commission=comm,
slippage=slip,
)
]
return []