mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 13:24:18 +08:00
release: v1.32.5 — 回撤持续统计修复,统一为最长水下期口径(单标的风险指标恒为 1)
This commit is contained in:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
"""返回全零指标字典(数据不足时的默认返回值)。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// ── 卡玛比率 = 年化收益 / 最大回撤 ───────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user