From 61d1f28fd861d47c21beb40a34600d5d28b84504 Mon Sep 17 00:00:00 2001
From: im47cn <67424112+im47cn@users.noreply.github.com>
Date: Thu, 9 Jul 2026 16:48:13 +0800
Subject: [PATCH] =?UTF-8?q?polish(backtest):=20=E9=87=87=E7=BA=B3=20#67=20?=
=?UTF-8?q?=E5=AE=A1=E6=9F=A5=E5=BB=BA=E8=AE=AE=20=E2=80=94=20MC=20clip=20?=
=?UTF-8?q?=E9=98=B2=E5=BE=A1=20+=20Sortino=20=E5=8F=A3=E5=BE=84=E6=B3=A8?=
=?UTF-8?q?=E9=87=8A=20+=20=E6=A0=87=E7=AD=BE=E6=BE=84=E6=B8=85=20(#81)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
作者 review PR #67 时提的 3 条非阻断建议, 本 PR 采纳:
1. 蒙特卡罗回撤加 np.clip(pnls, -0.9999, None): 单笔 pnl <= -100% 时 (1+pnl)<=0
会让 cumprod 符号翻转、净值非正、回撤失真。回测有止损实际不发生, 纯防御兜底。
加测试 test_mc_drawdown_clips_sub_minus_100pct_pnl 用 -150% 输入验证分位仍有界。
2. _calc_stats 逐笔 Sortino 沿用 sharpe 的 sqrt(252) 基准, docstring 注释点明这是
'内部一致 > 局部绝对' 的刻意选择, 免未来审查者重复质疑。
3. 前端标签 '蒙卡回撤(95%最坏)' → '(95%置信不差于此)', 避免误解为 95 分位值。
全量 130 测试通过 (含 upstream 已修的 trailing 测试, issue #66 已解决)。
---
backend/app/backtest/engine.py | 6 +++++-
backend/tests/backtest/test_robustness_metrics.py | 10 ++++++++++
frontend/src/pages/backtest/StrategyBacktest.tsx | 2 +-
3 files changed, 16 insertions(+), 2 deletions(-)
diff --git a/backend/app/backtest/engine.py b/backend/app/backtest/engine.py
index 8af1b6f..d9be4d6 100644
--- a/backend/app/backtest/engine.py
+++ b/backend/app/backtest/engine.py
@@ -1325,6 +1325,9 @@ class BacktestEngine:
大样本 (如 full 模式数千笔) 时按 2M 单元上限压降模拟次数, 防止瞬时数组 OOM。
"""
pnls = pnls[np.isfinite(pnls)] # 剔除 inf/nan, 否则 cumprod 传播 nan 导致分位为 nan
+ # 防御: 单笔 pnl <= -100% 时 (1+pnl) <= 0 会让 cumprod 符号翻转/得非正净值, 回撤失真。
+ # 回测有止损, 实际不会发生; 兜底 clip 到 -99.99% 保证 (1+pnl) 恒正。
+ pnls = np.clip(pnls, -0.9999, None)
n = len(pnls)
if n < 3:
return {"mc_maxdd_p50": None, "mc_maxdd_p95": None}
@@ -1407,7 +1410,8 @@ class BacktestEngine:
# 夏普 — 用交易收益标准差近似
sharpe = float(np.mean(pnls) / np.std(pnls)) * np.sqrt(252) if np.std(pnls) > 0 else 0.0
- # Sortino — 与 sharpe 同基准 (逐笔收益), 仅惩罚下行波动
+ # Sortino: 刻意沿用本函数 sharpe 的逐笔收益 x sqrt(252) 基准。逐笔年化非严格正确,
+ # 但保证同一函数内 sharpe/sortino 口径一致可比 (内部一致 > 局部绝对)。仅惩罚下行波动。
sortino = BacktestEngine._sortino_ratio(pnls)
# Calmar
diff --git a/backend/tests/backtest/test_robustness_metrics.py b/backend/tests/backtest/test_robustness_metrics.py
index bcd4900..8f3d7e4 100644
--- a/backend/tests/backtest/test_robustness_metrics.py
+++ b/backend/tests/backtest/test_robustness_metrics.py
@@ -81,6 +81,16 @@ def test_mc_drawdown_ignores_non_finite():
assert BacktestEngine._mc_drawdown_percentiles(dirty) == BacktestEngine._mc_drawdown_percentiles(_MC_INPUT)
+def test_mc_drawdown_clips_sub_minus_100pct_pnl():
+ """防御: 单笔 pnl <= -100% 会让 (1+pnl)<=0 使 cumprod 符号翻转; clip 后分位仍有限。"""
+ pnls = np.array([0.05, -1.5, 0.08, -0.06, 0.02, -0.04]) # -1.5 = -150%, 现实不会有
+ r = BacktestEngine._mc_drawdown_percentiles(pnls)
+ assert r["mc_maxdd_p50"] is not None
+ for v in (r["mc_maxdd_p50"], r["mc_maxdd_p95"]):
+ assert v == v # 非 nan
+ assert -1.0 <= v <= 0.0 # 回撤有界在 (-100%, 0], 未因符号翻转失真
+
+
def test_mc_drawdown_all_positive_has_zero_drawdown():
"""全正收益: 任何重排都无回撤 → 分位均为 0。"""
pnls = np.array([0.01, 0.02, 0.03, 0.04, 0.05])
diff --git a/frontend/src/pages/backtest/StrategyBacktest.tsx b/frontend/src/pages/backtest/StrategyBacktest.tsx
index e79bb39..7c9c667 100644
--- a/frontend/src/pages/backtest/StrategyBacktest.tsx
+++ b/frontend/src/pages/backtest/StrategyBacktest.tsx
@@ -1665,7 +1665,7 @@ export function StrategyBacktest() {
color="#34d399" />
-