From 239ff46ac872b166a337cfb48f1367d0ea0eb3fa Mon Sep 17 00:00:00 2001 From: Justin Gu <97915@qq.com> Date: Sat, 4 Jul 2026 21:07:39 +0800 Subject: [PATCH] =?UTF-8?q?release:=20v1.17.13=20=E2=80=94=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E5=A4=9A=E7=AD=96=E7=95=A5=E7=BB=84=E5=90=88=E5=9B=9E?= =?UTF-8?q?=E6=B5=8B=E6=9C=80=E5=A4=A7=E5=9B=9E=E6=92=A4=E8=99=9A=E9=AB=98?= =?UTF-8?q?=EF=BC=88=E5=88=86=E6=AF=8D=E8=AF=AF=E7=94=A8=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=80=BC=E8=80=8C=E9=9D=9E=E5=B3=B0=E5=80=BC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈:3 个策略各自最大回撤仅 45.53%/40.16%/16.89%,组合却显示 83.76%。 根因:_build_combined_equity 的 drawdown_pct 分母误用 initial(固定初始资金), 净值大涨后(总收益 545%,峰值≈6.45×初始)绝对回撤额除以小初始值被等比放大。 改为 drawdown / peak(相对当时峰值,0~1),与单标的 PortfolioTracker 一致。 连带修复卡玛比率(年化/最大回撤)。其余指标经核对均正确。加回归守卫。 --- CHANGELOG.md | 9 ++++ pyproject.toml | 2 +- .../backtest/multi_strategy_engine.py | 12 +++-- tests/unit/test_multi_strategy.py | 47 +++++++++++++++++++ 4 files changed, 65 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 575fd17..cde3876 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 +## [1.17.13] — 2026-07-04 + +**修复多策略组合回测「最大回撤」严重虚高** —— 用户反馈:3 个策略各自最大回撤仅 45.53%/40.16%/16.89%,组合在一起却显示 **83.76%**。根因是 `MultiStrategyEngine._build_combined_equity` 计算 `drawdown_pct` 时**分母误用初始资金(`initial`)而非逐点峰值(`peak`)**:净值大涨后峰值是初始值的好几倍(本例总收益 545%,峰值≈6.45×初始),同样的绝对回撤额除以小的初始值,百分比被等比放大。正确公式应为 `drawdown / peak`(相对当时峰值的回撤,0~1),与单标的 `PortfolioTracker.equity_curve` 的 `drawdown_pct` 定义一致。修复后最大回撤回到合理区间(≤ 各策略最大回撤的加权,不可能超过 100%)。**连带修复**:卡玛比率(`年化收益 / 最大回撤`)此前因 max_drawdown 虚高而被压低,修复后恢复正常。其余指标(总收益/年化/夏普/索提诺/波动率/交易数/胜率/盈亏比)经逐一核对**均正确**,不受此 bug 影响。 + +### 修复 + +- **`drawdown_pct` 分母改用逐点峰值**(`src/easy_tdx/backtest/multi_strategy_engine.py` `_build_combined_equity`)—— `drawdown / initial` → `drawdown / peak`(`peak_safe = peak.where(peak != 0, 1.0)` 防除零)。这同时修复 `EquityChart` 回撤曲线显示(前端读 `drawdown_pct` 取负向下画)。 +- **回归守卫**(`tests/unit/test_multi_strategy.py::test_max_drawdown_relative_to_peak_not_initial`)—— 构造「净值 1→6→4」的大涨后回撤场景,断言 `max_drawdown ≈ 33%`(旧逻辑会算成 200%,必 >1,断言 `≤1.0` 抓住回归)。 + ## [1.17.12] — 2026-07-04 **修复 CI 在新版 FastAPI 上路由注册失败** —— v1.17.11 的 `DELETE /api/v1/strategies/{id}` 用 `status_code=204`,较新 FastAPI/Starlette 在路由注册阶段(`add_api_route`)就抛 `AssertionError: Status code 204 must not have a response body`,导致 CI 的 ubuntu 矩阵(py3.10/3.12/3.13)整片 ERROR(21 个 web 测试因 fixture 导入 router 而连带失败)。改为返回 `200 + {"deleted": id}` 确认体,既消除注册期断言又给前端明确反馈。 diff --git a/pyproject.toml b/pyproject.toml index e72bc73..9866db5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.17.12" +version = "1.17.13" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" readme = "README.md" requires-python = ">=3.10" diff --git a/src/easy_tdx/backtest/multi_strategy_engine.py b/src/easy_tdx/backtest/multi_strategy_engine.py index fd8847c..320e5a1 100644 --- a/src/easy_tdx/backtest/multi_strategy_engine.py +++ b/src/easy_tdx/backtest/multi_strategy_engine.py @@ -228,12 +228,16 @@ class MultiStrategyEngine: aligned = aligned.ffill().fillna(0) total = aligned.sum(axis=1) - # 回撤:用正值约定(peak - total),与单标的 PortfolioTracker.equity_curve - # 及 PerformanceAnalyzer 一致;EquityChart 也按正值展示(前端取负向下画)。 + # 回撤:drawdown 为绝对回撤额(峰值-当前,正值),drawdown_pct 为相对当时 + # 峰值的回撤比例(drawdown / peak,0~1)。分母必须用逐点 peak 而非固定初始值: + # 净值大涨后 peak 是初始值的好几倍,若除以 initial 会把回撤百分比严重放大 + # (如峰值 6.45x 初始时,45% 的真实回撤会被算成 293%)。与单标的 + # PortfolioTracker.equity_curve 的 drawdown/drawdown_pct 定义保持一致, + # PerformanceAnalyzer 直接读 drawdown_pct 列算 max_drawdown。 peak = total.cummax() drawdown = peak - total - initial = peak.iloc[0] if len(peak) > 0 and peak.iloc[0] != 0 else 1.0 - drawdown_pct = drawdown / initial + peak_safe = peak.where(peak != 0, 1.0) + drawdown_pct = drawdown / peak_safe return pd.DataFrame( { diff --git a/tests/unit/test_multi_strategy.py b/tests/unit/test_multi_strategy.py index 763174e..9d25fe4 100644 --- a/tests/unit/test_multi_strategy.py +++ b/tests/unit/test_multi_strategy.py @@ -119,6 +119,53 @@ class TestMultiStrategyEngine: result = MultiStrategyEngine(slots, total_cash=1_000_000).run() assert (result.combined_equity["drawdown"] >= 0).all() + def test_max_drawdown_relative_to_peak_not_initial(self) -> None: + """最大回撤必须相对「当时峰值」而非「初始资金」。 + + 回归 v1.17.11/v1.17.12 的 bug:drawdown_pct 分母误用 initial(固定初始值), + 导致净值大涨后回撤被严重放大(如峰值 6x 初始时,真实 45% 回撤被算成 290%)。 + 构造一个大涨后回撤的场景:净值为 1→6→4(即从峰值回撤 33%),验证 max_drawdown + ≈ 33%(旧逻辑会算成 200%,超出 1.0)。 + """ + # 构造单标的净值序列:前 50 根 close 线性涨到 6 倍,后 50 根跌到 4 倍。 + # 用从不交易的 HoldStrategy,使 total ≈ initial_cash(曲线不随 close 变)…… + # 不行——HoldStrategy 净值恒为初始资金,无法制造涨跌。改用直接断言合并曲线 + # 的 drawdown_pct 计算逻辑:构造两段净值的合成 df 喂给 _build_combined_equity。 + from easy_tdx.backtest.types import BacktestResult + + # 两根等长净值曲线:均从 1.0 涨到 6.0 再跌到 4.0(各 50 根,峰值在第 50 根) + dates = pd.date_range("2024-01-01", periods=100, freq="D") + up = np.linspace(1.0, 6.0, 50) # 0→50: 1→6 + down = np.linspace(6.0, 4.0, 50) # 50→100: 6→4 + totals = np.concatenate([up, down]) # 峰值 6.0 在第 50 根,谷底 4.0 在末尾 + ec = pd.DataFrame( + { + "datetime": dates, + "total": totals * 100_000, # 缩放到资金量级 + "drawdown": np.zeros(100), + "drawdown_pct": np.zeros(100), + } + ) + # 造一个空 trades/positions 的 BacktestResult 占位 + empty_df = pd.DataFrame() + fake = BacktestResult( + performance={"total_return": 3.0}, + equity_curve=ec, + trades=empty_df, + positions=empty_df, + config={}, + ) + engine = MultiStrategyEngine.__new__(MultiStrategyEngine) + combined = engine._build_combined_equity( # noqa: SLF001 — 直接测内部算法 + {"A@SZ:000001": fake}, {"A@SZ:000001": 100_000.0} + ) + # 真实最大回撤(相对峰值):峰值 600000,谷底 400000,回撤 = 200000/600000 ≈ 33.3% + dd_pct = combined["drawdown_pct"].to_numpy() + max_dd = float(np.max(dd_pct)) + assert 0.30 <= max_dd <= 0.36, f"max_drawdown 应≈33%,实际 {max_dd:.4f}" + # 旧 bug(除以 initial=100000)会算成 200%(200000/100000),必然 >1 + assert max_dd <= 1.0, "drawdown_pct 相对峰值,绝不可能超过 100%" + def test_capital_split_equal(self) -> None: """资金按策略数均分:每个槽位 1/N。""" slots = [