mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 13:24:18 +08:00
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:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 类型分歧 bug:ExecutionModel 曾把 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
|
||||
|
||||
Reference in New Issue
Block a user