From fb6608193b5f3f1c5d1871277563b3e1efe3095a Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 4 Oct 2026 12:47:10 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9AWeb=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E4=B8=8E=E6=8A=A5=E5=91=8A=E6=A0=BC=E5=BC=8F=E5=8C=96?= =?UTF-8?q?=EF=BC=88=E5=B7=A5=E4=BD=9C=E5=8C=BA=E4=B8=AD=E6=AD=A4=E5=89=8D?= =?UTF-8?q?=E6=9C=AA=E6=8F=90=E4=BA=A4=E7=9A=84=E5=B7=A5=E4=BD=9C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 说明:本提交**不是本轮会话所做**,而是工作区里此前遗留的未提交改动。 为把历史分开,先单独提交它,再提交本轮会话的修改。 包含: - Web 前端:web/index.html、web/app.js(统一 SPA,含回测/画像/Walk-forward 页面) - 后端接口:web/server.py 路由、web/analysis.py(新增个股分析) - 报告层:report/format.py(新增统一数字格式化 NumFmt)、 report/{backtest,profile,sensitivity,universe,walkforward}_report.py 接入 NumFmt、 report/renderer.py - 股息率口径:factor/dividend_yield.py(毛刺消除 smooth_spikes) - 筛选:universe/selector.py、universe/filters/dividend.py - 绩效/敏感性:analysis/performance.py、analysis/sensitivity.py - 部署:deploy/install-service.sh - 测试:tests/test_format.py、tests/test_dividend_smoothing.py(新增)、 tests/test_web.py、tests/test_universe.py 提交时全量测试 403 项通过。 --- deploy/install-service.sh | 17 +- src/hdiv/analysis/performance.py | 8 +- src/hdiv/analysis/sensitivity.py | 18 +- src/hdiv/factor/dividend_yield.py | 123 +++- src/hdiv/report/backtest_report.py | 10 +- src/hdiv/report/format.py | 155 +++++ src/hdiv/report/profile_report.py | 15 +- src/hdiv/report/renderer.py | 29 +- src/hdiv/report/sensitivity_report.py | 9 +- src/hdiv/report/universe_report.py | 9 +- src/hdiv/report/walkforward_report.py | 3 +- src/hdiv/universe/filters/dividend.py | 37 +- src/hdiv/universe/selector.py | 40 +- src/hdiv/web/analysis.py | 494 ++++++++++++++++ src/hdiv/web/server.py | 84 ++- tests/test_dividend_smoothing.py | 250 ++++++++ tests/test_format.py | 159 ++++++ tests/test_universe.py | 59 ++ tests/test_web.py | 398 ++++++++++++- web/app.js | 786 +++++++++++++++++++++++++- web/index.html | 1 + 21 files changed, 2579 insertions(+), 125 deletions(-) create mode 100644 src/hdiv/report/format.py create mode 100644 src/hdiv/web/analysis.py create mode 100644 tests/test_dividend_smoothing.py create mode 100644 tests/test_format.py diff --git a/deploy/install-service.sh b/deploy/install-service.sh index e77c926..88ab694 100755 --- a/deploy/install-service.sh +++ b/deploy/install-service.sh @@ -7,10 +7,16 @@ # ./deploy/install-service.sh uninstall 停止并移除 # ./deploy/install-service.sh status 查看状态与连通性 # ./deploy/install-service.sh reinstall 重新渲染 plist 并重启 +# ./deploy/install-service.sh restart 只重启进程(改完 src/ 或 config/ 后执行) # # 为什么需要:nginx 由 brew services 托管、开机自启。若后端只是 nohup 进程, # 机器重启后它就不在了 —— 页面能打开但会显示「API 不可用」。 # +# 为什么 restart 是刚需:Python 进程把 hdiv 模块与 config/*.yml 读进了内存 +# (配置经 lru_cache 缓存),改完源码或配置**不会**自动生效。不重启就会出现 +# 「一边读新配置、一边用旧模型校验」的假故障,例如给 datasource.yml 加了 +# 新字段却报 ``Extra inputs are not permitted``。 +# # 若你不用 launchd,也可用 ./deploy/serve.sh start 手动启动(重启后需再次执行)。 # ============================================================ set -euo pipefail @@ -85,11 +91,20 @@ cmd_status() { tail -3 "${PROJECT_ROOT}/logs/web.err.log" 2>/dev/null | sed 's/^/ /' || true } +cmd_restart() { + is_loaded || die "服务未加载,先执行:./deploy/install-service.sh install" + # kickstart -k 先杀旧进程再拉新进程;PID 会变,所以顺手打印状态 + launchctl kickstart -k "gui/$(id -u)/${LABEL}" + sleep 3 + cmd_status +} + case "${1:-install}" in install) cmd_install ;; reinstall) render; launchctl unload -w "${TARGET}" 2>/dev/null || true launchctl load -w "${TARGET}"; sleep 3; cmd_status ;; + restart) cmd_restart ;; uninstall) cmd_uninstall ;; status) cmd_status ;; - *) die "未知动作:$1(可用:install|reinstall|uninstall|status)" ;; + *) die "未知动作:$1(可用:install|reinstall|restart|uninstall|status)" ;; esac diff --git a/src/hdiv/analysis/performance.py b/src/hdiv/analysis/performance.py index 72f55a8..04f604b 100644 --- a/src/hdiv/analysis/performance.py +++ b/src/hdiv/analysis/performance.py @@ -249,7 +249,7 @@ def format_metrics(m: dict[str, Any]) -> str: """控制台友好的指标摘要。""" def pct(k: str) -> str: v = m.get(k) - return "—" if v is None else f"{v * 100:,.2f}%" + return "—" if v is None else NumFmt.from_config().pct(v) def num(k: str, d: int = 2) -> str: v = m.get(k) @@ -275,9 +275,9 @@ def format_metrics(m: dict[str, Any]) -> str: for code, v in bench.items(): if isinstance(v, dict): lines.append( - f" {code}: 总收益 {(v.get('total_return') or 0) * 100:,.2f}% " - f"CAGR {(v.get('cagr') or 0) * 100:,.2f}% " - f"回撤 {(v.get('max_drawdown') or 0) * 100:,.2f}%" + f" {code}: 总收益 {_pct(v.get('total_return'))} " + f"CAGR {_pct(v.get('cagr'))} " + f"回撤 {_pct(v.get('max_drawdown'))}" ) return "\n".join(lines) diff --git a/src/hdiv/analysis/sensitivity.py b/src/hdiv/analysis/sensitivity.py index f8b9163..dcb8cd3 100644 --- a/src/hdiv/analysis/sensitivity.py +++ b/src/hdiv/analysis/sensitivity.py @@ -21,6 +21,8 @@ import numpy as np import pandas as pd from hdiv.core.config import load_config + +from hdiv.report.format import NumFmt from hdiv.core.errors import SchemaValidationError from hdiv.data import db from hdiv.data.repo import data_version @@ -205,17 +207,17 @@ class SensitivityRunner: return "(样本不足,无法评估敏感性)" lines = [ "敏感性判读(plan.md §27)", - f" CAGR 区间 {a['cagr_min'] * 100:.2f}% ~ {a['cagr_max'] * 100:.2f}%" - f"(跨度 {a['cagr_range'] * 100:.2f} 个百分点)", - f" 相邻档最大跳变 {a['max_jump'] * 100:.2f}pp,平均跳变 {a['mean_jump'] * 100:.2f}pp", + f" CAGR 区间 {_pct(a['cagr_min'])} ~ {_pct(a['cagr_max'])}" + f"(跨度 {_pct(a['cagr_range'], plus=False)})", + f" 相邻档最大跳变 {_pp(a['max_jump'])},平均跳变 {_pp(a['mean_jump'])}", f" 平滑度 {a['smoothness']:.2f}(越接近 1 越平滑)", ] if a.get("spikes"): lines.append(f" ⚠ 检出 {len(a['spikes'])} 处尖峰:") for s in a["spikes"]: lines.append( - f" 第 {s['index']} 点 CAGR {s['cagr'] * 100:.2f}% " - f"高于邻居均值 {s['excess'] * 100:.2f}pp" + f" 第 {s['index']} 点 CAGR {_pct(s['cagr'])} " + f"高于邻居均值 {_pp(s['excess'])}" ) lines.append(f" 结论:{a['verdict']}") return "\n".join(lines) @@ -296,9 +298,13 @@ def _f(v: Any) -> float | None: return None if not np.isfinite(f) else f +def _pp(v: Any, *, plus: bool = True) -> str: + return NumFmt.from_config().pct_pp(v, plus=plus) + + def _pct(v: Any) -> str: f = _f(v) - return "—" if f is None else f"{f * 100:,.2f}%" + return "—" if f is None else NumFmt.from_config().pct(f) def _num(v: Any) -> str: diff --git a/src/hdiv/factor/dividend_yield.py b/src/hdiv/factor/dividend_yield.py index ac34374..dbcd145 100644 --- a/src/hdiv/factor/dividend_yield.py +++ b/src/hdiv/factor/dividend_yield.py @@ -24,6 +24,37 @@ import pandas as pd TTM_DAYS = 365 +def ttm_params() -> tuple[int, int, bool]: + """读取 TTM 股息率的统一参数 ``(window_days, grace_days, smooth_spikes)``。 + + **单一事实来源**:筛选、画像、回测、Web 前端四处都用这一份参数。 + 早期实现里四处各写各的 —— 画像读配置、回测硬编码 365/45、 + walk-forward 与 Web 用函数默认值 —— 同一个「股息率」在不同环节定义不同, + 改了配置只有画像会变。现在统一从这里取。 + """ + from hdiv.core.config import load_config + + c = load_config("profile").ttm_dividend + return int(c.window_days), int(c.grace_days), bool( + getattr(c, "smooth_spikes", True) + ) + + +def ttm_dps_at(asof: date, events: pd.DataFrame) -> float | None: + """单点 TTM 每股分红(供筛选器等只需要一个时点的场景使用)。 + + 与 ``ttm_dps_series`` 用同一个实现,避免「筛选一个口径、画像另一个口径」。 + """ + if events is None or events.empty: + return None + w, g, sm = ttm_params() + # 用「asof 之前一年半」的稀疏日期轴求值:series 的语义是右端点取值, + # 这里只要 asof 当天的值 + idx = pd.DatetimeIndex([pd.Timestamp(asof)]) + v = ttm_dps_series(idx, events, ttm_days=w, grace_days=g, smooth_spikes=sm) + return float(v[0]) if len(v) else None + + def build_dps_events(dividends: pd.DataFrame) -> dict[str, pd.DataFrame]: """按股票整理分红事件(只保留现金分红 > 0)。 @@ -46,19 +77,35 @@ def ttm_dps_series( *, ttm_days: int = TTM_DAYS, grace_days: int = 45, + smooth_spikes: bool = True, ) -> np.ndarray: """给定日期序列,向量化计算每一天的 TTM 每股分红。 - **为什么需要 grace_days**:A 股年度分红的除权间隔中位数约 **366 天** - (实测招商银行 5 次间隔 > 365 天,最长 393 天)。若严格用 365 天窗口, - 每年都会出现 1~3 天的「空窗期」,股息率被算成 0 —— 这是统计假象, - 会拉低 min 与低分位,进而污染「历史分位」这一核心信号。 + **毛刺从哪来**:A 股相邻两次除权的间隔经常不是 365 天(实测招商银行 + 14 次分红中多次落在 355~395 天)。硬 365 天窗口于是在每年除权日附近 + 制造出两种假象: - 因此:先用严格 ``ttm_days`` 窗口计算;仅当结果为零时, - 回退到 ``ttm_days + grace_days`` 的窗口。公司真正停止分红时, - 超过宽限期后两者都会归零,不会被误判为仍在分红。 + - **重叠虚高**:间隔 < 365 天时,新分红入场而旧的尚未到期,两者同时在窗口内。 + 实测招商银行 2015-07-03:0.620 → 1.290(+108%),10 天后回落到 0.670。 + - **断档虚低**:间隔 > 365 天时,旧的已到期而新的尚未入场。 + 实测中国神华 2016-07-04:0.740 → 0.320(−57%)。 - 实现为对每个事件做区间增量累加,复杂度 O(n + m)。 + 两者都是日历假象而非分红能力变化,却会直接污染「历史分位」这一核心信号 + (虚高点拉高分位、虚低点压低 min 与低分位)。 + + **修法**:把「硬窗口」换成「按后继接管」。对每次分红 i: + + - 若与下一次分红的间隔 ``gap >= ttm_days - grace_days``,视为**同一档年度分红**, + 计入区间延到 ``min(下一次除权日, 除权日 + ttm_days + grace_days)``: + 间隔略小于一年 → 由后继提前接管,**消除重叠虚高**; + 间隔略大于一年 → 旧的一直计到新的入场,**填补断档虚低**; + 超过 ``ttm_days + grace_days`` 仍无后继(真停发)→ 封顶,如实归零。 + - 若 ``gap < ttm_days - grace_days``,视为**年内多次分红**(中期+年度), + 彼此不取代,各自保留标准 ``ttm_days`` 窗口 —— 否则会把中期分红误删, + 人为制造出新的低点。 + - 最后一次分红没有后继:沿用宽限期兜底(与旧行为一致)。 + + ``grace_days`` 现在同时承担两件事:判定「同一档」的容差,以及真停发时的兜底宽度。 """ n = len(dates) if n == 0: @@ -71,28 +118,39 @@ def ttm_dps_series( dps = events["cash_div_tax"].to_numpy(dtype="float64") d = dates.to_numpy(dtype="datetime64[ns]") - def accumulate(window_days: int) -> np.ndarray: - span = np.timedelta64(window_days, "D") - acc = np.zeros(n, dtype="float64") - for e in range(len(ex)): - if np.isnat(ex[e]): - continue - start = int(np.searchsorted(d, ex[e], side="left")) - end = int(np.searchsorted(d, ex[e] + span, side="left")) - if not np.isnat(imp[e]): - start = max(start, int(np.searchsorted(d, imp[e], side="left"))) - if end > start: - acc[start:end] += dps[e] - return acc + span_strict = np.timedelta64(ttm_days, "D") + span_ext = np.timedelta64(ttm_days + max(0, grace_days), "D") + # 「同一档年度分红」的判定阈值:间隔小于它即视为年内多次分红 + same_slot_min = np.timedelta64(max(0, ttm_days - max(0, grace_days)), "D") - strict = accumulate(ttm_days) - if grace_days <= 0: - return strict - gap = strict == 0 - if not gap.any(): - return strict - relaxed = accumulate(ttm_days + grace_days) - return np.where(gap, relaxed, strict) + acc = np.zeros(n, dtype="float64") + m = len(ex) + for i in range(m): + if np.isnat(ex[i]): + continue + + if not smooth_spikes: + end_ts = ex[i] + span_strict + elif i + 1 < m and not np.isnat(ex[i + 1]): + gap = ex[i + 1] - ex[i] + if gap >= same_slot_min: + # 同一档:由后继接管,但不超过宽限期封顶 + end_ts = min(ex[i + 1], ex[i] + span_ext) + else: + # 年内多次分红:保留标准窗口,互不取代 + end_ts = ex[i] + span_strict + else: + # 最后一次分红:宽限期兜底 + end_ts = ex[i] + span_ext + + start = int(np.searchsorted(d, ex[i], side="left")) + if not np.isnat(imp[i]): + # PIT:公告日之前不可见 + start = max(start, int(np.searchsorted(d, imp[i], side="left"))) + end = int(np.searchsorted(d, end_ts, side="left")) + if end > start: + acc[start:end] += dps[i] + return acc def dividend_yield_series( @@ -101,11 +159,15 @@ def dividend_yield_series( *, ttm_days: int = TTM_DAYS, grace_days: int = 45, + smooth_spikes: bool = True, ) -> pd.DataFrame: """构造单只股票的股息率日序列。 ``close`` 为**不复权**收盘价序列(index 为交易日)。 返回列:``trade_date / close / ttm_dps / dividend_yield``。 + + ``smooth_spikes`` 必须在此显式声明并透传 —— 曾经只加了调用方传参 + 而忘了这里接收,导致 walk-forward 直接 TypeError 崩掉。 """ if close.empty: return pd.DataFrame(columns=["trade_date", "close", "ttm_dps", "dividend_yield"]) @@ -114,7 +176,8 @@ def dividend_yield_series( if s.empty: return pd.DataFrame(columns=["trade_date", "close", "ttm_dps", "dividend_yield"]) idx = pd.DatetimeIndex(pd.to_datetime(s.index)) - dps = ttm_dps_series(idx, events, ttm_days=ttm_days, grace_days=grace_days) + dps = ttm_dps_series(idx, events, ttm_days=ttm_days, grace_days=grace_days, + smooth_spikes=smooth_spikes) out = pd.DataFrame({"trade_date": idx, "close": s.to_numpy(dtype="float64"), "ttm_dps": dps}) out["dividend_yield"] = out["ttm_dps"] / out["close"] return out.reset_index(drop=True) diff --git a/src/hdiv/report/backtest_report.py b/src/hdiv/report/backtest_report.py index 3e4612b..eea3f6c 100644 --- a/src/hdiv/report/backtest_report.py +++ b/src/hdiv/report/backtest_report.py @@ -10,6 +10,7 @@ import numpy as np import pandas as pd from hdiv.core.config import load_config +from hdiv.report.format import NumFmt from hdiv.data import db from hdiv.data.repo import Repo from hdiv.report.renderer import Provenance, Renderer, query @@ -97,7 +98,7 @@ def build_backtest_report(run_id: str, *, cfg: Any = None) -> Path: ) bt_cfg = load_config("backtest") - rf = f"{bt_cfg.risk_free_rate * 100:.2f}%" + rf = NumFmt.from_config().pct(bt_cfg.risk_free_rate) # 是否已被同策略同模式的更新运行取代? # 历史 run 必须保留(可复现性要求),但报告要如实标注, @@ -300,7 +301,8 @@ def _fmt_metric(code: str, v: Any) -> str: return "—" x = float(v) if code in _PCT_CODES: - return f"{x * 100:,.2f}%" + # 小数位来自 config/report.yml: layout.decimals.ratio(百分比 = ratio - 2 位) + return NumFmt.from_config().pct(x) if code in _MONEY_CODES: return f"{x:,.0f}" if code == "trade_count": @@ -313,7 +315,7 @@ def _fmt_metric(code: str, v: Any) -> str: def _pct(v: Any) -> str: if v is None or (isinstance(v, float) and not np.isfinite(v)): return "—" - return f"{float(v) * 100:,.2f}%" + return NumFmt.from_config().pct(float(v)) def _money(v: Any) -> str: @@ -375,7 +377,7 @@ def _reason_text(js: Any) -> str: return str(js)[:120] parts = [] if d.get("dividend_yield") is not None: - parts.append(f"股息率 {d['dividend_yield'] * 100:.2f}%") + parts.append(f"股息率 {NumFmt.from_config().pct(d['dividend_yield'])}") if d.get("yield_percentile") is not None: parts.append(f"历史分位 {d['yield_percentile']:.1f}%") if d.get("rule"): diff --git a/src/hdiv/report/format.py b/src/hdiv/report/format.py new file mode 100644 index 0000000..45462c2 --- /dev/null +++ b/src/hdiv/report/format.py @@ -0,0 +1,155 @@ +"""统一的数值格式化。 + +**为什么需要这个模块**:`config/report.yml` 的 ``layout.decimals`` 长期是个摆设 —— +``ratio`` 与 ``money`` 从未被任何代码读取(只有 ``price`` 在渲染器里用过一次), +而各报告模块各自硬编码小数位: + + profile_report._pct → f"{x*100:.2f}%" + backtest_report._pct → f"{x*100:.2f}%" + universe_report._pct → dec: int = 2 + sensitivity_report._pct / walkforward_report._pct → f"{x*100:.2f}%" + +结果就是**改了配置不生效**,7 处实现也容易各自漂移。现在所有格式化都走这里。 + +语义(与用户确认过):: + + decimals.ratio = 4 → 原始比率保留 4 位小数:0.06171491 → 0.0617 + 再乘 100 得到百分比:6.17% + 即 **百分比小数位 = ratio - 2** + +所以 ratio=4 时股息率显示 6.17%,ratio=6 时显示 6.1715%。 +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +def _is_missing(v: Any) -> bool: + if v is None or v == "": + return True + if isinstance(v, float) and v != v: # NaN + return True + try: # numpy / pandas 的 NaN + return bool(v != v) + except Exception: + return False + + +@dataclass(frozen=True) +class NumFmt: + """由 ``config/report.yml: layout.decimals`` 构造的格式化器。""" + + ratio: int = 4 + money: int = 2 + price: int = 2 + + # -- 派生 --------------------------------------------------------------- + + @property + def percent(self) -> int: + """百分比的小数位。 + + 比率保留 ``ratio`` 位后再乘 100,恰好少两位 —— 所以百分比小数位 = ratio - 2。 + ratio=4 → 6.17%;ratio=6 → 6.1715%。 + """ + return max(0, self.ratio - 2) + + # -- 基础 --------------------------------------------------------------- + + @staticmethod + def _f(v: Any, dec: int, *, thousands: bool = False) -> str: + if _is_missing(v): + return "—" + try: + x = float(v) + except (TypeError, ValueError): + return str(v) + return f"{x:,.{dec}f}" if thousands else f"{x:.{dec}f}" + + # -- 各类值 ------------------------------------------------------------- + + def ratio_str(self, v: Any) -> str: + """原始比率,保留 ratio 位。""" + return self._f(v, self.ratio) + + def pct(self, v: Any, *, plus: bool = False) -> str: + """比率 → 百分比字符串。""" + if _is_missing(v): + return "—" + try: + x = float(v) * 100.0 + except (TypeError, ValueError): + return str(v) + sign = "+" if (plus and x > 0) else "" + return f"{sign}{x:.{self.percent}f}%" + + def pct_pp(self, v: Any, *, plus: bool = True) -> str: + """百分点(用于超额收益等差值),带单位 pp。""" + if _is_missing(v): + return "—" + try: + x = float(v) * 100.0 + except (TypeError, ValueError): + return str(v) + sign = "+" if (plus and x > 0) else "" + return f"{sign}{x:.{self.percent}f}pp" + + def money_str(self, v: Any, *, thousands: bool = True) -> str: + return self._f(v, self.money, thousands=thousands) + + def yi(self, v: Any) -> str: + """元 → 亿元。""" + if _is_missing(v): + return "—" + try: + return f"{float(v) / 1e8:,.{self.money}f}亿" + except (TypeError, ValueError): + return str(v) + + def price_str(self, v: Any) -> str: + return self._f(v, self.price, thousands=True) + + def years(self, v: Any) -> str: + return self._f(v, 0, thousands=False) + + def count(self, v: Any) -> str: + return self._f(v, 0, thousands=True) + + def by_unit(self, v: Any, unit: str) -> str: + """按单位自动选择(供画像的分布表等使用)。""" + return { + "pct": self.pct, + "money": self.yi, + "years": self.years, + "price": self.price_str, + "int": self.count, + "ratio": self.ratio_str, + }.get(unit, self.ratio_str)(v) + + # -- 从配置构造 --------------------------------------------------------- + + @classmethod + def from_config(cls, cfg: Any = None) -> NumFmt: + """从 report.yml 读取;配置不可用时回落到默认值(不抛异常)。""" + if cfg is None: + try: + from hdiv.core.config import load_config + + cfg = load_config("report") + except Exception: + return cls() + try: + d = cfg.layout.decimals + return cls(ratio=int(d.ratio), money=int(d.money), price=int(d.price)) + except Exception: + return cls() + + +_DEFAULT = NumFmt() + + +def default() -> NumFmt: + """进程级默认格式化器(读一次配置)。""" + return _DEFAULT diff --git a/src/hdiv/report/profile_report.py b/src/hdiv/report/profile_report.py index 7d3b480..21655da 100644 --- a/src/hdiv/report/profile_report.py +++ b/src/hdiv/report/profile_report.py @@ -269,7 +269,8 @@ def _histogram(stats: pd.DataFrame, series: pd.DataFrame, metric: str, row: Any) hi = lo + 1e-6 edges = np.linspace(lo, hi, 13) counts, _ = np.histogram(vals, bins=edges) - labels = [f"{(edges[i] + edges[i + 1]) / 2 * 100:.2f}%" for i in range(len(edges) - 1)] + labels = [NumFmt.from_config().pct((edges[i] + edges[i + 1]) / 2) + for i in range(len(edges) - 1)] cur = float(row["current_value"]) if row is not None and pd.notna(row["current_value"]) else None bucket = None if cur is not None: @@ -313,22 +314,16 @@ def _scores(scores: pd.DataFrame) -> tuple[list[dict], list[dict]]: def _fmt(v: Any, unit: str) -> str: + """按单位格式化。小数位由 config/report.yml: layout.decimals 决定。""" if v is None or pd.isna(v): return "—" - x = float(v) - if unit == "pct": - return f"{x * 100:.2f}%" - if unit == "money": - return f"{x / 1e8:,.2f}亿" - if unit == "years": - return f"{x:.0f}" - return f"{x:,.4f}" + return NumFmt.from_config().by_unit(v, unit) def _pct(v: Any) -> str: if v is None or pd.isna(v): return "—" - return f"{float(v) * 100:.2f}%" + return NumFmt.from_config().pct(v) def _n(v: Any) -> float | None: diff --git a/src/hdiv/report/renderer.py b/src/hdiv/report/renderer.py index b7a5e55..7bb5b0a 100644 --- a/src/hdiv/report/renderer.py +++ b/src/hdiv/report/renderer.py @@ -27,6 +27,7 @@ from hdiv.core.paths import output_dir, project_root, resolve from hdiv.data import db from hdiv.data.sync.base import stable_id from hdiv.report import theme +from hdiv.report.format import NumFmt def _json_for_script(value: Any) -> Markup: @@ -89,16 +90,26 @@ class Renderer: # -- 数值格式化(模板层零计算,只做呈现) -------------------------------- - def fmt_num(self, v: Any, decimals: int = 2) -> str: - if v is None or v == "" or (isinstance(v, float) and v != v): - return "—" - try: - return f"{float(v):,.{decimals}f}" - except (TypeError, ValueError): - return str(v) + @property + def fmt(self) -> NumFmt: + """由 config/report.yml: layout.decimals 驱动的统一格式化器。 - def fmt_pct(self, v: Any, decimals: int = 2) -> str: - if v is None or (isinstance(v, float) and v != v): + 早期版本的 fmt_pct 默认 hardcode 2 位,且从不读取 decimals.ratio —— + 于是「改了配置不生效」。现在所有格式化都经由此处,配置是真的。 + """ + if getattr(self, "_fmt", None) is None: + self._fmt = NumFmt.from_config(self.cfg) + return self._fmt + + def fmt_num(self, v: Any, decimals: int | None = None) -> str: + if decimals is None: + return self.fmt.ratio_str(v) + return NumFmt._f(v, decimals, thousands=True) + + def fmt_pct(self, v: Any, decimals: int | None = None) -> str: + if decimals is None: + return self.fmt.pct(v) + if v is None: return "—" try: return f"{float(v) * 100:.{decimals}f}%" diff --git a/src/hdiv/report/sensitivity_report.py b/src/hdiv/report/sensitivity_report.py index a322a70..28d8021 100644 --- a/src/hdiv/report/sensitivity_report.py +++ b/src/hdiv/report/sensitivity_report.py @@ -10,6 +10,7 @@ import numpy as np import pandas as pd from hdiv.core.config import load_config +from hdiv.report.format import NumFmt from hdiv.data import db from hdiv.report.renderer import Provenance, Renderer, query @@ -133,7 +134,7 @@ def _analyse(cagrs: list[float]) -> dict[str, Any]: "neighbour_mean": float(neigh), "neighbour_mean_s": _pct(neigh), "excess": float(arr[i] - neigh), - "excess_s": f"{(arr[i] - neigh) * 100:,.2f}pp", + "excess_s": NumFmt.from_config().pct_pp(arr[i] - neigh), }) robust = smoothness >= 0.6 and not spikes return { @@ -153,8 +154,8 @@ def _analyse(cagrs: list[float]) -> dict[str, Any]: ), "cagr_min_s": _pct(arr.min()), "cagr_max_s": _pct(arr.max()), - "cagr_range_s": f"{(arr.max() - arr.min()) * 100:,.2f}pp", - "max_jump_s": f"{diffs.max() * 100:,.2f}pp", + "cagr_range_s": NumFmt.from_config().pct_pp(arr.max() - arr.min(), plus=False), + "max_jump_s": NumFmt.from_config().pct_pp(diffs.max(), plus=False), "smoothness_s": f"{smoothness:.2f}", } @@ -205,7 +206,7 @@ def _r(v: float | None) -> float | None: def _pct(v: Any) -> str: f = _f(v) - return "—" if f is None else f"{f * 100:,.2f}%" + return "—" if f is None else NumFmt.from_config().pct(f) def _num(v: Any) -> str: diff --git a/src/hdiv/report/universe_report.py b/src/hdiv/report/universe_report.py index 3efb3e4..875db96 100644 --- a/src/hdiv/report/universe_report.py +++ b/src/hdiv/report/universe_report.py @@ -9,6 +9,7 @@ from typing import Any import pandas as pd from hdiv.core.config import config_hash, load_config +from hdiv.report.format import NumFmt from hdiv.data import db from hdiv.data.repo import Repo from hdiv.report.renderer import Provenance, Renderer, query @@ -233,10 +234,16 @@ def _num(v: Any, dec: int = 2) -> str: return str(v) -def _pct(v: Any, dec: int = 2) -> str: +def _pct(v: Any, dec: int | None = None) -> str: + """百分比。``dec`` 显式给出时按其格式化,否则用配置的精度。 + + 早期实现默认 dec=2 且从不读配置,于是 decimals.ratio 改了也没反应。 + """ if v is None or (isinstance(v, float) and v != v) or pd.isna(v): return "—" try: + if dec is None: + return NumFmt.from_config().pct(v) return f"{float(v) * 100:.{dec}f}%" except (TypeError, ValueError): return str(v) diff --git a/src/hdiv/report/walkforward_report.py b/src/hdiv/report/walkforward_report.py index 922f35c..adc83fc 100644 --- a/src/hdiv/report/walkforward_report.py +++ b/src/hdiv/report/walkforward_report.py @@ -15,6 +15,7 @@ import numpy as np import pandas as pd from hdiv.core.config import load_config +from hdiv.report.format import NumFmt from hdiv.data import db from hdiv.report.renderer import Provenance, Renderer, query @@ -248,7 +249,7 @@ def _r(v: float | None) -> float | None: def _pct(v: Any) -> str: f = _f(v) - return "—" if f is None else f"{f * 100:,.2f}%" + return "—" if f is None else NumFmt.from_config().pct(f) def _num(v: Any) -> str: diff --git a/src/hdiv/universe/filters/dividend.py b/src/hdiv/universe/filters/dividend.py index 7d6e1db..cc64191 100644 --- a/src/hdiv/universe/filters/dividend.py +++ b/src/hdiv/universe/filters/dividend.py @@ -19,6 +19,7 @@ from typing import Any import pandas as pd from hdiv.core.config import DividendFilterConfig +from hdiv.factor.dividend_yield import ttm_dps_at from hdiv.universe.filters.base import Filter, FilterOutcome # 年报到次年 4 月 30 日前披露完毕(法定上限) @@ -174,17 +175,35 @@ class DividendFilter(Filter): window_start = start_year - cfg.window_years + 1 in_window = sorted(x for x in years if window_start <= x <= start_year) - # TTM 股息:除权日落在过去 12 个月内 - one_year_ago = _shift_year(asof, -1) + # TTM 股息:与画像/回测**共用同一实现**(factor.ttm_dps_at)。 + # + # 早期此处另写了一遍「trailing 12 个月求和」,两个问题: + # 1) 同一个「股息率」在筛选与画像/回测里口径可能不同; + # 2) 同样受除权间隔不规整造成的毛刺影响 —— 若 asof 恰好落在 + # 「新旧重叠」窗口里会虚高一倍,落在「断档」窗口里会虚低一半, + # 而这是**直接决定选股**的数字。 + ev_df = pd.DataFrame( + [ + { + "ex_date": r.get("ex_date"), + "imp_ann_date": r.get("imp_ann_date"), + "cash_div_tax": r.get("cash_div_tax"), + } + for r in cash + ] + ) ttm = 0.0 has_ttm = False - for r in cash: - ex = r.get("ex_date") - if ex is None or pd.isna(ex): - continue - ex = pd.to_datetime(ex).date() - if one_year_ago < ex <= asof: - ttm += float(r["cash_div_tax"] or 0) + if not ev_df.empty: + ev_df["cash_div_tax"] = pd.to_numeric( + ev_df["cash_div_tax"], errors="coerce" + ).fillna(0.0) + ev_df["ex_date"] = pd.to_datetime(ev_df["ex_date"], errors="coerce") + ev_df["imp_ann_date"] = pd.to_datetime(ev_df["imp_ann_date"], errors="coerce") + ev_df = ev_df.dropna(subset=["ex_date"]).sort_values("ex_date") + v = ttm_dps_at(asof, ev_df) + if v is not None and v > 0: + ttm = float(v) has_ttm = True # 年度 DPS(按报告期汇总),用于 CAGR 与波动 diff --git a/src/hdiv/universe/selector.py b/src/hdiv/universe/selector.py index 0ad1821..d00e033 100644 --- a/src/hdiv/universe/selector.py +++ b/src/hdiv/universe/selector.py @@ -166,12 +166,20 @@ class UniverseSelector: f"股票池 {member_count} 只 < 期望下限 {self.config.output.min_members} 只" ) + # run_id 必须由「输入」唯一决定,**不含时间戳**。 + # + # 早期实现把 datetime.now() 编进指纹,导致同样的筛选每跑一次就多一条记录 + # (同一 asof 累积了 4 条内容相同的记录)。现在的语义是: + # 同一份配置 + 同一时点 → 同一个 run_id → 重跑即原地覆盖。 + # + # 注意:刻意**不含 data_version**。数据更新后重跑仍覆盖同一条记录, + # 因为用户要的是「这一天的筛选结果」,而不是「每次数据快照各存一份」; + # 每次运行使用的 data_version 仍完整记录在 hd_universe_run 里可供追溯。 run_id = stable_id( "universe", self.config.name, str(effective), config_hash(self.config), - datetime.now().isoformat(), ) result = { "run_id": run_id, @@ -244,9 +252,15 @@ class UniverseSelector: if not avgs.empty: df = df.merge(avgs, on="symbol", how="left") - # 缺少当日行情的股票:is_fresh 为 NaN → 视为非当日(停牌/未交易) + # 缺少当日行情的股票:is_fresh 为 NaN → 视为非当日(停牌/未交易)。 + # + # merge(how="left") 后该列是 object(True/False/NaN 混合),直接 + # .fillna(False) 会触发 pandas 的 Downcasting object dtype FutureWarning。 + # 先转 nullable boolean 再填充,语义相同且不产生警告。 if "is_fresh" in df.columns: - df["is_fresh"] = df["is_fresh"].fillna(False).astype(bool) + df["is_fresh"] = ( + df["is_fresh"].astype("boolean").fillna(False).astype(bool) + ) return df # ------------------------------------------------------------------ @@ -280,7 +294,10 @@ class UniverseSelector: ), cfg=cfg, update_columns=[ - "member_count", "candidate_count", "stats_json", "status", "config_json" + "member_count", "candidate_count", "stats_json", "status", + "config_json", "data_version", "code_version", + # 刻意不含 display_name / notes / archived_at / deleted_at: + # 那些是用户在界面上的标注,重跑不应把命名或归档状态清掉。 ], ) @@ -319,6 +336,21 @@ class UniverseSelector: "passed", "fail_stage", "fail_reason", "values_json", "filter_json" ], ) + # 覆盖语义下的收尾:上次运行存在、本次不再出现在候选集里的成员, + # 标记为失效而不是删除(项目禁止物理删除)。 + # 这种情况只在数据变动(新股上市/退市)时出现,属边缘情形。 + syms = [r["symbol"] for r in rows] + placeholders = ",".join(f":s{i}" for i in range(len(syms))) + params = {f"s{i}": v for i, v in enumerate(syms)} + params["r"] = result["run_id"] + db.execute( + f"UPDATE hd_universe_member SET passed = 0, fail_stage = 'stale', " + f" fail_reason = '本次运行未出现在候选范围内' " + f"WHERE run_id = :r AND symbol NOT IN ({placeholders}) " + f" AND (fail_stage IS NULL OR fail_stage <> 'stale')", + params, + cfg=cfg, + ) # 因子快照(决策时点因子值,供后续画像/回测复用) snap_rows = [] diff --git a/src/hdiv/web/analysis.py b/src/hdiv/web/analysis.py new file mode 100644 index 0000000..b554d4a --- /dev/null +++ b/src/hdiv/web/analysis.py @@ -0,0 +1,494 @@ +"""回测结果分析:组合持仓查询与个股买卖点序列。 + +与 ``service.py`` 的分工: +- ``service.py`` 管「运行记录」本身(列表、命名、归档、关联) +- 本模块管「一次回测内部的明细」(某日持仓、某股买卖点与指标曲线) + +**所有派生计算都在服务端完成**(TTM 股息率、ROE 的 PIT 对齐等), +前端只负责渲染 —— 与报告「模板不做计算」的原则一致,保证页面上每个数字 +都能对应到一段可复核的 SQL。 + +口径要点: +- **股价用不复权收盘价**(``daily_basic.close``;``stock_daily`` 已验证与之逐日一致, + 但 ``daily_basic`` 覆盖更全,且同表带 ``pe_ttm``,一次查询即可) +- **股息率 = PIT-TTM 每股分红 / 不复权收盘价**,复用因子层的 ``ttm_dps_series``, + 与筛选、画像用的是同一套逻辑(含 45 天宽限期) +- **ROE 按公告日对齐**(``ann_date <= 当日``),是阶梯函数而非插值 —— + 插值会制造「当时还不知道的」中间值 +""" + +from __future__ import annotations + +import json +from datetime import date, timedelta +from decimal import Decimal +from typing import Any + +import numpy as np +import pandas as pd + +from hdiv.core.config import load_config + +from hdiv.report.format import NumFmt + + +def _fmt() -> NumFmt: + """当前配置的格式化器(每次读取,保证改配置立即生效)。""" + return NumFmt.from_config() + + +from hdiv.core.errors import HdivError +from hdiv.data import db +from hdiv.factor.dividend_yield import ttm_dps_series, ttm_params + +#: 可在趋势图上叠加的序列(前端勾选项) +SERIES_KEYS = ("close", "dv_yield", "pe_ttm", "roe", "pb", "drawdown") + +#: 单只股票最多返回的点数(约 12 年日频)。超出则等间隔降采样, +#: 只影响画图,不影响买卖点(买卖点单独返回且不降采样)。 +MAX_POINTS = 3200 + + +def _v(x: Any) -> Any: + if x is None: + return None + if isinstance(x, np.generic): + x = x.item() + if isinstance(x, Decimal): + return float(x) + if isinstance(x, (pd.Timestamp,)): + return x.date().isoformat() + if isinstance(x, date): + return x.isoformat() + if isinstance(x, float) and x != x: + return None + return x + + +def _fnum(x: Any) -> float | None: + try: + f = float(x) + except (TypeError, ValueError): + return None + return None if f != f else f + + +def _int_or_none(x: Any) -> int | None: + """NaN 安全的整数转换。 + + ``holding_days`` 这类列在 Pandas 里缺失时是 NaN 而**不是** None, + 所以 ``int(r["holding_days"]) if r["holding_days"] is not None else None`` + 会在卖出成交(没有持仓天数)上抛 ``ValueError: cannot convert float NaN``, + 让整个个股详情接口 500 —— 有卖出的个股因此整页打不开。 + """ + f = _fnum(x) + return None if f is None else int(f) + + +def _reason_text(d: dict[str, Any]) -> str: + parts = [] + y = _fnum(d.get("dividend_yield")) + p = _fnum(d.get("yield_percentile")) + if y is not None: + parts.append(f"股息率 {_fmt().pct(y)}") + if p is not None: + parts.append(f"历史分位 {p:.1f}%") + if d.get("rule"): + parts.append(str(d["rule"])) + if d.get("observation_count"): + parts.append(f"参照样本 {d['observation_count']}") + if d.get("reason_cn"): + parts.append(str(d["reason_cn"])) + return ";".join(parts) or "—" + + +# --------------------------------------------------------------------------- +# 组合持仓 +# --------------------------------------------------------------------------- + + +def position_dates(run_id: str, *, limit: int | None = None, + detail: bool = False) -> dict[str, Any]: + """该回测所有的持仓快照日期(供前端做日期选择/时间轴)。 + + ``detail=False``(默认)只返回日期字符串 —— 前端翻上下一个交易日 + 只需要这份清单,带全部数值字段会让响应从约 30KB 膨胀到 460KB。 + """ + cfg = load_config("datasource") + df = db.read_sql( + "SELECT e.trade_date, e.nav, e.total_value, e.cash, e.position_value, " + " e.drawdown, e.holding_count " + "FROM hd_backtest_equity e WHERE e.run_id = :r ORDER BY e.trade_date", + {"r": run_id}, cfg=cfg, + ) + if df.empty: + raise HdivError(f"回测 {run_id} 没有净值数据,无法查询持仓") + items = [{ + "date": _v(r["trade_date"]), + "total_value": _fnum(r["total_value"]), + "cash": _fnum(r["cash"]), + "position_value": _fnum(r["position_value"]), + "nav": _fnum(r["nav"]), + "drawdown": _fnum(r["drawdown"]), + "holding_count": int(r["holding_count"] or 0), + } for _, r in df.iterrows()] + out = {"dates": [x["date"] for x in items], + "count": len(items), "start": items[0]["date"], "end": items[-1]["date"]} + if detail: + out["items"] = items + if limit: + # 等间隔抽样,用于画持仓数量时间轴;不影响按日查询 + step = max(1, len(items) // int(limit)) + out["sampled"] = items[::step] + return out + + +def portfolio_on_date(run_id: str, day: str | None = None) -> dict[str, Any]: + """查询某一交易日的组合汇总与逐股持仓明细。 + + ``day`` 为空时取该回测最后一个交易日。若指定日非交易日, + 自动回退到**之前最近**的一个有快照的交易日,并在返回中说明。 + + 注意:日期清单在此处只用日期(``detail=False``),2500+ 个交易日的 + 全字段明细会让单次请求从约 30KB 涨到 460KB。 + """ + cfg = load_config("datasource") + all_dates = position_dates(run_id)["dates"] + + requested = day + if not day: + target = all_dates[-1] + else: + if day in all_dates: + target = day + else: + earlier = [d for d in all_dates if d <= day] + if not earlier: + raise HdivError( + f"{day} 早于该回测的首个快照 {all_dates[0]};" + f"可选区间 {all_dates[0]} ~ {all_dates[-1]}" + ) + target = earlier[-1] # 回退到之前最近的交易日 + + eq = db.read_sql( + "SELECT trade_date, nav, total_value, cash, position_value, daily_return, " + " cum_return, drawdown, holding_count " + "FROM hd_backtest_equity WHERE run_id = :r AND trade_date = :d", + {"r": run_id, "d": target}, cfg=cfg, + ) + # 名称/行业直接 JOIN 取回:既少一次往返,也避开 IN 元组绑定 + # (pymysql + pandas 下 `IN %(s)s` 不是合法语法) + pos = db.read_sql( + "SELECT p.symbol, p.quantity, p.avg_cost, p.close, p.market_value, p.weight, " + " p.unrealized_pnl, p.holding_days, s.name, s.industry " + "FROM hd_backtest_position p " + "LEFT JOIN stock s ON s.symbol = p.symbol " + "WHERE p.run_id = :r AND p.trade_date = :d " + "ORDER BY p.weight DESC, p.symbol", + {"r": run_id, "d": target}, cfg=cfg, + ) + + positions = [] + for _, r in pos.iterrows(): + nm, ind = r["name"], r["industry"] + cost = _fnum(r["avg_cost"]) + close = _fnum(r["close"]) + pnl_pct = ((close / cost - 1.0) if (cost and close) else None) + positions.append({ + "symbol": r["symbol"], "name": nm, "industry": ind, + "quantity": _fnum(r["quantity"]), + "avg_cost": cost, "close": close, + "market_value": _fnum(r["market_value"]), + "weight": _fnum(r["weight"]), + "unrealized_pnl": _fnum(r["unrealized_pnl"]), + "pnl_pct": pnl_pct, + "holding_days": _int_or_none(r["holding_days"]), + }) + + eq_row = eq.iloc[0] if not eq.empty else {} + total_mv = sum(p["market_value"] or 0.0 for p in positions) + total_cost = sum((p["avg_cost"] or 0.0) * (p["quantity"] or 0.0) for p in positions) + total_pnl = sum(p["unrealized_pnl"] or 0.0 for p in positions) + + return { + "run_id": run_id, + "date": target, + "requested_date": requested, + "adjusted": bool(requested and requested != target), + "range": {"start": all_dates[0], "end": all_dates[-1], "count": len(all_dates)}, + "equity": { + "nav": _fnum(eq_row.get("nav")), + "total_value": _fnum(eq_row.get("total_value")), + "cash": _fnum(eq_row.get("cash")), + "position_value": _fnum(eq_row.get("position_value")), + "daily_return": _fnum(eq_row.get("daily_return")), + "cum_return": _fnum(eq_row.get("cum_return")), + "drawdown": _fnum(eq_row.get("drawdown")), + "holding_count": int(eq_row.get("holding_count") or 0), + }, + "positions": positions, + "summary": { + "count": len(positions), + "market_value": total_mv, + "cost": total_cost, + "unrealized_pnl": total_pnl, + "unrealized_pnl_pct": (total_pnl / total_cost) if total_cost else None, + }, + } + + +# --------------------------------------------------------------------------- +# 个股买卖点与指标序列 +# --------------------------------------------------------------------------- + + +def run_stocks(run_id: str) -> list[dict[str, Any]]: + """该回测涉及的全部股票(持仓过或成交过),供前端选择。""" + cfg = load_config("datasource") + df = db.read_sql( + """ + SELECT p.symbol, + MAX(s.name) AS name, + MAX(s.industry) AS industry, + COUNT(*) AS hold_days, + MAX(p.trade_date) AS last_hold + FROM hd_backtest_position p + LEFT JOIN stock s ON s.symbol = p.symbol + WHERE p.run_id = :r + GROUP BY p.symbol + ORDER BY hold_days DESC, p.symbol + """, + {"r": run_id}, cfg=cfg, + ) + tdf = db.read_sql( + "SELECT symbol, COUNT(*) AS n, SUM(side='BUY') AS buys, SUM(side='SELL') AS sells " + "FROM hd_backtest_trade WHERE run_id = :r GROUP BY symbol", + {"r": run_id}, cfg=cfg, + ) + tmap = {r["symbol"]: (int(r["n"]), int(r["buys"] or 0), int(r["sells"] or 0)) + for _, r in tdf.iterrows()} + out = [] + for _, r in df.iterrows(): + n, buys, sells = tmap.get(r["symbol"], (0, 0, 0)) + out.append({ + "symbol": r["symbol"], "name": r["name"], "industry": r["industry"], + "hold_days": int(r["hold_days"]), "last_hold": _v(r["last_hold"]), + "trade_count": n, "buy_count": buys, "sell_count": sells, + }) + return out + + +def _price_panel(symbol: str, start: date, end: date, cfg: Any) -> pd.DataFrame: + """不复权收盘价 + PE/PB(同一张 daily_basic,一次查询)。 + + ``daily_basic`` 与 ``stock_daily`` 的收盘价已逐日核对一致, + 但前者覆盖更全且自带估值指标,因此作为唯一价格源。 + """ + return db.read_sql( + "SELECT trade_date, close, pe_ttm, pb, ps_ttm, dv_ttm, turnover_rate " + "FROM daily_basic WHERE symbol = :s AND trade_date BETWEEN :a AND :b " + "ORDER BY trade_date", + {"s": symbol, "a": start, "b": end}, cfg=cfg, + ) + + +def _roe_series(symbol: str, dates: pd.Series, cfg: Any) -> np.ndarray: + """把季度 ROE 对齐成日频阶梯序列(PIT:只看当日已公告的)。 + + 刻意用「向前填充」而不是插值:插值会凭空造出当时并不存在的中间值, + 属于未来函数。 + """ + df = db.read_sql( + "SELECT ann_date, end_date, roe FROM hd_fina_indicator " + "WHERE symbol = :s AND roe IS NOT NULL AND ann_date IS NOT NULL " + "ORDER BY ann_date, end_date", + {"s": symbol}, cfg=cfg, + ) + if df.empty: + return np.full(len(dates), np.nan) + df["ann_date"] = pd.to_datetime(df["ann_date"]) + dts = pd.to_datetime(dates) + # merge_asof:对每个交易日取 ann_date <= 当日 的最后一条 + left = pd.DataFrame({"trade_date": dts}).sort_values("trade_date") + merged = pd.merge_asof( + left, df[["ann_date", "roe"]].sort_values("ann_date"), + left_on="trade_date", right_on="ann_date", direction="backward", + ) + return merged["roe"].to_numpy(dtype=float) + + +def _dividend_yield_series( + symbol: str, dates: pd.Series, close: np.ndarray, cfg: Any +) -> np.ndarray: + """PIT-TTM 股息率 = TTM 每股分红 / 不复权收盘价。 + + 复用因子层的 ``ttm_dps_series``(与筛选、画像同一套逻辑,含 45 天宽限期), + 避免此处另写一份导致口径漂移。 + """ + from hdiv.data.repo import Repo + + if not len(dates): + return np.array([]) + dts = pd.to_datetime(dates) + lo, hi = dts.min().date(), dts.max().date() + # 往前多取一年,保证 TTM 窗口在起点也是完整的 + ev = Repo(cfg=cfg).dividend_events(lo - timedelta(days=400), hi) + if ev is None or ev.empty: + return np.full(len(dates), np.nan) + ev = ev[ev["symbol"] == symbol] + if ev.empty: + return np.full(len(dates), np.nan) + _w, _g, _sm = ttm_params() + dps = ttm_dps_series(pd.DatetimeIndex(dts), ev, + ttm_days=_w, grace_days=_g, smooth_spikes=_sm) + with np.errstate(divide="ignore", invalid="ignore"): + out = np.where((close > 0) & np.isfinite(dps), dps / close, np.nan) + return out.astype(float) + + +def _downsample(n: int, target: int) -> np.ndarray: + """等间隔取索引,保留首尾。仅用于画图,买卖点不降采样。""" + if n <= target: + return np.arange(n) + idx = np.linspace(0, n - 1, target).round().astype(int) + return np.unique(idx) + + +def stock_detail( + run_id: str, + symbol: str, + *, + start: str | None = None, + end: str | None = None, + series: list[str] | None = None, +) -> dict[str, Any]: + """某只股票在该回测中的买卖点与指标曲线。 + + ``series`` 指定需要计算哪些序列;未指定的不会计算(省时), + 但返回结构中仍会列出 ``available_series`` 供前端画勾选框。 + """ + cfg = load_config("datasource") + wanted = [s for s in (series or list(SERIES_KEYS)) if s in SERIES_KEYS] + if not wanted: + raise HdivError(f"series 无效:{series};可选 {list(SERIES_KEYS)}") + + info = db.read_sql( + "SELECT symbol, name, industry, market, list_date FROM stock WHERE symbol = :s", + {"s": symbol}, cfg=cfg, + ) + if info.empty: + raise HdivError(f"股票不存在:{symbol}") + + # 缺省区间:该股在该回测中的持仓区间;无持仓则用整个回测区间 + hold = db.read_sql( + "SELECT MIN(trade_date) AS a, MAX(trade_date) AS b, COUNT(*) AS n " + "FROM hd_backtest_position WHERE run_id = :r AND symbol = :s", + {"r": run_id, "s": symbol}, cfg=cfg, + ) + run = db.read_sql( + "SELECT start_date, end_date FROM hd_backtest_run WHERE run_id = :r", + {"r": run_id}, cfg=cfg, + ) + if run.empty: + raise HdivError(f"回测不存在:{run_id}") + run_a, run_b = run["start_date"].iloc[0], run["end_date"].iloc[0] + + has_hold = not hold.empty and hold["n"].iloc[0] + d_a = pd.to_datetime(start).date() if start else ( + hold["a"].iloc[0] if has_hold else run_a) + d_b = pd.to_datetime(end).date() if end else ( + hold["b"].iloc[0] if has_hold else run_b) + + panel = _price_panel(symbol, d_a, d_b, cfg) + if panel.empty: + raise HdivError( + f"{symbol} 在 {d_a} ~ {d_b} 没有行情数据。" + f"该股行情覆盖见 stock_daily/daily_basic。" + ) + + dates = panel["trade_date"] + close = panel["close"].to_numpy(dtype=float) + + series_out: dict[str, list[Any]] = {} + if "close" in wanted: + series_out["close"] = [_fnum(x) for x in close] + if "pe_ttm" in wanted: + series_out["pe_ttm"] = [_fnum(x) for x in panel["pe_ttm"]] + if "pb" in wanted: + series_out["pb"] = [_fnum(x) for x in panel["pb"]] + if "dv_yield" in wanted: + series_out["dv_yield"] = [_fnum(x) for x in + _dividend_yield_series(symbol, dates, close, cfg)] + if "roe" in wanted: + series_out["roe"] = [_fnum(x) for x in _roe_series(symbol, dates, cfg)] + if "drawdown" in wanted: + running_max = np.maximum.accumulate(np.where(np.isfinite(close), close, np.nan)) + with np.errstate(divide="ignore", invalid="ignore"): + series_out["drawdown"] = [_fnum(x) for x in (close / running_max - 1.0)] + + # 买卖点:不降采样,且带完整成交信息 + tdf = db.read_sql( + "SELECT trade_id, signal_date, execution_date, side, price, quantity, amount, " + " commission, stamp_tax, transfer_fee, slippage_cost, total_cost, " + " realized_pnl, holding_days, reason_json " + "FROM hd_backtest_trade WHERE run_id = :r AND symbol = :s " + "ORDER BY execution_date, trade_id", + {"r": run_id, "s": symbol}, cfg=cfg, + ) + trades = [] + for _, r in tdf.iterrows(): + reason = {} + if r["reason_json"]: + try: + reason = json.loads(r["reason_json"]) + except Exception: + reason = {} + trades.append({ + "trade_id": r["trade_id"], + "signal_date": _v(r["signal_date"]), + "execution_date": _v(r["execution_date"]), + "side": r["side"], + "price": _fnum(r["price"]), + "quantity": _fnum(r["quantity"]), + "amount": _fnum(r["amount"]), + "commission": _fnum(r["commission"]), + "stamp_tax": _fnum(r["stamp_tax"]), + "transfer_fee": _fnum(r["transfer_fee"]), + "slippage_cost": _fnum(r["slippage_cost"]), + "total_cost": _fnum(r["total_cost"]), + "realized_pnl": _fnum(r["realized_pnl"]), + "holding_days": _int_or_none(r["holding_days"]), + "reason": reason, + "reason_text": _reason_text(reason), + }) + + idx = _downsample(len(dates), MAX_POINTS) + dates_out = [_v(dates.iloc[i]) for i in idx] + series_out = {k: [v[i] for i in idx] for k, v in series_out.items()} + + buys = [t for t in trades if t["side"] == "BUY"] + sells = [t for t in trades if t["side"] == "SELL"] + realized = sum(t["realized_pnl"] or 0.0 for t in sells) + fees = sum((t["commission"] or 0) + (t["stamp_tax"] or 0) + (t["transfer_fee"] or 0) + for t in trades) + return { + "run_id": run_id, "symbol": symbol, + "info": {k: _v(v) for k, v in info.iloc[0].items()}, + "range": {"start": _v(dates.iloc[0]), "end": _v(dates.iloc[-1]), + "requested_start": d_a.isoformat(), "requested_end": d_b.isoformat(), + "points": len(dates), "downsampled": len(idx) < len(dates)}, + "available_series": list(SERIES_KEYS), + "series": series_out, + "dates": dates_out, + "trades": trades, + "stats": { + "trade_count": len(trades), + "buy_count": len(buys), "sell_count": len(sells), + "realized_pnl": realized, + "total_fees": fees, + "buy_amount": sum(t["amount"] or 0.0 for t in buys), + "sell_amount": sum(t["amount"] or 0.0 for t in sells), + "first_trade": trades[0]["execution_date"] if trades else None, + "last_trade": trades[-1]["execution_date"] if trades else None, + }, + } diff --git a/src/hdiv/web/server.py b/src/hdiv/web/server.py index 5fa4d74..0d7d3f4 100644 --- a/src/hdiv/web/server.py +++ b/src/hdiv/web/server.py @@ -29,8 +29,9 @@ from urllib.parse import parse_qs, unquote, urlparse import numpy as np +from hdiv.core.errors import HdivError from hdiv.core.paths import output_dir, project_root -from hdiv.web import service +from hdiv.web import analysis, service # --------------------------------------------------------------------------- # 路由表 @@ -64,6 +65,27 @@ def _health(**_: Any) -> dict[str, Any]: return {"ok": True, "time": datetime.now().isoformat(timespec="seconds")} +@route("GET", r"/api/config/display") +def _display_config(**_: Any) -> dict[str, Any]: + """把 config/report.yml 的显示精度暴露给前端。 + + 前端曾把百分比硬编码为 2 位小数,改配置不会有任何反应 —— + 与报告层是同一个毛病(配置是死的)。这里让前端也由配置驱动。 + """ + from hdiv.core.config import load_config + from hdiv.report.format import NumFmt + + cfg = load_config("report") + f = NumFmt.from_config(cfg) + return { + "ratio": f.ratio, "money": f.money, "price": f.price, + "percent": f.percent, + "max_width": cfg.layout.max_width, + "table_page_size": cfg.layout.table_page_size, + "theme": cfg.theme, + } + + @route("GET", r"/api/summary") def _summary(**_: Any) -> dict[str, Any]: return service.summary() @@ -135,6 +157,19 @@ def _stock(symbol: str, q: dict[str, list[str]], **_: Any) -> dict[str, Any]: return r +@route("GET", r"/api/walkforwards") +def _walkforwards(**_: Any) -> dict[str, Any]: + return {"items": service.list_walkforwards()} + + +@route("GET", r"/api/walkforwards/(?P[\w-]+)") +def _walkforward(wf_id: str, **_: Any) -> dict[str, Any]: + r = service.get_walkforward(wf_id) + if r is None: + raise ApiError(404, f"Walk-forward 记录不存在:{wf_id}") + return r + + @route("GET", r"/api/backtests") def _backtests(q: dict[str, list[str]], **_: Any) -> dict[str, Any]: return {"items": service.list_backtests( @@ -162,9 +197,16 @@ def _backtest_metrics(run_id: str, **_: Any) -> dict[str, Any]: return {"items": service.get_backtest_metrics(run_id)} +@route("GET", r"/api/indices") +def _indices(**_: Any) -> dict[str, Any]: + """可叠加到净值曲线右轴的基准指数。""" + return {"items": service.list_indices()} + + @route("GET", r"/api/backtests/(?P[\w-]+)/equity") -def _backtest_equity(run_id: str, **_: Any) -> dict[str, Any]: - return service.get_backtest_equity(run_id) +def _backtest_equity(run_id: str, q: dict[str, list[str]], **_: Any) -> dict[str, Any]: + """净值曲线;index= 指定右轴叠加的指数(缺省不叠加)。""" + return service.get_backtest_equity(run_id, index_code=_one(q, "index")) @route("GET", r"/api/backtests/(?P[\w-]+)/trades") @@ -174,9 +216,34 @@ def _backtest_trades(run_id: str, q: dict[str, list[str]], **_: Any) -> dict[str ) -@route("GET", r"/api/backtests/(?P[\w-]+)/positions") -def _backtest_positions(run_id: str, **_: Any) -> dict[str, Any]: - return {"items": service.get_backtest_positions(run_id)} +@route("GET", r"/api/backtests/(?P[\w-]+)/portfolio") +def _portfolio(run_id: str, q: dict[str, list[str]], **_: Any) -> dict[str, Any]: + """任意交易日的组合汇总 + 逐股持仓明细。date 省缺则取最后一日。""" + return analysis.portfolio_on_date(run_id, _one(q, "date")) + + +@route("GET", r"/api/backtests/(?P[\w-]+)/position-dates") +def _position_dates(run_id: str, q: dict[str, list[str]], **_: Any) -> dict[str, Any]: + return analysis.position_dates( + run_id, limit=int(_one(q, "sample") or 0) or None, + detail=_bool(q, "detail"), + ) + + +@route("GET", r"/api/backtests/(?P[\w-]+)/stocks") +def _run_stocks(run_id: str, **_: Any) -> dict[str, Any]: + return {"items": analysis.run_stocks(run_id)} + + +@route("GET", r"/api/backtests/(?P[\w-]+)/stocks/(?P[\w.]+)") +def _run_stock_detail(run_id: str, symbol: str, q: dict[str, list[str]], + **_: Any) -> dict[str, Any]: + """某股在该回测中的买卖点与指标曲线(series 可勾选)。""" + raw = _one(q, "series") + wanted = [x.strip() for x in raw.split(",") if x.strip()] if raw else None + return analysis.stock_detail( + run_id, symbol, start=_one(q, "start"), end=_one(q, "end"), series=wanted + ) @route("GET", r"/api/backtests/(?P[\w-]+)/signals") @@ -291,6 +358,11 @@ def make_handler(static: StaticFiles, *, api_only: bool = False) -> type[BaseHTT self._serve_static(path) except ApiError as exc: self._json(exc.status, {"error": exc.message}) + except HdivError as exc: + # HdivError = 用户可理解的问题(参数越界、数据缺失等)。 + # 返回 400 + 原始信息,而不是笼统的 500「服务端内部错误」—— + # 后者会把「日期超出范围」这种可自行修正的问题说成服务故障。 + self._json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) except Exception: traceback.print_exc() self._json(HTTPStatus.INTERNAL_SERVER_ERROR, diff --git a/tests/test_dividend_smoothing.py b/tests/test_dividend_smoothing.py new file mode 100644 index 0000000..aa72699 --- /dev/null +++ b/tests/test_dividend_smoothing.py @@ -0,0 +1,250 @@ +"""TTM 股息率毛刺消除测试。 + +**背景**:A 股相邻两次除权的间隔经常不是 365 天。硬 365 天窗口于是在每年 +除权日附近制造两种日历假象: + +- **重叠虚高**:间隔 < 365 时新旧分红同时在窗口内。实测招商银行 2015-07-03 + 股息率 0.620 → 1.290(+108%),10 天后回落到 0.670。 +- **断档虚低**:间隔 > 365 时旧的已到期而新的未入场。实测中国神华 + 2016-07-04:0.740 → 0.320(−57%)。 + +两者都会污染「历史分位」这一核心信号,且筛选器用的也是同一个数 +(直接决定选股),因此必须消除。 +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from hdiv.factor.dividend_yield import ( + build_dps_events, + ttm_dps_at, + ttm_dps_series, + ttm_params, +) + +TTM = 365 +GRACE = 45 + + +def _events(pairs: list[tuple[str, float]]) -> pd.DataFrame: + """构造分红事件表:(除权日, 金额)。""" + return pd.DataFrame({ + "ex_date": pd.to_datetime([d for d, _ in pairs]), + "imp_ann_date": pd.to_datetime([d for d, _ in pairs]), + "cash_div_tax": [a for _, a in pairs], + }) + + +def _daily(start: str, end: str) -> pd.DatetimeIndex: + return pd.date_range(start, end, freq="D") + + +# --------------------------------------------------------------------------- +# 核心:两种毛刺都要消除 +# --------------------------------------------------------------------------- + + +def test_overlap_spike_is_removed() -> None: + """间隔 360 天:新分红入场时旧的不应再计入(消除 +100% 虚高)。""" + d = _daily("2020-01-01", "2023-12-31") + ev = _events([("2021-06-01", 1.0), ("2022-05-27", 1.2), ("2023-05-22", 1.4)]) + + raw = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, + smooth_spikes=False), index=d) + sm = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, + smooth_spikes=True), index=d) + + # 未平滑时:2022-05-27 当刻涨到 1.0+1.2=2.2,5 天后旧的到期回落 + assert raw.max() > 2.1, f"未平滑应出现重叠虚高,实际 max={raw.max()}" + # 平滑后:不应出现两笔相加 + assert sm.max() <= 1.45, f"平滑后不应双算,实际 max={sm.max()}" + # 且切换当天不跳变 + after = sm.loc[pd.Timestamp("2022-05-27"):].iloc[:5] + assert after.max() / after.min() - 1 < 0.05, "接管当天不应有跳变" + + +def test_gap_dip_is_filled() -> None: + """间隔 370 天:旧的到期后应继续计到新的入场(消除断档虚低)。""" + d = _daily("2020-01-01", "2023-12-31") + ev = _events([("2021-06-01", 1.0), ("2022-06-06", 1.2)]) # 间隔 370 天 + + raw = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, + smooth_spikes=False), index=d) + sm = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, + smooth_spikes=True), index=d) + + # 只看「第一笔到第二笔」这段(尾部无后继本就该归零,属正确行为) + win = slice(pd.Timestamp("2021-06-01"), pd.Timestamp("2022-06-06")) + raw_a, sm_a = raw.loc[win], sm.loc[win] + # 未平滑:2022-06-01 旧的到期、新的还没来 → 归零 5 天 + assert raw_a.min() == 0.0, "未平滑应出现断档归零" + # 平滑后:同区间不应归零 + assert sm_a.min() > 0.9, f"平滑后不应断档,实际 min={sm_a.min()}" + + +def test_intra_year_multiple_payments_are_not_merged() -> None: + """年内多次分红(间隔 180 天)必须都保留 —— 否则会把中期分红误删。""" + d = _daily("2021-01-01", "2023-12-31") + ev = _events([ + ("2022-06-01", 0.3), ("2022-11-28", 0.7), + ("2023-05-29", 0.3), ("2023-11-25", 0.7), + ]) + sm = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, + smooth_spikes=True), index=d) + # 年中确实应同时含两笔(0.3 + 0.7 = 1.0) + peak = sm.loc[pd.Timestamp("2023-06-01"):pd.Timestamp("2023-11-20")] + assert peak.max() > 0.95, f"年内两笔分红应同时计入,实际 max={peak.max()}" + + +def test_true_cessation_still_goes_to_zero() -> None: + """真停发必须如实归零,不能因为平滑就永远挂着旧分红。""" + d = _daily("2020-01-01", "2025-12-31") + ev = _events([("2021-06-01", 1.0), ("2023-06-01", 1.0)]) # 中间空了两年 + sm = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, + smooth_spikes=True), index=d) + # 2021 那笔在 2022-06-01 + 45 天宽限后必须归零 + gap = sm.loc[pd.Timestamp("2022-09-01"):pd.Timestamp("2023-05-31")] + assert gap.max() == 0.0, f"停发期间应归零,实际 max={gap.max()}" + + +def test_smoothing_can_be_disabled() -> None: + """smooth_spikes=False 应精确复现旧的「硬窗口 + 归零才兜底」行为。""" + d = _daily("2020-01-01", "2023-12-31") + ev = _events([("2021-06-01", 1.0), ("2022-05-27", 1.2)]) + off = ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, smooth_spikes=False) + # 旧行为:重叠期双算 + assert off.max() >= 2.1 + on = ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True) + assert on.max() < off.max() + + +def test_build_dps_events_matches_series_expectations() -> None: + """事件表经 build_dps_events 规范化后仍可用。""" + raw = pd.DataFrame({ + "symbol": ["X"] * 3, + "ex_date": ["2021-06-01", "2022-05-27", "2023-05-22"], + "imp_ann_date": ["2021-05-25", "2022-05-20", "2023-05-15"], + "cash_div_tax": [1.0, 1.2, 1.4], + }) + e = build_dps_events(raw)["X"] + d = _daily("2021-01-01", "2023-12-31") + out = ttm_dps_series(d, e, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True) + assert len(out) == len(d) and out.max() <= 1.45 + + +# --------------------------------------------------------------------------- +# 口径统一:筛选 / 画像 / 回测 / Web 必须用同一份参数 +# --------------------------------------------------------------------------- + + +def test_ttm_params_is_single_source_of_truth() -> None: + """四处调用点必须都从 ttm_params() 取参,不得各自硬编码。""" + import inspect + from pathlib import Path + + root = Path(__file__).resolve().parents[1] / "src" / "hdiv" + # 回测引擎曾硬编码 ttm_days=365, grace_days=45 + eng = (root / "backtest" / "engine.py").read_text(encoding="utf-8") + assert "ttm_days=365, grace_days=45" not in eng, "引擎仍在硬编码 TTM 参数" + assert "ttm_params()" in eng + # walk-forward 与 web 曾用函数默认值 + for rel in ("backtest/walk_forward.py", "web/analysis.py"): + text = (root / rel).read_text(encoding="utf-8") + assert "ttm_params()" in text, f"{rel} 未使用统一参数" + # 因子层自身 + f = inspect.getsource(__import__( + "hdiv.factor.dividend_yield", fromlist=["x"])) + assert "def ttm_params" in f + + +def test_ttm_dps_at_matches_series_right_endpoint() -> None: + """单点求值(筛选器用)必须与序列右端点一致。""" + d = _daily("2020-01-01", "2022-12-31") + ev = _events([("2021-06-01", 1.0), ("2022-05-27", 1.2)]) + asof = pd.Timestamp("2021-12-31").date() + one = ttm_dps_at(asof, ev) + ser = ttm_dps_series(pd.DatetimeIndex([pd.Timestamp(asof)]), ev, + ttm_days=TTM, grace_days=GRACE, smooth_spikes=True) + assert one is not None + assert abs(one - float(ser[0])) < 1e-9 + + +def test_ttm_params_reads_config() -> None: + from hdiv.core.config import load_config + + w, g, sm = ttm_params() + c = load_config("profile").ttm_dividend + assert (w, g, sm) == (c.window_days, c.grace_days, c.smooth_spikes) + + +def test_config_exposes_smooth_spikes_switch() -> None: + """开关必须暴露在 YAML 里,用户可自行关闭。""" + from hdiv.core.config import load_config + + assert hasattr(load_config("profile").ttm_dividend, "smooth_spikes") + + +@pytest.mark.parametrize("grace", [0, 10, 45, 90]) +def test_smoothing_never_produces_negative_or_nan(grace: int) -> None: + d = _daily("2020-01-01", "2023-12-31") + ev = _events([("2021-06-01", 1.0), ("2022-06-06", 1.2), ("2023-06-01", 1.4)]) + out = ttm_dps_series(d, ev, ttm_days=TTM, grace_days=grace, smooth_spikes=True) + assert np.isfinite(out).all() + assert (out >= 0).all() + + +# --------------------------------------------------------------------------- +# 透传一致性:包装函数必须接收并转发所有参数 +# --------------------------------------------------------------------------- + + +def test_wrapper_signature_forwards_all_params() -> None: + """回归:`dividend_yield_series` 是 `ttm_dps_series` 的包装。 + + 曾经只给**调用方**加了 `smooth_spikes`,却忘了在包装函数签名里声明, + 于是 walk-forward 直接 `TypeError` 崩在第一个窗口 —— 而测试全绿, + 因为测试没走 walk-forward 那条路径。 + """ + import inspect + + from hdiv.factor import dividend_yield as dy + + inner = set(inspect.signature(dy.ttm_dps_series).parameters) - {"dates", "events"} + outer = set(inspect.signature(dy.dividend_yield_series).parameters) - {"close", "events"} + missing = inner - outer + assert not missing, ( + f"dividend_yield_series 未转发参数 {sorted(missing)};" + "调用方传了就会 TypeError" + ) + # 且必须真的往下传 + src = inspect.getsource(dy.dividend_yield_series) + for name in inner: + assert f"{name}={name}" in src, f"包装函数未把 {name} 传给 ttm_dps_series" + + +def test_wrapper_accepts_smooth_spikes() -> None: + """直接以关键字调用,确保签名真的可用(不只是字符串包含)。""" + from hdiv.factor.dividend_yield import dividend_yield_series + + d = _daily("2021-01-01", "2022-12-31") + close = pd.Series(10.0, index=d) + ev = _events([("2021-06-01", 1.0), ("2022-05-27", 1.2)]) + for flag in (True, False): + out = dividend_yield_series(close, ev, ttm_days=365, grace_days=45, + smooth_spikes=flag) + assert not out.empty + assert "dividend_yield" in out.columns + + +def test_all_ttm_callers_pass_the_unified_params() -> None: + """五处调用点都必须显式传 smooth_spikes,不能靠默认值(否则与配置脱钩)。""" + from pathlib import Path + + root = Path(__file__).resolve().parents[1] / "src" / "hdiv" + for rel in ("profile/builder.py", "backtest/engine.py", + "backtest/walk_forward.py", "web/analysis.py"): + src = (root / rel).read_text(encoding="utf-8") + assert "smooth_spikes" in src, f"{rel} 未传 smooth_spikes(会与配置脱钩)" diff --git a/tests/test_format.py b/tests/test_format.py new file mode 100644 index 0000000..d754c12 --- /dev/null +++ b/tests/test_format.py @@ -0,0 +1,159 @@ +"""统一数值格式化测试。 + +**背景**:`config/report.yml: layout.decimals` 长期是摆设 —— `ratio` 与 +`money` 从未被读取,各报告模块各自硬编码小数位(7 处), +前端也把百分比写死 2 位。结果是「改了配置不生效」。 +本测试钉住「配置必须真的驱动输出」。 +""" + +from __future__ import annotations + +import pytest + +from hdiv.report.format import NumFmt + + +# --------------------------------------------------------------------------- +# 语义:百分比小数位 = ratio - 2 +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "ratio,expect", + [(2, "6%"), (4, "6.17%"), (6, "6.1715%"), (8, "6.171491%")], +) +def test_percent_decimals_derive_from_ratio(ratio: int, expect: str) -> None: + """比率保留 ratio 位后乘 100,恰好少两位 —— 这是与用户确认的语义。""" + f = NumFmt(ratio=ratio) + assert f.pct(0.06171491) == expect + assert f.percent == max(0, ratio - 2) + + +def test_ratio_str_keeps_ratio_decimals() -> None: + assert NumFmt(ratio=4).ratio_str(0.06171491) == "0.0617" + assert NumFmt(ratio=6).ratio_str(0.06171491) == "0.061715" + + +def test_ratio_below_two_does_not_go_negative() -> None: + """ratio=1 时百分比不能出现负小数位(会抛异常)。""" + f = NumFmt(ratio=1) + assert f.percent == 0 + assert f.pct(0.0617) == "6%" + + +def test_missing_values_render_as_dash() -> None: + f = NumFmt() + for v in (None, float("nan")): + assert f.pct(v) == "—" + assert f.ratio_str(v) == "—" + assert f.yi(v) == "—" + + +def test_non_numeric_falls_back_to_str() -> None: + """传进来已格式化的字符串不应崩,也不应二次加工。""" + f = NumFmt() + assert f.pct("已格式化") == "已格式化" + + +def test_pp_and_plus_signs() -> None: + f = NumFmt(ratio=4) + assert f.pct_pp(0.1211) == "+12.11pp" + assert f.pct_pp(-0.0324) == "-3.24pp" + assert f.pct(0.0401, plus=True) == "+4.01%" + + +def test_by_unit_dispatch() -> None: + f = NumFmt(ratio=4, money=2) + assert f.by_unit(0.0617, "pct") == "6.17%" + assert f.by_unit(123456789.0, "money") == "1.23亿" + assert f.by_unit(16.72, "years") == "17" + assert f.by_unit(0.0617, "ratio") == "0.0617" + assert f.by_unit(2838, "int") == "2,838" + + +# --------------------------------------------------------------------------- +# 配置必须真的驱动输出 +# --------------------------------------------------------------------------- + + +def test_from_config_reads_report_yml() -> None: + from hdiv.core.config import load_config + + cfg = load_config("report") + f = NumFmt.from_config(cfg) + d = cfg.layout.decimals + assert (f.ratio, f.money, f.price) == (d.ratio, d.money, d.price) + assert f.percent == max(0, d.ratio - 2) + + +def test_from_config_survives_broken_config() -> None: + """配置不可用时回落默认值,而不是让报告生成崩掉。""" + + class Boom: + @property + def layout(self): + raise RuntimeError("配置坏了") + + f = NumFmt.from_config(Boom()) + assert f.ratio == 4 + + +def test_renderer_uses_config_not_hardcoded_defaults() -> None: + """回归:渲染器的 fmt_pct 曾默认 2 位且从不读 decimals.ratio。""" + import inspect + + from hdiv.report.renderer import Renderer + + src = inspect.getsource(Renderer.fmt_pct) + assert "NumFmt" in src or "self.fmt" in src, "fmt_pct 应走统一格式化器" + assert ":.2f}%" not in src, "fmt_pct 不应再硬编码 2 位" + + +def test_no_hardcoded_percent_format_in_report_modules() -> None: + """五个报告模块都不应再有硬编码的百分比精度。""" + from pathlib import Path + + root = Path(__file__).resolve().parents[1] / "src" / "hdiv" / "report" + offenders = [] + for name in ("profile_report", "backtest_report", "universe_report", + "sensitivity_report", "walkforward_report"): + text = (root / f"{name}.py").read_text(encoding="utf-8") + for i, line in enumerate(text.splitlines(), 1): + if ":.2f}%" in line or ":.2f}pp" in line: + offenders.append(f"{name}:{i}") + assert not offenders, f"仍硬编码百分比精度:{offenders}" + + +def test_frontend_precision_endpoint_exists() -> None: + """前端也必须由配置驱动(曾把百分比写死 2 位)。""" + from hdiv.web.server import ROUTES + + assert any(pat.match("/api/config/display") for _m, pat, _f in ROUTES), \ + "缺少 /api/config/display,前端无法获知配置的显示精度" + + +def test_no_hardcoded_percent_anywhere_in_src() -> None: + """整个 src/ 都不应再有硬编码的百分比精度(含接口层与 CLI 输出)。 + + 曾散落 20 余处,其中「成交理由里的股息率」是用户直接看到的那种。 + 唯一允许的例外是 format.py 自身的文档说明。 + """ + from pathlib import Path + + root = Path(__file__).resolve().parents[1] / "src" / "hdiv" + offenders = [] + for f in root.rglob("*.py"): + if f.name == "format.py": + continue + for i, line in enumerate(f.read_text(encoding="utf-8").splitlines(), 1): + if ":.2f}%" in line or "100:.2f" in line: + offenders.append(f"{f.relative_to(root)}:{i}") + assert not offenders, f"仍硬编码百分比精度:{offenders}" + + +def test_frontend_uses_config_precision() -> None: + from pathlib import Path + + js = (Path(__file__).resolve().parents[1] / "web" / "app.js").read_text(encoding="utf-8") + assert "FMT" in js and "config/display" in js, "前端未接入配置驱动精度" + assert "FMT.percent" in js, "百分比应使用配置的 percent" diff --git a/tests/test_universe.py b/tests/test_universe.py index 7978e9f..cf2fa23 100644 --- a/tests/test_universe.py +++ b/tests/test_universe.py @@ -560,3 +560,62 @@ def test_dividend_records_include_base_share() -> None: src = inspect.getsource(Repo.dividend_records) assert "base_share" in src, "dividend_records 必须选出 base_share" + + +# --------------------------------------------------------------------------- +# 重跑覆盖同一条记录 +# --------------------------------------------------------------------------- + + +def test_universe_run_id_is_deterministic() -> None: + """回归:run_id 不得含时间戳,否则同参数重跑会不断累积重复记录。 + + 早期实现把 datetime.now() 编进指纹,同一 asof 最多累积了 11 条内容相同的记录。 + 现在的语义是「同一份配置 + 同一时点 → 同一个 run_id → 重跑原地覆盖」。 + """ + import inspect + + from hdiv.universe import selector + + src = inspect.getsource(selector.UniverseSelector) + i = src.find("run_id = stable_id(") + assert i != -1, "未找到 run_id 生成处" + # 取到该语句结束的分号行(不能用第一个 ')',那会截断在 config_hash(self.config) 里) + end = src.find("\n )", i) + assert end != -1, "未找到 run_id 语句结尾" + block = src[i:end] + assert "datetime.now" not in block, f"run_id 指纹仍含时间戳:{block}" + for must in ("config_hash", "effective", "self.config.name"): + assert must in block, f"run_id 指纹缺少 {must}:{block}" + + +@pytest.mark.db +def test_universe_rerun_overwrites_same_record() -> None: + """同参数重跑不新增记录,且成员行数等于候选数(无重复堆积)。""" + from hdiv.core.config import load_config + from hdiv.data import db + from hdiv.data.sync.base import stable_id + + db.load_dotenv_once() + cfg = load_config("datasource") + df = db.read_sql( + "SELECT r.run_id, r.candidate_count, r.asof_date, r.config_hash, r.name, " + " COUNT(m.id) AS member_rows " + "FROM hd_universe_run r LEFT JOIN hd_universe_member m ON m.run_id = r.run_id " + "GROUP BY r.run_id HAVING member_rows > 0 " + "ORDER BY r.created_at DESC LIMIT 5", + cfg=cfg, + ) + if df.empty: + pytest.skip("没有筛选记录") + checked = 0 + for _, r in df.iterrows(): + expect = stable_id("universe", r["name"], str(r["asof_date"]), r["config_hash"]) + if r["run_id"] != expect: + continue # 确定化之前的历史记录,跳过 + checked += 1 + assert int(r["member_rows"]) == int(r["candidate_count"]), ( + f"run_id={r['run_id'][:10]} 成员行数 {r['member_rows']} " + f"应等于候选数 {r['candidate_count']}(出现重复堆积)" + ) + assert checked > 0, "未找到确定化之后生成的筛选记录,无法验证" diff --git a/tests/test_web.py b/tests/test_web.py index a8e4360..4a53baa 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -26,10 +26,12 @@ from hdiv.web.server import ROUTES #: 路径样例用于匹配路由正则;改前端时需同步此表。 FRONTEND_CALLS: list[tuple[str, str]] = [ ("GET", "/api/health"), + ("GET", "/api/config/display"), ("GET", "/api/summary"), ("GET", "/api/universes"), ("GET", "/api/universes/abc123"), ("GET", "/api/universes/abc123/members"), + ("GET", "/api/universes/abc123/members/600519.SH"), ("GET", "/api/universes/abc123/backtests"), ("PATCH", "/api/universes/abc123"), ("GET", "/api/stocks/600519.SH"), @@ -38,8 +40,18 @@ FRONTEND_CALLS: list[tuple[str, str]] = [ ("GET", "/api/backtests/abc123/metrics"), ("GET", "/api/backtests/abc123/equity"), ("GET", "/api/backtests/abc123/trades"), + # 净值曲线右轴可叠加的基准指数 + ("GET", "/api/indices"), ("GET", "/api/backtests/abc123/signals"), ("PATCH", "/api/backtests/abc123"), + # 回测内分析:任意日持仓 + 个股买卖点 + ("GET", "/api/backtests/abc123/portfolio"), + ("GET", "/api/backtests/abc123/position-dates"), + ("GET", "/api/backtests/abc123/stocks"), + ("GET", "/api/backtests/abc123/stocks/600519.SH"), + # Walk-forward 样本外 + ("GET", "/api/walkforwards"), + ("GET", "/api/walkforwards/abc123"), ] @@ -87,18 +99,25 @@ def test_members_endpoint_defaults_to_selected() -> None: "未指定 passed 时应视为 1(仅入选)" -def test_every_route_has_a_frontend_or_cli_consumer() -> None: - """反向检查:后端不应暴露无人使用的接口(便于发现遗留死接口)。""" - known_paths = {p for _m, p in FRONTEND_CALLS} - orphans = [] - for _m, pat, fn in ROUTES: - # 用契约表中的样例路径试探该路由是否有消费者 - sample = pat.pattern.replace("^", "").replace("$", "") - sample = re.sub(r"\(\?P<\w+>\[[^\]]+\]\+?\)", "abc123", sample) - if not any(pat.match(p) for p in known_paths) and "/stocks/" not in sample: - orphans.append((_m, pat.pattern)) - # /stocks/ 由画像页使用;/universes/{id}/members/{sym} 为可选下钻 - assert len(orphans) <= 2, f"疑似无人使用的接口:{orphans}" +def test_every_route_is_covered_by_the_contract() -> None: + """反向检查:每条后端路由都必须出现在契约表里。 + + 这样契约表就是「前后端接口清单」的唯一事实来源: + 新增接口忘了登记会被发现,删接口忘了清契约也会被发现。 + + 早期版本给两个「可选下钻」接口开了后门(阈值 <= 2), + 结果新增的三个接口漏登记却被放行 —— 所以现在零容忍。 + """ + known = {p for _m, p in FRONTEND_CALLS} + uncovered = [] + for method, pat, _fn in ROUTES: + if not any(m == method and pat.match(p) for m, p in FRONTEND_CALLS) and \ + not any(pat.match(p) for p in known): + uncovered.append((method, pat.pattern)) + assert not uncovered, ( + f"以下路由未登记在 FRONTEND_CALLS 中:{uncovered}\n" + "新增接口时请同步更新契约表,否则前端改动无法被发现。" + ) # --------------------------------------------------------------------------- @@ -326,6 +345,44 @@ def test_all_api_payloads_are_json_serializable() -> None: json.dumps(p, ensure_ascii=False, cls=_Encoder) # 不应抛异常 +@requires_db +def test_equity_index_overlay_is_date_aligned() -> None: + """净值曲线右轴叠加的指数必须与日期**逐点对齐**。 + + 类目轴上每个类目一个点:指数序列只要少一天, + 整条指数线就会相对净值曲线整体错位,画出错误的对比。 + """ + from hdiv.core.errors import HdivError + from hdiv.web import service + + rid = _sample_backtest_run() + if not rid: + pytest.skip("没有可用的回测") + items = service.list_indices() + if not items: + pytest.skip("hd_index_daily 没有指数行情") + assert [x["code"] for x in items if x["is_default"]] == [service.DEFAULT_INDEX_CODE], \ + "应恰好把默认指数(沪深300)标成 default" + + base = service.get_backtest_equity(rid) + if not base["dates"]: + pytest.skip("该回测没有净值曲线") + assert base["index"] is None, "不传 index 时不应凭空叠加指数" + + for it in items: + ix = service.get_backtest_equity(rid, index_code=it["code"])["index"] + assert ix["code"] == it["code"] and ix["name"] + assert len(ix["close"]) == len(base["dates"]), f"{it['code']} 未与净值曲线对齐" + vals = [v for v in ix["close"] if v is not None] + # 叠加的是指数点位,不是净值;量级错了说明取错了列 + assert not vals or min(vals) > 10, f"{it['code']} 取值不像指数点位:{vals[:3]}" + json.dumps(ix, allow_nan=False) + + # 库里没有的指数应当明确报错,而不是画一条空线 + with pytest.raises(HdivError): + service.get_backtest_equity(rid, index_code="999999.XX") + + @requires_db def test_reason_text_is_human_readable() -> None: """成交理由必须渲染成人话,而不是丢一坨 JSON 给前端。""" @@ -573,3 +630,320 @@ def test_site_build_does_not_clobber_spa() -> None: site.sync_frontend(verbose=False) html = (project_root() / "output" / "index.html").read_text(encoding="utf-8") assert "app/app.js" in html + + +# --------------------------------------------------------------------------- +# 回测内分析:任意日持仓 + 个股买卖点 +# --------------------------------------------------------------------------- + + +def _sample_backtest_run() -> str | None: + from hdiv.core.config import load_config + from hdiv.data import db + + df = db.read_sql( + "SELECT r.run_id FROM hd_backtest_run r " + "JOIN hd_backtest_position p ON p.run_id = r.run_id " + "WHERE r.mode = 'single' " + "GROUP BY r.run_id ORDER BY COUNT(*) DESC LIMIT 1", + cfg=load_config("datasource"), + ) + return None if df.empty else str(df["run_id"].iloc[0]) + + +@requires_db +def test_position_dates_is_compact_by_default() -> None: + """默认只返回日期字符串:带全字段会让响应从约 30KB 涨到 460KB。""" + from hdiv.web import analysis + + rid = _sample_backtest_run() + if not rid: + pytest.skip("没有带持仓的回测") + d = analysis.position_dates(rid) + assert "dates" in d and d["dates"], "应返回日期数组" + assert "items" not in d, "默认不应返回逐日全字段明细" + assert d["count"] == len(d["dates"]) + assert d["dates"] == sorted(d["dates"]), "日期应升序" + # 紧凑形式必须显著更小 + import json + + compact = len(json.dumps(d, ensure_ascii=False).encode()) + full = len(json.dumps(analysis.position_dates(rid, detail=True), + ensure_ascii=False).encode()) + assert compact < full / 3, f"紧凑形式应远小于明细({compact} vs {full})" + + +@requires_db +def test_portfolio_falls_back_to_previous_trading_day() -> None: + """非交易日应回退到之前最近的有快照交易日,并如实标注。""" + from hdiv.web import analysis + from hdiv.core.errors import HdivError + + rid = _sample_backtest_run() + if not rid: + pytest.skip("没有带持仓的回测") + dates = analysis.position_dates(rid)["dates"] + d = analysis.portfolio_on_date(rid, dates[-1]) + assert d["date"] == dates[-1] and not d["adjusted"] + + # 区间内但非交易日(用周末构造) + import datetime as _dt + + mid = _dt.date.fromisoformat(dates[len(dates) // 2]) + weekend = mid + _dt.timedelta(days=(5 - mid.weekday()) % 7 + 1) + d2 = analysis.portfolio_on_date(rid, weekend.isoformat()) + assert d2["date"] <= weekend.isoformat() + assert d2["adjusted"] is True, "非交易日应标注已回退" + + # 早于首个快照应给出可理解错误 + with pytest.raises(HdivError, match="早于该回测的首个快照"): + analysis.portfolio_on_date(rid, "1990-01-01") + + +@requires_db +def test_portfolio_summary_is_internally_consistent() -> None: + """持仓汇总必须自洽:市值合计 = 逐股之和;权重合计 ≈ 仓位占比。""" + from hdiv.web import analysis + + rid = _sample_backtest_run() + if not rid: + pytest.skip("没有带持仓的回测") + dates = analysis.position_dates(rid)["dates"] + # 找一个有持仓的交易日 + for day in reversed(dates): + d = analysis.portfolio_on_date(rid, day) + if d["positions"]: + break + else: + pytest.skip("没有非空持仓日") + + s = sum(p["market_value"] or 0 for p in d["positions"]) + assert abs(s - d["summary"]["market_value"]) < 1.0 + assert d["summary"]["count"] == len(d["positions"]) + w = sum(p["weight"] or 0 for p in d["positions"]) + tv = d["equity"]["total_value"] or 0 + if tv: + assert abs(w - d["summary"]["market_value"] / tv) < 0.02, \ + f"权重合计 {w:.4f} 应约等于仓位占比 {d['summary']['market_value']/tv:.4f}" + # 每只股票都应带名称(JOIN stock) + assert all(p["symbol"] for p in d["positions"]) + + +@requires_db +def test_stock_detail_series_and_trades() -> None: + """个股买卖点:序列长度一致、买卖点带完整成交信息。""" + from hdiv.web import analysis + + rid = _sample_backtest_run() + if not rid: + pytest.skip("没有带持仓的回测") + stocks = analysis.run_stocks(rid) + if not stocks: + pytest.skip("该回测没有持仓股票") + sym = stocks[0]["symbol"] + + d = analysis.stock_detail(rid, sym) + n = len(d["dates"]) + assert n > 0 + for k, v in d["series"].items(): + assert len(v) == n, f"序列 {k} 长度与日期不一致({len(v)} vs {n})" + assert "close" in d["series"] + + # 股息率必须在合理量级内(单位错误会让它变成 0 或几百) + dv = [x for x in d["series"]["dv_yield"] if x is not None] + if dv: + assert max(dv) < 1.0, f"股息率不应超过 100%:{max(dv)}" + assert min(dv) >= 0.0, "股息率不应为负" + + for t in d["trades"]: + assert t["side"] in {"BUY", "SELL"} + assert t["price"] and t["price"] > 0 + assert t["quantity"] and t["quantity"] > 0 + assert t["amount"] and t["amount"] > 0 + assert t["reason_text"] and t["reason_text"] != "" + assert d["stats"]["trade_count"] == len(d["trades"]) + + +@requires_db +def test_stock_detail_with_sell_trades_does_not_500() -> None: + """回归:有卖出的个股必须能打开。 + + 卖出成交的 ``holding_days`` 在库里是 NaN 而**不是** None, + 老代码 ``int(r["holding_days"]) if ... is not None else None`` 会抛 + ``ValueError: cannot convert float NaN to integer``, + 让个股详情接口 500 —— 22/35 只有卖出的个股整页打不开。 + """ + from hdiv.web import analysis + + rid = _sample_backtest_run() + if not rid: + pytest.skip("没有带持仓的回测") + stocks = analysis.run_stocks(rid) + sells = [s for s in stocks if (s.get("sell_count") or 0) > 0] + if not sells: + pytest.skip("该回测没有卖出成交") + sym = sells[0]["symbol"] + + d = analysis.stock_detail(rid, sym) # 老代码在这一行 500 + assert any(t["side"] == "SELL" for t in d["trades"]), "应至少有一笔卖出" + for t in d["trades"]: + hd = t["holding_days"] + assert hd is None or isinstance(hd, int), f"holding_days 应为整数或 None:{hd!r}" + assert hd is None or hd >= 0 + # NaN 会以非法 JSON 的形式漏到前端,这里一并卡住 + json.dumps(d, allow_nan=False) + + +@requires_db +def test_stock_detail_respects_series_selection() -> None: + """勾选哪些指标就只算哪些(不为没勾的做无谓计算)。""" + from hdiv.web import analysis + from hdiv.core.errors import HdivError + + rid = _sample_backtest_run() + if not rid: + pytest.skip("没有带持仓的回测") + sym = analysis.run_stocks(rid)[0]["symbol"] + d = analysis.stock_detail(rid, sym, series=["close", "roe"]) + assert set(d["series"]) == {"close", "roe"} + assert "pe_ttm" not in d["series"] + # 无效指标应报错而不是静默忽略 + with pytest.raises(HdivError): + analysis.stock_detail(rid, sym, series=["不存在的指标"]) + + +@requires_db +def test_roe_series_is_stepwise_not_interpolated() -> None: + """ROE 必须按公告日对齐成阶梯(插值会造出当时不存在的值)。""" + from hdiv.web import analysis + + rid = _sample_backtest_run() + if not rid: + pytest.skip("没有带持仓的回测") + sym = analysis.run_stocks(rid)[0]["symbol"] + d = analysis.stock_detail(rid, sym, series=["roe"]) + vals = [x for x in d["series"]["roe"] if x is not None] + if len(vals) < 50: + pytest.skip("ROE 样本不足") + # 阶梯序列的不同取值数应远少于样本数(季度更新,约 4 次/年) + distinct = len(set(round(v, 6) for v in vals)) + assert distinct < len(vals) / 5, \ + f"ROE 取值数 {distinct} 相对样本 {len(vals)} 过多,疑似插值而非阶梯" + + +# --------------------------------------------------------------------------- +# Walk-forward 前端可见性 +# --------------------------------------------------------------------------- + + +@requires_db +def test_walkforward_list_and_detail() -> None: + """Walk-forward 记录必须在接口层可见(此前完全没有入口)。""" + from hdiv.web import service + + items = service.list_walkforwards() + if not items: + pytest.skip("没有 walk-forward 记录") + w = items[0] + assert w["wf_id"] and w["window_count"] > 0 + assert w["title"], "应有可读标题" + assert w["strategy"]["conditions"], "应带策略条件说明" + assert "oos" in w and w["oos"].get("window_count") == w["window_count"] + + d = service.get_walkforward(w["wf_id"]) + assert d is not None + assert len(d["windows"]) == w["window_count"] + for win in d["windows"]: + # 每个窗口都必须有训练段与测试段 + assert win["train_start"] and win["test_start"] + assert win["train_run_id"] and win["test_run_id"] + assert "in_sample" in win and "out_of_sample" in win + s = d["summary"] + assert len(s["oos_returns"]) == w["window_count"] + assert s["oos_mean"] is not None + assert 0.0 <= s["oos_win_rate"] <= 1.0 + + +@requires_db +def test_walkforward_summary_is_consistent() -> None: + """汇总必须与逐窗口数据自洽(曾靠 metric 行数反推导致胜率算错)。""" + from hdiv.web import service + + items = service.list_walkforwards() + if not items: + pytest.skip("没有 walk-forward 记录") + for w in items[:3]: + d = service.get_walkforward(w["wf_id"]) + rets = [x["out_of_sample"].get("total_return") for x in d["windows"]] + rets = [x for x in rets if x is not None] + if not rets: + continue + assert abs(d["summary"]["oos_mean"] - sum(rets) / len(rets)) < 1e-9 + expect_win = sum(1 for x in rets if x > 0) / len(rets) + assert abs(d["summary"]["oos_win_rate"] - expect_win) < 1e-9 + # 列表页的汇总应与详情页一致 + assert abs((w["oos"]["mean_return"] or 0) - d["summary"]["oos_mean"]) < 1e-9 + + +def test_walkforward_frontend_page_exists() -> None: + """前端必须有 walk-forward 页与导航入口。""" + js = (project_root() / "web" / "app.js").read_text(encoding="utf-8") + html = (project_root() / "web" / "index.html").read_text(encoding="utf-8") + assert "viewWalkforwards" in js and "viewWalkforwardDetail" in js + assert "#/walkforwards" in html, "导航缺「样本外」入口" + assert "mountWalkforwardDetail" in js, "详情页应挂载对比图" + + +@requires_db +def test_walkforward_exposes_benchmark_and_excess() -> None: + """回归:基准指标存为 benchmark_,曾被 benchmark_code='' 过滤掉, + 导致页面上看不到最重要的「超额收益」。""" + from hdiv.web import service + + items = service.list_walkforwards() + if not items: + pytest.skip("没有 walk-forward 记录") + d = service.get_walkforward(items[0]["wf_id"]) + s = d["summary"] + assert s.get("benchmark_mean") is not None, "缺少基准均值" + assert s.get("excess_mean") is not None, "缺少超额收益均值" + assert s.get("excess_win_rate") is not None + + got = 0 + for w in d["windows"]: + if w["benchmark_return"] is None: + continue + got += 1 + assert w["benchmark_code"], "应记录基准代码" + o = w["out_of_sample"].get("total_return") + if o is not None: + assert abs(w["excess_return"] - (o - w["benchmark_return"])) < 1e-9, \ + "超额必须等于 策略收益 − 基准收益" + # 基准行不得混进策略指标里 + assert not any(k.startswith("benchmark::") for k in w["out_of_sample"]) + assert got > 0, "没有任何窗口带基准收益" + + +@requires_db +def test_walkforward_frozen_params_record_calibration() -> None: + """冻结参数必须记录「校准出的绝对阈值」,而不只是配置里的分位。 + + 训练段的作用是把相对分位(P75)转成绝对股息率;若只有分位、 + 没有绝对阈值,说明训练段实际上没做校准。 + """ + from hdiv.web import service + + items = service.list_walkforwards() + if not items: + pytest.skip("没有 walk-forward 记录") + d = service.get_walkforward(items[0]["wf_id"]) + abs_entries = [] + for w in d["windows"]: + f = w["frozen_params"] + assert "entry_yield_percentile" in f, "应保留分位口径" + assert f.get("absolute_entry_yield"), f"窗口 {w['window_index']} 缺校准阈值" + assert f.get("calibration_obs"), "应记录校准样本数" + abs_entries.append(f["absolute_entry_yield"]) + # 各窗口的绝对阈值应随市场水平变化(全相同说明没真校准) + assert len(set(round(x, 6) for x in abs_entries)) > 1, \ + "各窗口校准出的绝对阈值完全相同,疑似未真正校准" diff --git a/web/app.js b/web/app.js index 419dd20..19d77a1 100644 --- a/web/app.js +++ b/web/app.js @@ -18,10 +18,19 @@ const COLORS = ['#1E40AF','#D97706','#3B82F6','#059669','#DC2626', const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); -const num = (v, d = 2) => (v === null || v === undefined || Number.isNaN(v)) - ? '—' : Number(v).toLocaleString('zh-CN', {minimumFractionDigits: d, maximumFractionDigits: d}); -const pct = (v, d = 2) => (v === null || v === undefined || Number.isNaN(v)) - ? '—' : (v * 100).toFixed(d) + '%'; +// 显示精度来自 config/report.yml: layout.decimals(启动时从 /api/config/display 取)。 +// 曾经这里硬编码 2 位,改配置不会生效 —— 与报告层是同一个毛病。 +const FMT = {ratio: 4, money: 2, price: 2, percent: 2}; + +const num = (v, d) => (v === null || v === undefined || Number.isNaN(v)) + ? '—' : Number(v).toLocaleString('zh-CN', + {minimumFractionDigits: d === undefined ? FMT.ratio : d, + maximumFractionDigits: d === undefined ? FMT.ratio : d}); +// 百分比小数位 = ratio - 2(比率保留 ratio 位后乘 100,恰好少两位) +const pct = (v, d) => (v === null || v === undefined || Number.isNaN(v)) + ? '—' : (v * 100).toFixed(d === undefined ? FMT.percent : d) + '%'; +const pp = (v, d) => (v === null || v === undefined || Number.isNaN(v)) + ? '—' : ((v > 0 ? '+' : '') + (v * 100).toFixed(d === undefined ? FMT.percent : d) + 'pp'); const yi = v => (v === null || v === undefined) ? '—' : (Math.abs(v) >= 1e8 ? (v/1e8).toFixed(1) + ' 亿' : num(v, 0)); const sign = v => (v === null || v === undefined) ? '' : (v > 0 ? 'gain' : (v < 0 ? 'loss' : '')); @@ -59,6 +68,16 @@ function chart(id, option) { return c; } function disposeCharts() { while (charts.length) { try { charts.pop().dispose(); } catch (e) {} } } +/** 只销毁一张图(切换叠加指数时重画同一张图,不动页面上其它图)。 */ +function disposeChart(id) { + const el = document.getElementById(id); + if (!el || typeof echarts === 'undefined') return; + const inst = echarts.getInstanceByDom(el); + if (!inst) return; + inst.dispose(); + const i = charts.indexOf(inst); + if (i >= 0) charts.splice(i, 1); +} window.addEventListener('resize', () => charts.forEach(c => c.resize())); /* ---------------- 模态框 ---------------- */ @@ -634,7 +653,33 @@ async function viewBacktestDetail(runId) { ${rc.balanced ? '成本与分红入账完整无遗漏。' : '此时不应采信上方绩效指标。'} -

净值曲线与基准

+
+

净值曲线与基准

+
+ 叠加指数(右轴) + + +
+
+
+ 右轴叠加的是所选指数的点位(不归一化),左轴是净值(起点 = 1); + 虚线「基准净值」是本次回测自带的基准,已归一到 1,可直接与策略净值比高低。 + 指数默认沪深300,可在上方下拉切换或取消叠加。 +
+
+ +
+

持仓明细 …

+
+ 日期 + + + + + +
+
加载中…
+

回测条件

@@ -678,26 +723,105 @@ async function viewBacktestDetail(runId) {
`; } -async function mountBacktestDetail(runId) { - const eq = await api(`backtests/${runId}/equity`); - if (eq.dates && eq.dates.length) { - chart('c_eq', { - tooltip:{trigger:'axis'}, legend:{top:0,textStyle:{color:'#64748B'}}, - grid:{left:66,right:30,top:38,bottom:56}, - xAxis:{type:'category',data:eq.dates,axisLabel:{color:'#64748B', - formatter:v=>String(v).slice(0,7)}}, - yAxis:{type:'value',scale:true,name:'净值',axisLabel:{color:'#64748B'}, - splitLine:{lineStyle:{color:'#E9EEF6'}}}, - dataZoom:[{type:'inside'},{type:'slider',height:16,bottom:12}], - series:[ - {name:'策略净值',type:'line',data:eq.nav,showSymbol:false,lineStyle:{width:2,color:COLORS[0]}}, - {name:'基准净值',type:'line',data:eq.bench,showSymbol:false, - lineStyle:{width:1.4,color:COLORS[1],type:'dashed'}}, - {name:'回撤',type:'line',data:eq.drawdown,showSymbol:false,yAxisIndex:0, - lineStyle:{width:1,color:COLORS[4]},areaStyle:{opacity:.1}}] +const eqState = {runId: null, index: '', indices: []}; + +/** 净值曲线 + 可选的指数叠加(指数画在右轴,单位是点位,不参与净值刻度)。 */ +async function mountEquityChart(runId, indexCode) { + const host = document.getElementById('c_eq'); + if (!host) return; + disposeChart('c_eq'); + const eq = await api(`backtests/${runId}/equity` + + (indexCode ? `?index=${encodeURIComponent(indexCode)}` : '')); + const note = document.getElementById('eq-index-note'); + if (!eq.dates || !eq.dates.length) { + host.innerHTML = '
该回测没有净值曲线
'; + if (note) note.textContent = ''; + return; + } + host.innerHTML = ''; + disposeChart('c_eq'); // 连续切换下拉时,上一次请求可能已经 init 过 + const ix = eq.index; + const isNav = n => n === '策略净值' || n === '基准净值'; + // itemStyle 必须跟着写:图例与提示框的圆点取 itemStyle(缺省按调色板序号取色), + // 只写 lineStyle 会让「回撤」的圆点变成浅蓝、指数圆点变成绿色,与线色对不上。 + const series = [ + {name:'策略净值',type:'line',data:eq.nav,showSymbol:false, + lineStyle:{width:2,color:COLORS[0]},itemStyle:{color:COLORS[0]}}, + {name:'基准净值',type:'line',data:eq.bench,showSymbol:false, + lineStyle:{width:1.4,color:COLORS[1],type:'dashed'},itemStyle:{color:COLORS[1]}}, + {name:'回撤',type:'line',data:eq.drawdown,showSymbol:false, + lineStyle:{width:1,color:COLORS[4]},itemStyle:{color:COLORS[4]}, + areaStyle:{opacity:.1}}, + ]; + if (ix) { + series.push({ + name:`${ix.name}(右轴)`, type:'line', yAxisIndex:1, data:ix.close, + showSymbol:false, connectNulls:true, // 指数偶有缺日,不因此断线 + // 用紫色:与策略净值的深蓝、基准的橙虚线、回撤的红都拉开 + lineStyle:{width:1.6,color:COLORS[5]}, itemStyle:{color:COLORS[5]}, }); } + // 净值 / 回撤 / 指数点位三种量纲,不能用同一个小数位 + const rowFmt = p => { + const v = p.value; + const text = v == null ? '—' + : p.seriesName === '回撤' ? pct(v, 2) + : isNav(p.seriesName) ? num(v, 4) + : num(v, 2) + ' 点'; + return `
+ ${p.marker}${esc(p.seriesName)} + ${text}
`; + }; + + chart('c_eq', { + tooltip:{trigger:'axis',axisPointer:{type:'cross'}, + formatter: ps => `
` + + `${esc(ps.length ? ps[0].axisValue : '')}
${ps.map(rowFmt).join('')}`}, + legend:{top:0,textStyle:{color:'#64748B'}}, + grid:{left:66,right:ix?62:30,top:38,bottom:56}, + xAxis:{type:'category',data:eq.dates,axisLabel:{color:'#64748B', + formatter:v=>String(v).slice(0,7)}}, + yAxis:[ + {type:'value',scale:true,name:'净值',axisLabel:{color:'#64748B'}, + splitLine:{lineStyle:{color:'#E9EEF6'}}}, + ...(ix ? [{type:'value',scale:true,name:'指数点位',position:'right', + axisLabel:{color:'#64748B',formatter:v=>num(v,0)}, + nameTextStyle:{color:'#64748B'},splitLine:{show:false}}] : []), + ], + dataZoom:[{type:'inside'},{type:'slider',height:16,bottom:12}], + series, + }); + + if (note) { + if (!ix) note.textContent = ''; + else if (!ix.covered) + note.innerHTML = '该指数在本次回测区间内没有行情'; + else note.textContent = + `${ix.name} ${ix.code} · 覆盖 ${ix.points}/${eq.dates.length} 个交易日`; + } +} + +async function mountBacktestDetail(runId) { + // 持仓面板与净值曲线并行加载:持仓查询较慢,不想拖住曲线 + pfState.runId = runId; + loadPortfolio(null); + + // 指数下拉:拿不到列表也不该挡住净值曲线本身 + eqState.runId = runId; + try { eqState.indices = (await api('indices')).items || []; } + catch (e) { eqState.indices = []; } + const def = eqState.indices.find(x => x.is_default) || eqState.indices[0]; + eqState.index = def ? def.code : ''; + const sel = document.getElementById('eq-index'); + if (sel) { + sel.innerHTML = eqState.indices.map(x => + ``).join('') + + ``; + } + await mountEquityChart(runId, eqState.index); + const t = await api(`backtests/${runId}/trades?size=200`); document.getElementById('trade-count').textContent = t.total + ' 笔'; const host = document.getElementById('trades'); @@ -709,7 +833,7 @@ async function mountBacktestDetail(runId) { 已实现盈亏持仓天数触发理由 ${t.items.map((x,i)=>` ${i+1} - ${esc(x.symbol)} + ${esc(x.symbol)} ${esc(x.signal_date)}${esc(x.execution_date)} ${esc(x.side)} ${num(x.price,3)}${num(x.quantity,0)} @@ -738,6 +862,577 @@ async function mountBacktestDetail(runId) { } catch (e) { /* 未成交信号是可选信息,失败不影响主流程 */ } } +/* ---------------- 回测详情:持仓明细 ---------------- */ + +const pfState = {runId: null, dates: [], cursor: null, loadedFor: null}; + +// 日期清单在进入回测时取一次即可 —— 曾在每次切日期时重复拉取, +// 单次 460KB,翻页几下就很浪费。 +async function ensurePfDates(runId) { + if (pfState.loadedFor === runId && pfState.dates.length) return pfState.dates; + const d = await api(`backtests/${runId}/position-dates`); + pfState.dates = d.dates || []; + pfState.loadedFor = runId; + return pfState.dates; +} + +async function loadPortfolio(date) { + const host = document.getElementById('pf-body'); + if (!host) return; + host.innerHTML = '
加载中…
'; + try { + const q = date ? `?date=${encodeURIComponent(date)}` : ''; + const d = await api(`backtests/${pfState.runId}/portfolio${q}`); + await ensurePfDates(pfState.runId); + pfState.cursor = d.date; + const el = document.getElementById('pf-date'); + if (el) el.value = d.date; + const badge = document.getElementById('pf-badge'); + if (badge) badge.textContent = d.adjusted + ? `请求 ${d.requested_date} → 最近交易日 ${d.date}` : d.date; + const rng = document.getElementById('pf-range'); + if (rng) rng.textContent = `可选区间 ${d.range.start} ~ ${d.range.end}(${d.range.count} 个交易日)`; + + const e = d.equity, sm = d.summary; + const rows = d.positions.map((x, i) => ` + ${i + 1} + ${esc(x.symbol)} + ${esc(x.name || '')} + ${esc(x.industry || '')} + ${num(x.quantity, 0)} + ${num(x.avg_cost, 3)} + ${num(x.close, 3)} + ${num(x.market_value, 0)} + ${pct(x.weight)} + ${num(x.unrealized_pnl, 0)} + ${pct(x.pnl_pct)} + ${x.holding_days ?? '—'} + `).join(''); + + host.innerHTML = ` +
+
总资产
${yi(e.total_value)}
+
净值 ${num(e.nav, 4)}
+
现金
${yi(e.cash)}
+
占比 ${pct(e.total_value ? e.cash / e.total_value : null)}
+
持仓市值
${yi(e.position_value)}
+
${sm.count} 只
+
浮动盈亏
+
${yi(sm.unrealized_pnl)}
+
成本 ${yi(sm.cost)} · ${pct(sm.unrealized_pnl_pct)}
+
当日涨跌
+
${pct(e.daily_return)}
+
累计 ${pct(e.cum_return)}
+
回撤
+
${pct(e.drawdown)}
+
相对历史高点
+
+ ${d.positions.length ? `
+ + + + ${rows} + + + + + + +
#代码名称行业股数成本收盘市值权重浮动盈亏收益率持仓天数
合计${num(sm.market_value, 0)}${pct(e.total_value ? sm.market_value / e.total_value : null)}${num(sm.unrealized_pnl, 0)}${pct(sm.unrealized_pnl_pct)}
` : '
该日空仓(100% 现金)
'} +
+ 点击任意股票代码可打开该股在本次回测中的买卖点与趋势图。 +
`; + } catch (err) { + host.innerHTML = `
加载失败:${esc(err.message)}
`; + } +} + +function shiftPortfolio(delta) { + if (!pfState.dates.length) return loadPortfolio(null); + if (delta === 0) return loadPortfolio(null); + const i = pfState.dates.indexOf(pfState.cursor); + const j = Math.max(0, Math.min(pfState.dates.length - 1, + (i < 0 ? pfState.dates.length - 1 : i) + delta)); + loadPortfolio(pfState.dates[j]); +} + +/* ---------------- 视图:回测内个股买卖点 ---------------- */ + +const stState = {runId: null, symbol: null, data: null, shown: {}}; + +async function viewBacktestStock(runId, symbol) { + let d; + try { d = await api(`backtests/${runId}/stocks/${encodeURIComponent(symbol)}`); } + catch (e) { + return ` +
无法加载:${esc(e.message)}
`; + } + const st = d.stats; + const i = d.info; + return ` + +

${esc(i.name || symbol)} + ${esc(symbol)}

+
${esc(i.industry || '—')} · 区间 ${esc(d.range.start)} ~ ${esc(d.range.end)} + ${d.range.downsampled ? `(原始 ${d.range.points} 点,已降采样显示)` : `(${d.range.points} 个交易日)`}
+ +
+
成交笔数
${st.trade_count}
+
买 ${st.buy_count} / 卖 ${st.sell_count}
+
买入金额
${yi(st.buy_amount)}
+
卖出 ${yi(st.sell_amount)}
+
已实现盈亏
+
${yi(st.realized_pnl)}
+
仅平仓部分
+
交易费用
${num(st.total_fees, 2)}
+
佣金+印花税+过户费
+
首笔 / 末笔
+
${esc(st.first_trade || '—')}
${esc(st.last_trade || '—')}
+
成交日期
+
+ +
+

趋势与买卖点

+
+ 显示指标(勾几项就是几联图,自上而下排列): + ${d.available_series.map(k => ``).join('')} + +
+
+
+
+ 每个指标独占一个面板(N 联图):时间轴、缩放与十字光标上下联动, + 便于对照同一时点的估值与质量读数。▲ 买入 / ▼ 卖出 同时标注在每个面板上, + 位置取该指标在成交日的取值;鼠标悬停可一次看全部指标与成交的价格、股数、金额。 + 股息率为 PIT-TTM 口径(TTM 每股分红 ÷ 不复权收盘价), + ROE 按公告日对齐成阶梯线(不插值,避免未来函数)。 +
+
+ +
+

逐笔成交明细 ${st.trade_count}

+ ${st.trade_count ? `
+ + + + + ${d.trades.map((t, i2) => ` + + + + + + + + + + + `).join('')} +
#信号日成交日方向价格股数金额佣金印花税过户费费用合计滑点成本已实现盈亏持仓天数触发理由
${i2 + 1}${esc(t.signal_date)}${esc(t.execution_date)}${t.side === 'BUY' ? '买入' : '卖出'}${num(t.price, 3)}${num(t.quantity, 0)}${num(t.amount, 0)}${num(t.commission, 2)}${num(t.stamp_tax, 2)}${num(t.transfer_fee, 2)}${num(t.total_cost, 2)}${num(t.slippage_cost, 2)}${t.realized_pnl == null ? '—' : num(t.realized_pnl, 0)}${t.holding_days ?? '—'}${esc(t.reason_text)}
` : '
该股在本次回测中没有成交
'} +
`; +} + +const SERIES_LABEL = {close: '股价', dv_yield: '股息率', pe_ttm: 'PE(TTM)', + pb: 'PB', roe: 'ROE', drawdown: '回撤'}; +// 面板排列顺序:股价永远在首位(买卖点以成交价标注),其余按 估值 → 质量 → 风险 排。 +// 顺序固定而非按勾选先后,避免同一组指标因勾选次序不同而换位置。 +const SERIES_ORDER = ['close', 'dv_yield', 'pe_ttm', 'pb', 'roe', 'drawdown']; +const SERIES_UNIT = {close: '元', dv_yield: '', pe_ttm: '倍', pb: '倍', + roe: '', drawdown: ''}; +// 轴刻度 / 十字光标标签:比率型指标(股息率、回撤)在此转为 % +const SERIES_TICK = {close: v => num(v, 2), dv_yield: v => (v * 100).toFixed(2) + '%', + pe_ttm: v => num(v, 1), pb: v => num(v, 1), roe: v => num(v, 1) + '%', + drawdown: v => (v * 100).toFixed(0) + '%'}; +// 提示框数值(带单位) +const SERIES_TEXT = {close: v => num(v, 2) + ' 元', dv_yield: v => pct(v, 2), + pe_ttm: v => num(v, 2), pb: v => num(v, 2), roe: v => num(v, 2) + '%', + drawdown: v => pct(v, 2)}; + +function mountBacktestStock(runId, symbol) { + const render = async () => { + const host = document.getElementById('c_stock'); + if (!host) return; + const shown = [...document.querySelectorAll('.st-ser:checked')] + .map(x => x.value) + .sort((a, b) => SERIES_ORDER.indexOf(a) - SERIES_ORDER.indexOf(b)); + if (!shown.length) { + host.style.height = '140px'; + host.innerHTML = '
请至少勾选一个指标
'; + const n0 = document.getElementById('c_stock_note'); + if (n0) n0.innerHTML = ''; + return; + } + host.innerHTML = '
加载中…
'; + const d = await api(`backtests/${runId}/stocks/${encodeURIComponent(symbol)}` + + `?series=${shown.join(',')}`); + stState.data = d; + host.innerHTML = ''; + paintStockPanels(host, d, shown); + }; + render().catch(e => { + const host = document.getElementById('c_stock'); + if (host) host.innerHTML = `
图表加载失败:${esc(e.message)}
`; + const note = document.getElementById('c_stock_note'); + if (note) note.innerHTML = ''; + }); + return render; +} + +/** 把选中的指标画成 N 联图:每个指标一个 grid,共用一个时间轴与缩放。 */ +function paintStockPanels(host, d, shown) { + const N = shown.length; + const TOP = 46; // 顶部:买卖点图例 + 首个面板标题 + const GAP = 34; // 面板间距(容纳下一个面板标题) + const BOTTOM = 54; // 末面板的 x 轴标签 + dataZoom 滑条 + const PANEL_H = N <= 2 ? 170 : (N <= 4 ? 132 : 112); + const gridTop = i => TOP + i * (PANEL_H + GAP); + host.style.height = (gridTop(N - 1) + PANEL_H + BOTTOM) + 'px'; + + const dateIndex = new Map(d.dates.map((dt, i) => [dt, i])); + const idxOf = date => (dateIndex.has(date) ? dateIndex.get(date) : -1); + const tradesOn = new Map(); + d.trades.forEach(t => { + if (!tradesOn.has(t.execution_date)) tradesOn.set(t.execution_date, []); + tradesOn.get(t.execution_date).push(t); + }); + + // 成交日可能落在价格区间之外(实测 000338.SZ 的卖出在区间最后一天之后一天), + // 直接用日期当类目会把这笔成交整笔丢掉。这里把这些日期按序补进横轴, + // 让股价面板仍能按成交价标出买卖点。 + const extraDates = [...new Set(d.trades.map(t => t.execution_date))] + .filter(dt => !dateIndex.has(dt)).sort(); + const axisDates = d.dates.slice(); + extraDates.forEach(dt => { + let lo = 0, hi = axisDates.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (axisDates[mid] < dt) lo = mid + 1; else hi = mid; + } + axisDates.splice(lo, 0, dt); + }); + + const titles = [], grids = [], xAxis = [], yAxis = [], series = []; + shown.forEach((k, i) => { + const color = COLORS[i % COLORS.length]; + const vals = d.series[k] || []; + const top = gridTop(i); + grids.push({left: 76, right: 26, top, height: PANEL_H}); + xAxis.push({ + type: 'category', data: axisDates, gridIndex: i, + // 两端留 1% 空隙:首/末成交日的三角标不会被画到 grid 外面切掉 + boundaryGap: ['1%', '1%'], + axisTick: {show: false}, + axisLine: {show: i === N - 1, lineStyle: {color: '#DBEAFE'}}, + // 只有最下面的面板显示日期,其余靠十字光标对齐读取 + axisLabel: {show: i === N - 1, color: '#64748B', fontSize: 11, + formatter: v => String(v).slice(0, 7)}, + axisPointer: {label: {show: i === N - 1}}, + }); + yAxis.push({ + // scale:true 让轴随数据取值;splitNumber 不能太小 —— 取 3 时 + // 「nice」刻度会把价格轴整到 0~120,趋势被压扁(实测过)。 + type: 'value', gridIndex: i, scale: true, splitNumber: 4, + axisLabel: {color: '#64748B', fontSize: 11, formatter: SERIES_TICK[k]}, + splitLine: {lineStyle: {color: '#E9EEF6'}}, + axisPointer: {label: {formatter: p => SERIES_TICK[k](p.value)}}, + }); + titles.push({ + left: 76, top: top - 20, + text: `{n|${SERIES_LABEL[k] || k}}` + + (SERIES_UNIT[k] ? `{u|(${SERIES_UNIT[k]})}` : '') + + `{v|最新 ${SERIES_TEXT[k](vals[vals.length - 1])}}`, + textStyle: {rich: { + n: {fontSize: 12, fontWeight: 600, color}, + u: {fontSize: 11, color: '#94A3B8'}, + v: {fontSize: 11, color: '#94A3B8', padding: [0, 0, 0, 10]}, + }}, + }); + series.push({ + name: SERIES_LABEL[k] || k, type: 'line', xAxisIndex: i, yAxisIndex: i, + // 指标序列按补过日期的横轴对齐(多出来的位置为 null,折线自然断开) + data: k === 'close' && !extraDates.length + ? vals : axisDates.map(dt => { + const j = dateIndex.get(dt); + return j === undefined ? null : vals[j]; + }), + showSymbol: false, sampling: 'lttb', z: 5, + lineStyle: {width: k === 'close' ? 1.6 : 1.3, color}, + itemStyle: {color}, + ...(k === 'dv_yield' ? {areaStyle: {opacity: 0.08, color}} : {}), + }); + // 买卖点画在**每个**面板上:取该指标在成交日的取值, + // 这样能直接看出「买在多少股息率 / 多少 PE」。 + // 股价面板用成交价(含滑点),与「▲▼ 标在成交价上」一致; + // 区间外的成交日只有成交价、没有指标值,因此只画在股价面板。 + [['BUY', '买入', COLORS[4], 'triangle', k === 'close' ? 12 : 8], + ['SELL', '卖出', COLORS[3], 'diamond', k === 'close' ? 12 : 8]] + .forEach(([side, cn, c, sym, size]) => { + const pts = d.trades.filter(t => t.side === side).map(t => { + if (k === 'close') return t.price == null ? null : [t.execution_date, t.price]; + const j = dateIndex.get(t.execution_date); + const v = j === undefined ? null : vals[j]; + return v == null ? null : [t.execution_date, v]; + }).filter(Boolean); + if (!pts.length) return; + series.push({ + name: cn, type: 'scatter', xAxisIndex: i, yAxisIndex: i, z: 20, + symbol: sym, symbolSize: size, itemStyle: {color: c}, + data: pts, tooltip: {show: false}, // 统一由 axis 提示框呈现 + }); + }); + }); + + const xIdx = shown.map((_, i) => i); + chart('c_stock', { + animation: false, + // 任意面板悬停都弹同一份「全指标 + 当日成交」读数 + tooltip: { + trigger: 'axis', confine: true, + axisPointer: {type: 'cross', label: {backgroundColor: '#475569'}}, + formatter: params => { + const p = Array.isArray(params) ? params[0] : params; + if (!p) return ''; + const date = p.axisValue, idx = idxOf(date); + const head = `
${esc(date)}
`; + const rows = idx < 0 + ? `
该日超出指标数据区间,仅此处的成交记录
` + : shown.map((k, i) => ` +
+ ● ${esc(SERIES_LABEL[k] || k)} + ${esc(SERIES_TEXT[k]((d.series[k] || [])[idx]))} +
`).join(''); + const trs = (tradesOn.get(date) || []).map(t => ` +
+ + ${t.side === 'BUY' ? '▲ 买入' : '▼ 卖出'} + ${num(t.price, 3)} 元 · + ${num(t.quantity, 0)} 股 · ${num(t.amount, 0)} 元
+ ${esc(t.reason_text)} +
`).join(''); + return `
${head}${rows}${trs}
`; + }, + }, + legend: {data: ['买入', '卖出'], top: 2, right: 8, itemWidth: 12, + itemHeight: 8, itemGap: 14, + textStyle: {color: '#64748B', fontSize: 11}}, + axisPointer: {link: [{xAxisIndex: 'all'}]}, // 十字光标跨面板对齐 + title: titles, grid: grids, xAxis, yAxis, + dataZoom: [{type: 'inside', xAxisIndex: xIdx}, + {type: 'slider', xAxisIndex: xIdx, height: 16, bottom: 10, + borderColor: '#DBEAFE', fillerColor: 'rgba(30,64,175,.08)', + handleStyle: {color: COLORS[0]}, + textStyle: {color: '#64748B', fontSize: 10}}], + series, + }); + + // 区间外的成交只画得出股价面板,明确说明,避免读者以为图上没卖点就是没卖过 + const note = document.getElementById('c_stock_note'); + if (note) { + const items = extraDates.map(dt => (tradesOn.get(dt) || []).map(t => + `${dt} ${t.side === 'BUY' ? '买入' : '卖出'} ${num(t.price, 3)} 元`).join('、')) + .filter(Boolean); + note.innerHTML = items.length ? `
+ 有 ${items.length} 笔成交发生在指标区间(${esc(d.range.start)} ~ ${esc(d.range.end)})之外: + ${esc(items.join(';'))}。
+ 这些成交只能按成交价标在「股价」面板上, + 股息率 / PE 等面板没有对应日期的取值,因此不标注(悬停对应日期仍可看到成交信息)。 +
` : ''; + } +} + +/* ---------------- 视图:Walk-forward ---------------- */ + +async function viewWalkforwards() { + const d = await api('walkforwards'); + return ` +

Walk-forward 样本外验证

+
每个窗口用训练段校准阈值、测试段冻结参数,是判断策略是否真正有效的核心依据。
+
+ 判读要点:单条路径的全期回测会系统性高估策略。 + 请以样本外均值与稳定性(均值/标准差,<1 表示窗口间差异大于均值本身)为准。 +
+ ${d.items.length ? `
${d.items.map(wfRec).join('')}
` + : `
还没有 Walk-forward 记录。
+ 运行 python -m hdiv backtest --mode walkforward(约 25 分钟)。
`}`; +} + +function wfRec(w) { + const o = w.oos || {}; + const m = o.mean_return, wr = o.win_rate, dd = o.worst_drawdown; + return `
+
+
+ ${esc(w.title)} + ${esc(w.status || 'OK')} +
+
+ ${esc((w.created_at || '').slice(0, 16))} + 步进 ${w.step_months} 月 + 数据版本 ${esc((w.data_version || '').slice(0, 10))} +
+ ${m != null ? `
+ 样本外均值${pct(m)} + 样本外胜率${pct(wr, 1)} + 最差回撤${pct(dd)} + 有效窗口${o.sample_count ?? '—'}/${o.window_count ?? '—'} +
` : ''} +
+
+ +
+
`; +} + +async function viewWalkforwardDetail(wfId) { + let d; + try { d = await api(`walkforwards/${wfId}`); } + catch (e) { + return ` +
无法加载:${esc(e.message)}
`; + } + const s = d.summary; + const rows = d.windows.map(w => { + const i = w.in_sample, o = w.out_of_sample; + const excess = (i.total_return != null && o.total_return != null) ? null : null; + return ` + #${w.window_index} + ${esc(w.train_start)} ~ ${esc(w.train_end)} + ${esc(w.test_start)} ~ ${esc(w.test_end)} + ${num(i.total_return != null ? i.total_return * 100 : null, 2)}% + ${num(i.cagr != null ? i.cagr * 100 : null, 2)}% + ${num(i.sharpe, 2)} + ${num(o.total_return != null ? o.total_return * 100 : null, 2)}% + ${num(o.cagr != null ? o.cagr * 100 : null, 2)}% + ${num(w.benchmark_return != null ? w.benchmark_return * 100 : null, 2)}% + ${num(w.excess_return != null ? w.excess_return * 100 : null, 2)}pp + ${num(o.max_drawdown != null ? o.max_drawdown * 100 : null, 2)}% + ${num(o.sharpe, 2)} + ${num(o.trade_count, 0)} + P${num(w.frozen_params.entry_yield_percentile, 0)} + `; + }).join(''); + + return ` +
样本外/${esc(d.strategy_id)}
+

${esc(d.title)}

+
+ ${esc(d.scheme)} · 训练 ${d.train_years} 年 / 测试 ${d.test_years} 年 · 步进 ${d.step_months} 月 · + ${esc(d.wf_id)} +
+ +
+
样本外收益均值
+
${pct(s.oos_mean)}
+
中位数 ${pct(s.oos_median)}
+
样本外胜率
+
${pct(s.oos_win_rate, 1)}
+
${s.oos_returns.filter(x => x > 0).length} / ${s.oos_returns.length} 个窗口为正
+
基准均值(沪深300)
+
${pct(s.benchmark_mean)}
+
同期被动持有
+
超额收益均值
+
${num(s.excess_mean != null ? s.excess_mean * 100 : null, 2)}pp
+
超额胜率 ${pct(s.excess_win_rate, 1)}
+
稳定性
+
${num(s.oos_stability, 2)}
+
均值/标准差,<1 表示结论不稳
+
最差回撤
+
${pct(s.oos_worst_drawdown)}
+
样本外最深
+
窗口数
+
${s.window_count}
+
${d.scheme} 滚动
+
+ +
+

样本内 vs 样本外

+
+
+ 若样本内收益明显高于样本外,说明阈值是在训练段「拟合」出来的, + 样本外无法复现 —— 这就是过拟合的直接证据。
+ 超额 = 策略 − 基准(沪深300)。本策略的典型形态是 + 牛市跑输、熊市跑赢:请结合当年的市场环境判读,不要只看均值。
+ 注意区间长度:训练段 5 年、测试段 1 年,因此上图统一用年化口径。 + 直接比两者的累计收益会得出「样本内远高于样本外」的错误印象。 +
+
+ +
+

逐窗口明细

+
+ + + + + + + + + + ${rows} +
训练段(5 年 · 校准参照分布)测试段(1 年 · 冻结参数)
窗口训练区间测试区间收益CAGRSharpe收益年化基准超额最大回撤Sharpe成交冻结阈值
+
+ 冻结阈值是该窗口在训练段校准出、并在测试段强制沿用的买入分位 —— + 各窗口若差异很大,说明策略对参数不稳定。 +
+
+ +
+

可复现性

+
+ + + + +
策略${esc(d.strategy_id)} v${esc(d.strategy_version)}
配置指纹${esc(d.config_hash || '—')}
数据版本${esc(d.data_version || '—')}
代码版本${esc(d.code_version || '—')}
+
`; +} + +function mountWalkforwardDetail(d) { + const w = d.windows || []; + if (!w.length) return; + chart('c_wf', { + tooltip: {trigger: 'axis', axisPointer: {type: 'shadow'}}, + legend: {top: 0, textStyle: {color: '#64748B'}}, + grid: {left: 64, right: 30, top: 38, bottom: 76}, + xAxis: {type: 'category', + data: w.map(x => `#${x.window_index}\n${String(x.test_start).slice(0, 7)}`), + axisLabel: {color: '#64748B', fontSize: 10, lineHeight: 13}}, + yAxis: {type: 'value', name: '年化收益 %', + axisLabel: {color: '#64748B', formatter: v => v + '%'}, + splitLine: {lineStyle: {color: '#E9EEF6'}}}, + series: [ + // 用**年化**而非累计:训练段 5 年、测试段 1 年, + // 直接比累计收益是不同长度区间的比较,会得出误导性结论。 + {name: `样本内年化(${w[0] ? '' : ''}5年)`, type: 'bar', barMaxWidth: 22, + itemStyle: {color: COLORS[2]}, + data: w.map(x => x.in_sample.cagr != null + ? +(x.in_sample.cagr * 100).toFixed(2) : null)}, + {name: '样本外年化(1年)', type: 'bar', barMaxWidth: 22, + itemStyle: {color: COLORS[0]}, + data: w.map(x => x.out_of_sample.cagr != null + ? +(x.out_of_sample.cagr * 100).toFixed(2) : null)}, + {name: '基准(年内)', type: 'bar', barMaxWidth: 22, + itemStyle: {color: COLORS[3]}, + data: w.map(x => x.benchmark_return != null + ? +(x.benchmark_return * 100).toFixed(2) : null)}, + {name: '超额', type: 'line', symbolSize: 7, lineStyle: {width: 1.6, color: COLORS[1]}, + itemStyle: {color: COLORS[1]}, + data: w.map(x => x.excess_return != null + ? +(x.excess_return * 100).toFixed(2) : null)}, + {name: '零轴', type: 'line', data: [], markLine: { + silent: true, symbol: 'none', + lineStyle: {color: '#94A3B8', type: 'dashed', width: 1}, + data: [{yAxis: 0}]}}, + ], + }); +} + /* ---------------- 视图:归档 ---------------- */ async function viewArchive() { const [us, bs] = await Promise.all([ @@ -778,6 +1473,7 @@ async function render() { const on = (key === 'home' && !parts.length) || (key === 'universes' && parts[0] === 'universes') || (key === 'backtests' && parts[0] === 'backtests') || + (key === 'walkforwards' && parts[0] === 'walkforwards') || (key === 'archive' && parts[0] === 'archive'); a.classList.toggle('active', !!on); }); @@ -798,10 +1494,22 @@ async function render() { if (d) after = () => mountStock(d); } else if (parts[0] === 'backtests' && parts.length === 1) html = await viewBacktests(q); + else if (parts[0] === 'backtests' && parts.length === 4 && parts[2] === 'stocks') { + html = await viewBacktestStock(parts[1], parts[3]); + after = () => { stState.runId = parts[1]; stState.symbol = parts[3]; + mountBacktestStock(parts[1], parts[3]); }; + } else if (parts[0] === 'backtests' && parts.length === 2) { html = await viewBacktestDetail(parts[1]); after = () => mountBacktestDetail(parts[1]).catch(e => toast(e.message, true)); } + else if (parts[0] === 'walkforwards' && parts.length === 1) + html = await viewWalkforwards(); + else if (parts[0] === 'walkforwards' && parts.length === 2) { + const d = await api(`walkforwards/${parts[1]}`); + html = await viewWalkforwardDetail(parts[1]); + after = () => mountWalkforwardDetail(d); + } else if (parts[0] === 'archive') html = await viewArchive(); else html = `
未知页面:${esc(path)}
返回概览
`; @@ -824,7 +1532,7 @@ document.addEventListener('click', async e => { const base = scope === 'b' ? 'backtests' : 'universes'; try { if (act === 'open') { - location.hash = `#/${base}/${id}`; return; + location.hash = `#/${btn.dataset.prefix || base}/${id}`; return; } if (act === 'rename') { const cur = await api(`${base}/${id}`); @@ -854,6 +1562,21 @@ document.addEventListener('click', async e => { toast(del ? '已删除(可从「归档」页恢复)' : '已恢复'); currentPath = ''; render(); return; } + if (act === 'pf-shift') { + shiftPortfolio(parseInt(btn.dataset.d, 10)); + return; + } + if (act === 'st-apply') { + const host = document.getElementById('c_stock'); + if (host) { + const shown = [...document.querySelectorAll('.st-ser:checked')].map(x => x.value); + if (!shown.length) { toast('请至少勾选一个指标', true); return; } + disposeCharts(); + const {parts} = parseHash(); + mountBacktestStock(parts[1], parts[3]); + } + return; + } if (act === 'page') { const {parts, q} = parseHash(); q.set('page', btn.dataset.p); @@ -877,6 +1600,16 @@ document.addEventListener('click', async e => { }); document.addEventListener('change', e => { + if (e.target.id === 'pf-date') { + loadPortfolio(e.target.value); + return; + } + if (e.target.id === 'eq-index') { + eqState.index = e.target.value; + mountEquityChart(eqState.runId, eqState.index) + .catch(err => toast(err.message, true)); + return; + } if (e.target.id === 'incArch' || e.target.id === 'incDel') { const {parts, q} = parseHash(); if (e.target.id === 'incArch') q.set('archived', e.target.checked ? '1' : '0'); @@ -900,6 +1633,11 @@ window.addEventListener('hashchange', () => { currentPath = ''; render(); }); try { await api('health'); st.textContent = 'API 正常'; st.className = 'badge ok'; + try { + const dc = await api('config/display'); + Object.assign(FMT, dc); + document.documentElement.style.setProperty('--max-width', (dc.max_width || 1440) + 'px'); + } catch (e) { /* 取不到就用默认精度,不影响使用 */ } } catch (e) { st.textContent = 'API 不可用'; st.className = 'badge fail'; } diff --git a/web/index.html b/web/index.html index 4df9a37..5b1fca3 100644 --- a/web/index.html +++ b/web/index.html @@ -15,6 +15,7 @@ 概览 股票池筛选 回测 + 样本外 归档