diff --git a/CHANGELOG.md b/CHANGELOG.md index e2af38b..b32f6b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 修复与龙头池下线。 diff --git a/pyproject.toml b/pyproject.toml index 4725134..f714b77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/easy_tdx/backtest/grading.py b/src/easy_tdx/backtest/grading.py index 6ce3a57..8543a68 100644 --- a/src/easy_tdx/backtest/grading.py +++ b/src/easy_tdx/backtest/grading.py @@ -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 diff --git a/src/easy_tdx/backtest/performance.py b/src/easy_tdx/backtest/performance.py index e9ca5fe..5b05ae2 100644 --- a/src/easy_tdx/backtest/performance.py +++ b/src/easy_tdx/backtest/performance.py @@ -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]: """返回全零指标字典(数据不足时的默认返回值)。 diff --git a/tests/unit/test_backtest_performance.py b/tests/unit/test_backtest_performance.py index 66ab8cc..6146918 100644 --- a/tests/unit/test_backtest_performance.py +++ b/tests/unit/test_backtest_performance.py @@ -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 diff --git a/tests/unit/test_grading_scoring.py b/tests/unit/test_grading_scoring.py index 035ecbe..c3ed87f 100644 --- a/tests/unit/test_grading_scoring.py +++ b/tests/unit/test_grading_scoring.py @@ -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(): diff --git a/web-ui/src/grading/combinedMetrics.ts b/web-ui/src/grading/combinedMetrics.ts index 879535b..e63edb2 100644 --- a/web-ui/src/grading/combinedMetrics.ts +++ b/web-ui/src/grading/combinedMetrics.ts @@ -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 - if (ddPct > maxDrawdown) { - maxDrawdown = ddPct - maxDdDuration = i - curPeakIdx + const ddPct = runningPeak > 0 ? (runningPeak - totals[i]) / runningPeak : 0 + if (ddPct > maxDrawdown) { + maxDrawdown = ddPct + } + if (ddPct === 0) { + if (i - lastPeakIdx > 1) { + maxDdDuration = Math.max(maxDdDuration, i - lastPeakIdx) } + lastPeakIdx = i } } + maxDdDuration = Math.max(maxDdDuration, n - 1 - lastPeakIdx) } // ── 卡玛比率 = 年化收益 / 最大回撤 ───────────────────────────────────────