release: v1.14.1 — 高级回测 ExecutionModel 路径 3 个真实数据兼容 Bug 修复

- datetime 类型分歧(致命):Trade.datetime 转 int 与 PortfolioTracker 的 Timestamp key 失配,TWAP/VWAP/Limit 路径交易全部静默丢失、权益曲线恒定、收益归零
- volume 列名分歧:回测认 volume 而真实行情为 vol,滑点 volume 恒 0 退化百分比模式,VWAP 退化为等权
- date/datetime 列名分歧:日线返回 date 列引擎要 datetime,run() 入口由 date 派生下游无感兼容
新增 3 个回归测试(均红灯验证)。650 单测通过,backtest 模块 ruff + mypy strict 清洁。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
GitHub
2026-06-15 20:50:49 +08:00
co-authored by Claude
parent b49cfd66f8
commit c54071e85e
7 changed files with 277 additions and 23 deletions
+7
View File
@@ -114,6 +114,13 @@ class BacktestEngine:
if len(df) == 0:
return self._empty_result()
# 兼容真实行情日线数据:get_security_bars 日线返回 date 列,引擎内部
# StrategyDataProxy / PortfolioTracker / _find_bar_index)统一使用
# datetime 列。缺则由 date 派生,避免上层手动重命名。
if "datetime" not in df.columns and "date" in df.columns:
df = df.copy()
df["datetime"] = df["date"]
# Auto-compute chanlun if chanlun_level is set and no manual result
if chanlun_result is None and self._chanlun_level is not None:
from easy_tdx.chanlun.analyser import ChanlunAnalyser
+30 -17
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
import numpy as np
import pandas as pd
@@ -15,6 +15,14 @@ if TYPE_CHECKING:
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):
"""执行仿真基类。"""
@@ -61,7 +69,8 @@ class ExecutionModel(ABC):
"""计算滑点。"""
if slippage_model is None:
return 0.0
volume = float(df["volume"].iloc[-1]) if "volume" in df.columns else 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,
@@ -99,12 +108,15 @@ class ExecutionModel(ABC):
return float(int(target_value / price / 100) * 100)
return signal_size
def _get_datetime_int(self, df: pd.DataFrame, idx: int) -> int:
"""获取指定 index 的 datetime int。"""
dt_raw = df["datetime"].iloc[idx]
if hasattr(dt_raw, "strftime"):
return int(dt_raw.strftime("%Y%m%d"))
return int(dt_raw)
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):
@@ -150,7 +162,7 @@ class ImmediateExecution(ExecutionModel):
slip = self._calc_slippage(size, price, False, slippage_model, df)
return [
Trade(
datetime=self._get_datetime_int(df, exec_idx),
datetime=self._get_datetime(df, exec_idx),
direction="BUY",
size=size,
price=price,
@@ -175,7 +187,7 @@ class ImmediateExecution(ExecutionModel):
slip = self._calc_slippage(size, price, True, slippage_model, df)
return [
Trade(
datetime=self._get_datetime_int(df, exec_idx),
datetime=self._get_datetime(df, exec_idx),
direction="SELL",
size=size,
price=price,
@@ -283,7 +295,7 @@ class TWAPExecution(ExecutionModel):
slip = self._calc_slippage(actual_size, price, False, slippage_model, df)
trades.append(
Trade(
datetime=self._get_datetime_int(df, exec_idx),
datetime=self._get_datetime(df, exec_idx),
direction="BUY",
size=float(actual_size),
price=price,
@@ -334,7 +346,7 @@ class TWAPExecution(ExecutionModel):
slip = self._calc_slippage(actual_size, price, True, slippage_model, df)
trades.append(
Trade(
datetime=self._get_datetime_int(df, exec_idx),
datetime=self._get_datetime(df, exec_idx),
direction="SELL",
size=float(actual_size),
price=price,
@@ -395,10 +407,11 @@ class VWAPExecution(ExecutionModel):
"""获取成交量权重分布。"""
start = max(0, bar_idx - self._volume_lookback + 1)
lookback = df.iloc[start : bar_idx + 1]
if "volume" not in lookback.columns or len(lookback) == 0:
vol_series = _volume_series(lookback)
if vol_series is None or len(lookback) == 0:
return [1.0 / self._n_bars] * self._n_bars
volumes = lookback["volume"].to_numpy()
volumes = vol_series.to_numpy()
total_vol = float(volumes.sum())
if total_vol <= 0:
return [1.0 / self._n_bars] * self._n_bars
@@ -462,7 +475,7 @@ class VWAPExecution(ExecutionModel):
slip = self._calc_slippage(actual_size, price, False, slippage_model, df)
trades.append(
Trade(
datetime=self._get_datetime_int(df, exec_idx),
datetime=self._get_datetime(df, exec_idx),
direction="BUY",
size=float(actual_size),
price=price,
@@ -512,7 +525,7 @@ class VWAPExecution(ExecutionModel):
slip = self._calc_slippage(actual_size, price, True, slippage_model, df)
trades.append(
Trade(
datetime=self._get_datetime_int(df, exec_idx),
datetime=self._get_datetime(df, exec_idx),
direction="SELL",
size=float(actual_size),
price=price,
@@ -624,7 +637,7 @@ class LimitExecution(ExecutionModel):
return [
Trade(
datetime=self._get_datetime_int(df, exec_idx),
datetime=self._get_datetime(df, exec_idx),
direction=signal.direction,
size=float(size),
price=target_price,
+6 -3
View File
@@ -312,9 +312,12 @@ class OrderSimulator:
return size * self.slippage
def _get_current_volume(self) -> float:
"""获取最后一根K线的成交量。"""
if "volume" in self.df.columns and len(self.df) > 0:
return float(self.df["volume"].iloc[-1])
"""获取最后一根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: