release: v1.32.5 — 回撤持续统计修复,统一为最长水下期口径(单标的风险指标恒为 1)

This commit is contained in:
Justin Gu
2026-09-06 02:42:34 +08:00
parent c1d1349c8b
commit 99733f13d1
7 changed files with 122 additions and 43 deletions
+14
View File
@@ -2,6 +2,20 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
## [1.32.5] — 2026-09-06
**回撤持续统计修复:不再恒为 1**——单标的回测「绩效指标 → 风险 → 回撤持续」此前无论什么股票都显示 1,本版修复计算错误,并把三处实现的口径统一为指标文档承诺的「最长水下期」:从净值峰值跌落到重新创新高的最长天数(末日仍未修复则计到最后一天),即"最长一次套牢了多久"。
### 修复
- **回撤持续恒为 1**[performance.py](src/easy_tdx/backtest/performance.py)):`_compute_max_dd_duration` 旧实现从最大回撤谷底向前找"第一根高于谷底的 bar",下跌途中那几乎总是紧邻的上一根,导致任何股票该指标恒为 1;重写为以创新高(drawdown 归零)为界切分水下区间取最长段。单标的、组合、轮动回测共用此分析器,一并修复。
- **组合视图/评级口径同步**[grading.py](src/easy_tdx/backtest/grading.py)、[combinedMetrics.ts](web-ui/src/grading/combinedMetrics.ts)):两处此前算的是"峰值→谷底"距离,与术语表及评级锚点量纲(30 天/90 天/365 天…)承诺的"峰值→重新创新高"不符,会低估持续天数、虚高风险维度得分;同步改为最长水下期口径,与 `performance.py` 三端口径一致。最大回撤数值不受影响。
### 测试
- 新增 3 例回归([test_backtest_performance.py](tests/unit/test_backtest_performance.py)):最长水下期取值、末日未修复计到最后一根、全程无回撤为 0;grading 组合指标用例加固为精确断言([test_grading_scoring.py](tests/unit/test_grading_scoring.py))。
- 用真实数据(603519 日线 bbp_reversal 近 800 根)端到端核对:旧代码返回 1,修复后同一资金曲线算出 293 根(2024-06-26 峰值 → 2025-09-05 收复)。全量回归:pytest 1667 通过、ruff / ruff format / mypy 严格模式 / 前端 `vue-tsc` 全绿。
## [1.32.4] — 2026-09-05
**盘面洞察系列:六栏目 + AI 复盘 + 资金日历**——从"热点滚动"出发,把"快速理解盘面"做成一个系列:交易日×板块涨跌矩阵看热点轮动、指数红绿日历看全年情绪、连板天梯看涨停生态、宽度分时+涨停温度计看市场冷热、相关性热力图看抱团与跷跷板、量能曲线看放量缩量,最后一键交给 AI 生成盘面复盘。另含成分股排序/截断两个真实 bug 修复与龙头池下线。
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "easy-tdx"
version = "1.32.4"
version = "1.32.5"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md"
requires-python = ">=3.10"
+15 -7
View File
@@ -460,30 +460,38 @@ def compute_combined_metrics(equity: list[dict[str, Any]]) -> CombinedMetrics:
downside_std = float(np.sqrt(np.mean(downside**2))) if len(downside) else 0.0
sortino = mean_r / downside_std * math.sqrt(_TRADING_DAYS_PER_YEAR) if downside_std > 0 else 0.0
# 最大回撤 & 持续:优先用 drawdown_pct(与前端一致),缺则从 totals 反推
# 最大回撤:优先用 drawdown_pct(与前端一致),缺则从 totals 反推
# 持续 = 最长水下期(峰值 → 重新创新高;末日未修复则计到最后一点),
# 与 performance.py / combinedMetrics.ts / max_dd_duration 锚点量纲同口径。
max_dd = 0.0
max_dd_dur = 0
if equity[0].get("drawdown_pct") is not None:
cur_peak = 0
last_peak = 0
for i, e in enumerate(equity):
dd = float(e.get("drawdown_pct") or 0.0)
if dd > max_dd:
max_dd = dd
max_dd_dur = i - cur_peak
if dd == 0:
cur_peak = i
# 间隔 ≥2 点才夹着真实的水下段(相邻新高不算回撤)
if i - last_peak > 1:
max_dd_dur = max(max_dd_dur, i - last_peak)
last_peak = i
max_dd_dur = max(max_dd_dur, n - 1 - last_peak)
else:
running_peak = totals[0]
cur_peak = 0
last_peak = 0
for i, v in enumerate(totals):
if v > running_peak:
running_peak = v
cur_peak = i
if running_peak > 0:
dd_pct = (running_peak - v) / running_peak
if dd_pct > max_dd:
max_dd = dd_pct
max_dd_dur = i - cur_peak
if dd_pct == 0:
if i - last_peak > 1:
max_dd_dur = max(max_dd_dur, i - last_peak)
last_peak = i
max_dd_dur = max(max_dd_dur, n - 1 - last_peak)
if max_dd > 0:
calmar = annual_return / max_dd
+22 -21
View File
@@ -60,7 +60,8 @@ class PerformanceAnalyzer:
- total_return: 总收益率
- annual_return: 年化收益率
- max_drawdown: 最大回撤
- max_dd_duration: 最大回撤持续时间(bar 数)
- max_dd_duration: 最大回撤持续时间(最长水下期:峰值到重新
创新高的 bar 数,末日未修复则计到最后一根)
- sharpe: 夏普比率
- sortino: 索提诺比率
- calmar: 卡玛比率
@@ -118,8 +119,8 @@ class PerformanceAnalyzer:
drawdown_pct = self._equity_curve["drawdown_pct"].to_numpy()
max_drawdown = float(np.max(drawdown_pct))
# 4. 最大回撤持续时间
max_dd_duration = self._compute_max_dd_duration(total, drawdown)
# 4. 最大回撤持续时间(最长水下期:峰值 → 重新创新高)
max_dd_duration = self._compute_max_dd_duration(drawdown)
# 5. 夏普比率
rf_daily = self._risk_free_rate / self.ANNUAL_DAYS
@@ -366,36 +367,36 @@ class PerformanceAnalyzer:
return total_days, total_size
def _compute_max_dd_duration(self, total: NDArray, drawdown: NDArray) -> int:
"""计算最大回撤持续时间。
def _compute_max_dd_duration(self, drawdown: NDArray) -> int:
"""计算最大回撤持续时间(最长水下期)
找到最大回撤点,然后计算从回撤前的高点到该点的 bar 数。
以创新高(drawdown == 0)为界切分水下区间,取「从峰值跌落到
重新回到前高」的最长一段 bar 数;末日仍未修复的区间计到最后一根。
与 glossary「最长一次套牢了多久」、grading 的 max_dd_duration 锚点
量纲(30 天/90 天/365 天…)以及前端 computeCombinedMetrics 同口径。
Args:
total: 总权益数组
drawdown: 回撤数组
drawdown: 回撤数组(peak - total,与资金曲线等长)
Returns:
大回撤持续时间(bar 数)
长水下期(bar 数);全程无回撤时为 0
"""
if len(drawdown) == 0:
return 0
max_dd_idx: int = int(np.argmax(drawdown))
max_dd_value = drawdown[max_dd_idx]
# 如果没有回撤,返回 0
if max_dd_value == 0:
peak_hits = np.flatnonzero(drawdown == 0)
if peak_hits.size == 0:
return 0
# 找到回撤前的高点
peak_idx: int = max_dd_idx
for i in range(max_dd_idx - 1, -1, -1):
if total[i] > total[max_dd_idx]:
peak_idx = i
break
# 相邻两个创新高点间隔 ≥2 根才夹着真实的水下段(间隔 1 为连续新高)
gaps = np.diff(peak_hits)
deep_gaps = gaps[gaps > 1]
longest = int(deep_gaps.max()) if deep_gaps.size > 0 else 0
return int(max_dd_idx - peak_idx)
# 末日仍在水下:从最后一次创新高计到最后一根
tail = len(drawdown) - 1 - int(peak_hits[-1])
return int(max(longest, tail))
def _empty_metrics(self) -> dict[str, float]:
"""返回全零指标字典(数据不足时的默认返回值)。
+34
View File
@@ -610,3 +610,37 @@ def test_avg_win_zero_when_no_cost_basis_column() -> None:
# 无 cost_basis → 单笔收益率无法计算 → 记 0.0,不抛异常
assert metrics["avg_win"] == 0.0
assert metrics["max_win"] == 0.0
# ── 回归测试:回撤持续 = 最长水下期 ─────────────────────────────────────────
# 旧实现从最大回撤点向前找「第一根高于谷底的 bar」,下跌途中那几乎总是
# 紧邻的上一根,导致单标的回测的回撤持续恒为 1。新口径(与 glossary、
# grading 锚点量纲、前端 computeCombinedMetrics 一致):从峰值跌落到重新
# 创新高的最长 bar 数;末日仍未修复则计到最后一根。
def test_max_dd_duration_longest_underwater_span() -> None:
"""回撤持续应取最长水下期(峰值 → 重新创新高),而非恒为 1。
total=[100, 110, 105, 95, 90, 100, 110, 120, 115]
idx1 峰值 110 → idx6 回到 110,水下 5 根(旧实现会返回 1);
idx7 峰值 120 后回落,末日未修复,尾段 1 根。
"""
metrics = _metrics_from_total([100, 110, 105, 95, 90, 100, 110, 120, 115])
assert metrics["max_dd_duration"] == 5
def test_max_dd_duration_unclosed_counts_to_last_bar() -> None:
"""末日仍未修复的水下期应计到最后一根。"""
metrics = _metrics_from_total([100, 95, 90, 92])
# idx0 峰值后再未回到 100,水下 3 根
assert metrics["max_dd_duration"] == 3
def test_max_dd_duration_consecutive_peaks_not_counted() -> None:
"""连续创新高(无水下 bar)不计入回撤持续,全程无回撤时为 0。"""
metrics = _metrics_from_total([100, 105, 110, 120])
assert metrics["max_dd_duration"] == 0
+14 -1
View File
@@ -189,7 +189,20 @@ def test_combined_metrics_with_drawdown():
eq[i]["total"] *= 0.8
m = compute_combined_metrics(eq)
assert m.max_drawdown >= 0.19
assert m.max_dd_duration > 0
# 最长水下期:峰值 idx49 → 重新创新高 idx70 = 21 根
# (旧实现按「峰值→谷底」只算 20,且单标的侧旧 bug 恒为 1)
assert m.max_dd_duration == 21
def test_combined_metrics_dd_duration_unclosed_counts_to_end():
"""末日仍未修复的水下期应计到最后一点。"""
eq = _equity(120)
# 尾段砸 10% 的坑且不再收回
for i in range(100, 120):
eq[i]["total"] *= 0.9
m = compute_combined_metrics(eq)
# idx99 最后一次创新高 → 末日 idx119,共 20 根
assert m.max_dd_duration == 20
def test_combined_metrics_insufficient_points():
+20 -11
View File
@@ -113,39 +113,48 @@ export function computeCombinedMetrics(equity: EquityPoint[]): CombinedMetrics {
// ── 最大回撤 & 持续天数 ─────────────────────────────────────────────────
// 优先用后端已算好的 drawdown_pct(与图表一致),反推峰值&持续更准。
// 若后端字段缺失,再退回从 totals 反推。
// 回撤持续 = 最长水下期:从峰值跌落到重新创新高的最长交易日数
// (末日仍未修复则计到最后一天),与 glossary / grading 锚点同口径。
let maxDrawdown = 0
let maxDdDuration = 0
if (equity[0].drawdown_pct !== undefined) {
let curPeakIdx = 0
let lastPeakIdx = 0
for (let i = 0; i < n; i++) {
const dd = equity[i].drawdown_pct
if (dd > maxDrawdown) {
maxDrawdown = dd
maxDdDuration = i - curPeakIdx
}
// 触及新峰值:重置当前峰值点
// 注意 drawdown_pct == 0 表示创新高
if (dd === 0) curPeakIdx = i
// 触及前高(drawdown_pct == 0)结算一段水下期;相邻新高不算回撤
if (dd === 0) {
if (i - lastPeakIdx > 1) {
maxDdDuration = Math.max(maxDdDuration, i - lastPeakIdx)
}
lastPeakIdx = i
}
}
// 末日仍在水下:从最后一次创新高计到最后一天
maxDdDuration = Math.max(maxDdDuration, n - 1 - lastPeakIdx)
} else {
// 退化路径:从 totals 反推
let runningPeak = totals[0]
let curPeakIdx = 0
let lastPeakIdx = 0
for (let i = 0; i < n; i++) {
if (totals[i] > runningPeak) {
runningPeak = totals[i]
curPeakIdx = i
}
if (runningPeak > 0) {
const dd = runningPeak - totals[i]
const ddPct = dd / runningPeak
const ddPct = runningPeak > 0 ? (runningPeak - totals[i]) / runningPeak : 0
if (ddPct > maxDrawdown) {
maxDrawdown = ddPct
maxDdDuration = i - curPeakIdx
}
if (ddPct === 0) {
if (i - lastPeakIdx > 1) {
maxDdDuration = Math.max(maxDdDuration, i - lastPeakIdx)
}
lastPeakIdx = i
}
}
maxDdDuration = Math.max(maxDdDuration, n - 1 - lastPeakIdx)
}
// ── 卡玛比率 = 年化收益 / 最大回撤 ───────────────────────────────────────