diff --git a/README.md b/README.md index b050219..110b4cc 100644 --- a/README.md +++ b/README.md @@ -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 行情服务器。 diff --git a/examples/22_backtest_advanced/backtest_601088_advanced.py b/examples/22_backtest_advanced/backtest_601088_advanced.py new file mode 100644 index 0000000..575d929 --- /dev/null +++ b/examples/22_backtest_advanced/backtest_601088_advanced.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml index 7712179..be47af4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/easy_tdx/backtest/engine.py b/src/easy_tdx/backtest/engine.py index 943c893..1ba4138 100644 --- a/src/easy_tdx/backtest/engine.py +++ b/src/easy_tdx/backtest/engine.py @@ -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 diff --git a/src/easy_tdx/backtest/execution.py b/src/easy_tdx/backtest/execution.py index bc1ba57..8dd91b5 100644 --- a/src/easy_tdx/backtest/execution.py +++ b/src/easy_tdx/backtest/execution.py @@ -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, diff --git a/src/easy_tdx/backtest/orders.py b/src/easy_tdx/backtest/orders.py index b6a03bb..e5bad93 100644 --- a/src/easy_tdx/backtest/orders.py +++ b/src/easy_tdx/backtest/orders.py @@ -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: diff --git a/tests/unit/test_backtest_engine.py b/tests/unit/test_backtest_engine.py index 25a39f2..e1c0584 100644 --- a/tests/unit/test_backtest_engine.py +++ b/tests/unit/test_backtest_engine.py @@ -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