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
+14
View File
@@ -1618,6 +1618,20 @@ ruff format --check src/ tests/ # format check
## Changelog
### 1.14.1 (2026-06-15)
**高级回测 ExecutionModel 路径 3 个真实数据兼容 Bug 修复** — 实测 `601088` 高级回测(方根滑点 + TWAP)暴露:权益曲线恒定、收益归零。根因为 ExecutionModel 路径与真实行情数据的格式/列名/类型脱节。
Bug 修复:
- **datetime 类型分歧(致命)**`ExecutionModel``Trade.datetime` 转成 `int(YYYYMMDD)`,而 `PortfolioTracker` 用 df 原始 `Timestamp` 作为 `trade_map` 字典 key,导致 TWAP/VWAP/Limit 路径的交易**全部静默丢失**、权益曲线恒定、收益恒为 0%。修复:`Trade.datetime` 改用 df 原始值,与 `OrderSimulator` 一致。
- **volume 列名分歧**`execution.py`/`orders.py` 仅认 `"volume"` 列,但真实行情(`get_security_bars`)列为 `"vol"`,导致滑点模型 volume 恒为 0、`SquareRootSlippage` 退化百分比模式、VWAP 退化为等权。修复:兼容 `vol`/`volume` 列名。
- **date/datetime 列名分歧**:日线 `get_security_bars` 返回 `date` 列,但 `BacktestEngine` 硬性要求 `datetime` 列,按文档直接跑日线回测会 `ValueError`。修复:`BacktestEngine.run` 入口缺 `datetime` 时由 `date` 派生,下游无感兼容。
为何此前未发现:`test_engine_with_twap` 仅断言「生成了交易」,未断言「交易实际影响了组合」;execution 单测用 int datetime 掩盖了类型分歧。本次新增 3 个回归测试编码「权益曲线随交易变化」「vol 列可读」「date 列可跑」契约,均经红灯验证(修复前精确失败)。
验证:全部 650 单测通过,backtest 模块 ruff + mypy strict 清洁,`examples/22_backtest_advanced/backtest_601088_advanced.py` 实测权益曲线不再恒定、高级档收益从假的 0% 修正为真实的 -3.57%。
### 1.14.0 (2026-06-15)
**新增新浪财报三表** — 三层接入(编程 API / CLI / Web API),独立数据源,无需连接 TDX 行情服务器。
@@ -0,0 +1,138 @@
"""601088(中国神华)高级回测演示。
对比三档执行精度:
1. 无摩擦(baseline
2. 固定百分比滑点 + 即时成交
3. 方根市场冲击滑点 + TWAP 拆单执行(高级回测)
并对最高精度档做成本归因分析。
用法: python backtest_601088_demo.py
"""
from __future__ import annotations
import sys
# Windows GBK 终端中文输出兜底
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
import pandas as pd # noqa: E402
from easy_tdx import KlineCategory, Market, TdxClient # noqa: E402
from easy_tdx.MyTT import MA # noqa: E402
from easy_tdx.backtest import BacktestEngine, Strategy # noqa: E402
from easy_tdx.backtest.attribution import AttributionAnalyzer # noqa: E402
from easy_tdx.backtest.execution import TWAPExecution # noqa: E402
from easy_tdx.backtest.slippage import PercentSlippage, SquareRootSlippage # noqa: E402
CODE = "601088"
MARKET = Market.SH
CASH = 1_000_000.0
# ── 策略:MA10/MA30 双均线 + 8% 止损 / 18% 止盈 ──────────────────────────────────
class DualMAStrategy(Strategy):
def init(self) -> None:
self.ma10 = self.I(MA, self.data.close, 10)
self.ma30 = self.I(MA, self.data.close, 30)
def next(self) -> None:
if self._bar_index < 30:
return
i = self._bar_index
price = self.data.close[0]
holding = self.position["size"] > 0
golden = self.ma10[i] > self.ma30[i] and self.ma10[i - 1] <= self.ma30[i - 1]
death = self.ma10[i] < self.ma30[i] and self.ma10[i - 1] >= self.ma30[i - 1]
if golden and not holding:
# 金叉开多,带止损止盈
self.buy(size=0, stop_loss=price * 0.92, take_profit=price * 1.18)
elif death and holding:
self.sell(size=0)
def fmt_perf(label: str, perf: dict) -> str:
return (
f" {label:<20}"
f"总收益 {perf['total_return']:>8.2%} "
f"年化 {perf['annual_return']:>8.2%} "
f"最大回撤 {perf['max_drawdown']:>8.2%} "
f"夏普 {perf['sharpe']:>6.3f}"
)
def main() -> None:
# ── 1. 数据 ───────────────────────────────────────────────────────────────
client = TdxClient()
try:
df = client.get_security_bars(MARKET, CODE, KlineCategory.DAY, 0, 800)
finally:
client.close()
if len(df) < 60:
print(f"数据不足: 仅 {len(df)} 根 K 线,需要网络连接通达信服务器")
return
df = df.dropna(subset=["close"]).reset_index(drop=True)
dt_col = "datetime" if "datetime" in df.columns else "date"
span = f"{df[dt_col].iloc[0]} ~ {df[dt_col].iloc[-1]}"
print(f"=== {CODE} 高级回测 ===")
print(f"K 线区间: {span}{len(df)} 根 初始资金: {CASH:,.0f}\n")
# ── 2. 三档回测对比 ─────────────────────────────────────────────────────────
runs: list[tuple[str, dict]] = []
# (a) 无摩擦基线
r_a = BacktestEngine(DualMAStrategy, cash=CASH).run(df)
runs.append(("无摩擦基线", r_a.performance))
# (b) 固定百分比滑点 + 即时成交
r_b = BacktestEngine(
DualMAStrategy, cash=CASH, slippage_model=PercentSlippage(rate=0.001)
).run(df)
runs.append(("固定0.1%滑点", r_b.performance))
# (c) 高级:方根市场冲击 + TWAP 拆单
r_c = BacktestEngine(
DualMAStrategy,
cash=CASH,
slippage_model=SquareRootSlippage(impact_coeff=0.1),
execution_model=TWAPExecution(n_bars=3),
).run(df)
runs.append(("方根滑点+TWAP", r_c.performance))
print("── 性能对比 ──")
for label, perf in runs:
print(fmt_perf(label, perf))
# ── 3. 摩擦成本(基线 vs 高级)──────────────────────────────────────────────
print("\n── 摩擦成本影响 ──")
drag = r_a.performance["total_return"] - r_c.performance["total_return"]
print(f" 高级档相对无摩擦基线收益折损: {drag:.2%}")
print(f" 高级档交易笔数: {len(r_c.trades)} (含被拒单 {r_c.trades['rejected'].sum()})")
# ── 4. 高级档成本归因 ───────────────────────────────────────────────────────
print("\n── 高级档成本归因 ──")
att = AttributionAnalyzer(r_c.trades, r_c.equity_curve)
cost = att.cost_attribution()
print(f" 区间总收益: {cost.total_return:>10.2%}")
print(f" 总交易成本: {cost.total_trade_cost:>10.0f}")
print(f" 佣金: {cost.commission_cost:>10.0f}")
print(f" 滑点: {cost.slippage_cost:>10.0f}")
print(f" 印花税: {cost.stamp_tax_cost:>10.0f}")
# ── 5. 交易明细(前 8 笔)──────────────────────────────────────────────────
print("\n── 高级档交易明细(前 8 笔)──")
cols = ["datetime", "direction", "size", "price", "commission", "slippage"]
print(r_c.trades[cols].head(8).to_string(index=False))
# ── 6. 权益曲线尾段 ─────────────────────────────────────────────────────────
print("\n── 高级档权益曲线(尾 5 日)──")
print(r_c.equity_curve.tail(5).to_string(index=False))
if __name__ == "__main__":
main()
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "easy-tdx"
version = "1.14.0"
version = "1.14.1"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md"
requires-python = ">=3.10"
+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:
+81 -2
View File
@@ -7,8 +7,8 @@ import pandas as pd
from easy_tdx import MyTT
from easy_tdx.backtest.engine import BacktestEngine
from easy_tdx.backtest.execution import TWAPExecution
from easy_tdx.backtest.slippage import FixedSlippage
from easy_tdx.backtest.execution import TWAPExecution, VWAPExecution
from easy_tdx.backtest.slippage import FixedSlippage, SquareRootSlippage
from easy_tdx.backtest.strategy import Strategy
@@ -609,6 +609,63 @@ class TestEngineExecutionModel:
buy_trades = result.trades[result.trades["direction"] == "BUY"]
assert len(buy_trades) >= 1
def test_execution_model_affects_equity(self) -> None:
"""ExecutionModel 路径的交易必须真正进入 PortfolioTracker。
回归 datetime 类型分歧 bugExecutionModel 曾把 Trade.datetime 转成
int(YYYYMMDD),而 PortfolioTracker 用 df 原始 Timestamp 作为 trade_map
的 key,导致 ExecutionModel 路径(TWAP/VWAP/Limit)的交易全部被静默
跳过、权益曲线恒定、收益归零。
"""
class BuyAndHold(Strategy):
def init(self) -> None:
pass
def next(self) -> None:
if self._bar_index == 0:
self.buy(size=0) # 全仓
df = _make_df(30) # Timestamp datetime(真实行情场景)
engine = BacktestEngine(
BuyAndHold,
cash=100000,
execution_model=TWAPExecution(n_bars=3),
)
result = engine.run(df)
# 交易必须影响持仓与权益曲线(不能全程空仓 / 恒定)
assert result.positions["size"].max() > 0
assert result.equity_curve["total"].nunique() > 1
def test_execution_model_with_vol_column(self) -> None:
"""真实行情数据使用 vol 列名,VWAP/方根滑点应能读到成交量。
回归 volume 列名分歧 bug:回测代码曾只认 "volume",真实数据列为
"vol",导致滑点模型 volume 恒为 0、退化到百分比模式,VWAP 退化为等权。
"""
class BuyOnce(Strategy):
def init(self) -> None:
pass
def next(self) -> None:
if self._bar_index == 0:
self.buy(size=0)
df = _make_df(30).rename(columns={"volume": "vol"})
engine = BacktestEngine(
BuyOnce,
cash=100000,
execution_model=VWAPExecution(n_bars=3),
slippage_model=SquareRootSlippage(),
)
result = engine.run(df)
buy = result.trades[result.trades["direction"] == "BUY"]
assert len(buy) > 0
# volume 读到非 0 → 方根冲击未退化 → 滑点 > 0
assert (buy["slippage"] > 0).all()
def test_engine_backward_compatible(self) -> None:
"""No new params: behavior unchanged."""
@@ -624,3 +681,25 @@ class TestEngineExecutionModel:
engine = BacktestEngine(SimpleBuy, cash=100000)
result = engine.run(df)
assert len(result.trades) >= 1
def test_engine_accepts_date_column(self) -> None:
"""引擎应直接接受真实行情日线的 date 列(而非 datetime)。
get_security_bars 日线返回 date 列,引擎在 run() 入口由 date 派生
datetime,下游无感兼容。回归此前用户必须手动重命名才能跑日线回测的问题。
"""
class SimpleBuy(Strategy):
def init(self) -> None:
pass
def next(self) -> None:
if self._bar_index == 0:
self.buy(size=0)
# 仅 date 列、无 datetime 列 —— 模拟 get_security_bars 日线输出
df = _make_df(30).rename(columns={"datetime": "date", "volume": "vol"})
engine = BacktestEngine(SimpleBuy, cash=100000)
result = engine.run(df)
assert result.positions["size"].max() > 0