功能:Web 前端与报告格式化(工作区中此前未提交的工作)
说明:本提交**不是本轮会话所做**,而是工作区里此前遗留的未提交改动。
为把历史分开,先单独提交它,再提交本轮会话的修改。
包含:
- 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 项通过。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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}%"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 与波动
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
+78
-6
@@ -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<wf_id>[\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<run_id>[\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<run_id>[\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<run_id>[\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<run_id>[\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<run_id>[\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<run_id>[\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<run_id>[\w-]+)/stocks/(?P<symbol>[\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<run_id>[\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,
|
||||
|
||||
@@ -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(会与配置脱钩)"
|
||||
@@ -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"
|
||||
@@ -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, "未找到确定化之后生成的筛选记录,无法验证"
|
||||
|
||||
+386
-12
@@ -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_<code>,曾被 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, \
|
||||
"各窗口校准出的绝对阈值完全相同,疑似未真正校准"
|
||||
|
||||
+761
-23
@@ -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 ? '成本与分红入账完整无遗漏。' : '<b>此时不应采信上方绩效指标。</b>'}
|
||||
</div>
|
||||
|
||||
<div class="card"><h2>净值曲线与基准</h2><div id="c_eq" class="chart"></div></div>
|
||||
<div class="card">
|
||||
<h2>净值曲线与基准</h2>
|
||||
<div class="toolbar">
|
||||
<span class="small muted">叠加指数(右轴)</span>
|
||||
<select id="eq-index" style="width:auto"></select>
|
||||
<span class="small muted" id="eq-index-note"></span>
|
||||
</div>
|
||||
<div id="c_eq" class="chart"></div>
|
||||
<div class="callout" style="margin-top:12px">
|
||||
<b>右轴</b>叠加的是所选指数的<b>点位</b>(不归一化),左轴是净值(起点 = 1);
|
||||
虚线「基准净值」是本次回测<b>自带</b>的基准,已归一到 1,可直接与策略净值比高低。
|
||||
指数默认沪深300,可在上方下拉切换或取消叠加。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="portfolio-card">
|
||||
<h2>持仓明细 <span class="badge info" id="pf-badge">…</span></h2>
|
||||
<div class="toolbar">
|
||||
<span class="small muted">日期</span>
|
||||
<input type="date" id="pf-date" style="width:auto">
|
||||
<button data-act="pf-shift" data-d="-1">← 上一交易日</button>
|
||||
<button data-act="pf-shift" data-d="1">下一交易日 →</button>
|
||||
<button data-act="pf-shift" data-d="0">最新</button>
|
||||
<span class="small muted" id="pf-range"></span>
|
||||
</div>
|
||||
<div id="pf-body"><div class="loading">加载中…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>回测条件</h2>
|
||||
@@ -678,26 +723,105 @@ async function viewBacktestDetail(runId) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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]}},
|
||||
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 = '<div class="empty">该回测没有净值曲线</div>';
|
||||
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'}},
|
||||
{name:'回撤',type:'line',data:eq.drawdown,showSymbol:false,yAxisIndex:0,
|
||||
lineStyle:{width:1,color:COLORS[4]},areaStyle:{opacity:.1}}]
|
||||
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 `<div style="display:flex;gap:18px;justify-content:space-between;line-height:1.7">
|
||||
<span>${p.marker}${esc(p.seriesName)}</span>
|
||||
<b style="font-family:monospace">${text}</b></div>`;
|
||||
};
|
||||
|
||||
chart('c_eq', {
|
||||
tooltip:{trigger:'axis',axisPointer:{type:'cross'},
|
||||
formatter: ps => `<div style="font-weight:600;margin-bottom:4px">`
|
||||
+ `${esc(ps.length ? ps[0].axisValue : '')}</div>${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 = '<span class="badge warn">该指数在本次回测区间内没有行情</span>';
|
||||
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 =>
|
||||
`<option value="${esc(x.code)}"${x.code === eqState.index ? ' selected' : ''}>`
|
||||
+ `${esc(x.name)}(${esc(x.code)})</option>`).join('')
|
||||
+ `<option value=""${eqState.index ? '' : ' selected'}>不叠加</option>`;
|
||||
}
|
||||
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) {
|
||||
<th>已实现盈亏</th><th>持仓天数</th><th class="l">触发理由</th></tr></thead>
|
||||
<tbody>${t.items.map((x,i)=>`<tr>
|
||||
<td class="num">${i+1}</td>
|
||||
<td class="l"><a href="#/stocks/${esc(x.symbol)}" class="mono">${esc(x.symbol)}</a></td>
|
||||
<td class="l"><a href="#/backtests/${esc(runId)}/stocks/${esc(x.symbol)}" class="mono">${esc(x.symbol)}</a></td>
|
||||
<td class="num">${esc(x.signal_date)}</td><td class="num">${esc(x.execution_date)}</td>
|
||||
<td><span class="badge ${x.side==='BUY'?'gain':'loss'}">${esc(x.side)}</span></td>
|
||||
<td class="num">${num(x.price,3)}</td><td class="num">${num(x.quantity,0)}</td>
|
||||
@@ -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 = '<div class="loading">加载中…</div>';
|
||||
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) => `<tr>
|
||||
<td class="num">${i + 1}</td>
|
||||
<td class="l"><a href="#/backtests/${esc(d.run_id)}/stocks/${esc(x.symbol)}" class="mono">${esc(x.symbol)}</a></td>
|
||||
<td class="l">${esc(x.name || '')}</td>
|
||||
<td class="l small muted">${esc(x.industry || '')}</td>
|
||||
<td class="num">${num(x.quantity, 0)}</td>
|
||||
<td class="num">${num(x.avg_cost, 3)}</td>
|
||||
<td class="num">${num(x.close, 3)}</td>
|
||||
<td class="num">${num(x.market_value, 0)}</td>
|
||||
<td class="num">${pct(x.weight)}</td>
|
||||
<td class="num ${sign(x.unrealized_pnl)}">${num(x.unrealized_pnl, 0)}</td>
|
||||
<td class="num ${sign(x.pnl_pct)}">${pct(x.pnl_pct)}</td>
|
||||
<td class="num">${x.holding_days ?? '—'}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
host.innerHTML = `
|
||||
<div class="kpi-grid" style="margin-bottom:14px">
|
||||
<div class="kpi"><div class="label">总资产</div><div class="value">${yi(e.total_value)}</div>
|
||||
<div class="note">净值 ${num(e.nav, 4)}</div></div>
|
||||
<div class="kpi"><div class="label">现金</div><div class="value">${yi(e.cash)}</div>
|
||||
<div class="note">占比 ${pct(e.total_value ? e.cash / e.total_value : null)}</div></div>
|
||||
<div class="kpi"><div class="label">持仓市值</div><div class="value">${yi(e.position_value)}</div>
|
||||
<div class="note">${sm.count} 只</div></div>
|
||||
<div class="kpi"><div class="label">浮动盈亏</div>
|
||||
<div class="value ${sign(sm.unrealized_pnl)}">${yi(sm.unrealized_pnl)}</div>
|
||||
<div class="note">成本 ${yi(sm.cost)} · ${pct(sm.unrealized_pnl_pct)}</div></div>
|
||||
<div class="kpi"><div class="label">当日涨跌</div>
|
||||
<div class="value ${sign(e.daily_return)}">${pct(e.daily_return)}</div>
|
||||
<div class="note">累计 ${pct(e.cum_return)}</div></div>
|
||||
<div class="kpi"><div class="label">回撤</div>
|
||||
<div class="value ${e.drawdown < 0 ? 'loss' : ''}">${pct(e.drawdown)}</div>
|
||||
<div class="note">相对历史高点</div></div>
|
||||
</div>
|
||||
${d.positions.length ? `<div class="tw"><table class="data">
|
||||
<thead><tr><th>#</th><th class="l">代码</th><th class="l">名称</th><th class="l">行业</th>
|
||||
<th>股数</th><th>成本</th><th>收盘</th><th>市值</th><th>权重</th>
|
||||
<th>浮动盈亏</th><th>收益率</th><th>持仓天数</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
<tfoot><tr><td colspan="7" class="l"><b>合计</b></td>
|
||||
<td class="num"><b>${num(sm.market_value, 0)}</b></td>
|
||||
<td class="num"><b>${pct(e.total_value ? sm.market_value / e.total_value : null)}</b></td>
|
||||
<td class="num ${sign(sm.unrealized_pnl)}"><b>${num(sm.unrealized_pnl, 0)}</b></td>
|
||||
<td class="num ${sign(sm.unrealized_pnl_pct)}"><b>${pct(sm.unrealized_pnl_pct)}</b></td>
|
||||
<td></td></tr></tfoot>
|
||||
</table></div>` : '<div class="empty">该日空仓(100% 现金)</div>'}
|
||||
<div class="callout" style="margin-top:12px">
|
||||
点击任意股票代码可打开<b>该股在本次回测中的买卖点与趋势图</b>。
|
||||
</div>`;
|
||||
} catch (err) {
|
||||
host.innerHTML = `<div class="callout fail">加载失败:${esc(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
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 `<div class="crumb"><a href="#/backtests/${esc(runId)}">返回回测</a></div>
|
||||
<div class="callout fail"><b>无法加载:</b>${esc(e.message)}</div>`;
|
||||
}
|
||||
const st = d.stats;
|
||||
const i = d.info;
|
||||
return `
|
||||
<div class="crumb"><a href="#/backtests">回测</a><span>/</span>
|
||||
<a href="#/backtests/${esc(runId)}">${esc(runId.slice(0, 12))}…</a><span>/</span>个股</div>
|
||||
<h1 class="page">${esc(i.name || symbol)}
|
||||
<span class="mono muted" style="font-size:15px">${esc(symbol)}</span></h1>
|
||||
<div class="page-sub">${esc(i.industry || '—')} · 区间 ${esc(d.range.start)} ~ ${esc(d.range.end)}
|
||||
${d.range.downsampled ? `(原始 ${d.range.points} 点,已降采样显示)` : `(${d.range.points} 个交易日)`}</div>
|
||||
|
||||
<div class="kpi-grid">
|
||||
<div class="kpi"><div class="label">成交笔数</div><div class="value">${st.trade_count}</div>
|
||||
<div class="note">买 ${st.buy_count} / 卖 ${st.sell_count}</div></div>
|
||||
<div class="kpi"><div class="label">买入金额</div><div class="value">${yi(st.buy_amount)}</div>
|
||||
<div class="note">卖出 ${yi(st.sell_amount)}</div></div>
|
||||
<div class="kpi"><div class="label">已实现盈亏</div>
|
||||
<div class="value ${sign(st.realized_pnl)}">${yi(st.realized_pnl)}</div>
|
||||
<div class="note">仅平仓部分</div></div>
|
||||
<div class="kpi"><div class="label">交易费用</div><div class="value">${num(st.total_fees, 2)}</div>
|
||||
<div class="note">佣金+印花税+过户费</div></div>
|
||||
<div class="kpi"><div class="label">首笔 / 末笔</div>
|
||||
<div class="value" style="font-size:14px">${esc(st.first_trade || '—')}<br>${esc(st.last_trade || '—')}</div>
|
||||
<div class="note">成交日期</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>趋势与买卖点</h2>
|
||||
<div class="toolbar">
|
||||
<span class="small muted">显示指标(勾几项就是几联图,自上而下排列):</span>
|
||||
${d.available_series.map(k => `<label class="check">
|
||||
<input type="checkbox" class="st-ser" value="${k}"
|
||||
${['close','dv_yield','pe_ttm','roe'].includes(k) ? 'checked' : ''}>
|
||||
${esc(SERIES_LABEL[k] || k)}</label>`).join('')}
|
||||
<button data-act="st-apply" style="margin-left:auto">应用</button>
|
||||
</div>
|
||||
<div id="c_stock" class="chart"></div>
|
||||
<div id="c_stock_note"></div>
|
||||
<div class="callout" style="margin-top:12px">
|
||||
每个指标<b>独占一个面板</b>(N 联图):时间轴、缩放与十字光标上下联动,
|
||||
便于对照同一时点的估值与质量读数。<b>▲ 买入 / ▼ 卖出</b> 同时标注在<b>每个面板</b>上,
|
||||
位置取该指标在成交日的取值;鼠标悬停可一次看全部指标与成交的价格、股数、金额。
|
||||
股息率为 PIT-TTM 口径(TTM 每股分红 ÷ 不复权收盘价),
|
||||
ROE 按<b>公告日</b>对齐成阶梯线(不插值,避免未来函数)。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>逐笔成交明细 <span class="badge info">${st.trade_count}</span></h2>
|
||||
${st.trade_count ? `<div class="tw"><table class="data">
|
||||
<thead><tr><th>#</th><th>信号日</th><th>成交日</th><th>方向</th>
|
||||
<th>价格</th><th>股数</th><th>金额</th><th>佣金</th><th>印花税</th><th>过户费</th>
|
||||
<th>费用合计</th><th>滑点成本</th><th>已实现盈亏</th><th>持仓天数</th>
|
||||
<th class="l">触发理由</th></tr></thead>
|
||||
<tbody>${d.trades.map((t, i2) => `<tr>
|
||||
<td class="num">${i2 + 1}</td>
|
||||
<td class="num">${esc(t.signal_date)}</td><td class="num">${esc(t.execution_date)}</td>
|
||||
<td><span class="badge ${t.side === 'BUY' ? 'gain' : 'loss'}">${t.side === 'BUY' ? '买入' : '卖出'}</span></td>
|
||||
<td class="num">${num(t.price, 3)}</td><td class="num">${num(t.quantity, 0)}</td>
|
||||
<td class="num">${num(t.amount, 0)}</td>
|
||||
<td class="num">${num(t.commission, 2)}</td><td class="num">${num(t.stamp_tax, 2)}</td>
|
||||
<td class="num">${num(t.transfer_fee, 2)}</td><td class="num">${num(t.total_cost, 2)}</td>
|
||||
<td class="num">${num(t.slippage_cost, 2)}</td>
|
||||
<td class="num ${sign(t.realized_pnl)}">${t.realized_pnl == null ? '—' : num(t.realized_pnl, 0)}</td>
|
||||
<td class="num">${t.holding_days ?? '—'}</td>
|
||||
<td class="l small">${esc(t.reason_text)}</td></tr>`).join('')}
|
||||
</tbody></table></div>` : '<div class="empty">该股在本次回测中没有成交</div>'}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 = '<div class="empty">请至少勾选一个指标</div>';
|
||||
const n0 = document.getElementById('c_stock_note');
|
||||
if (n0) n0.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
host.innerHTML = '<div class="loading">加载中…</div>';
|
||||
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 = `<div class="callout fail">图表加载失败:${esc(e.message)}</div>`;
|
||||
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 = `<div style="font-weight:600;margin-bottom:4px">${esc(date)}</div>`;
|
||||
const rows = idx < 0
|
||||
? `<div style="color:#94A3B8;font-size:12px">该日超出指标数据区间,仅此处的成交记录</div>`
|
||||
: shown.map((k, i) => `
|
||||
<div style="display:flex;gap:16px;justify-content:space-between;line-height:1.7">
|
||||
<span style="color:${COLORS[i % COLORS.length]}">● ${esc(SERIES_LABEL[k] || k)}</span>
|
||||
<b style="font-family:monospace">${esc(SERIES_TEXT[k]((d.series[k] || [])[idx]))}</b>
|
||||
</div>`).join('');
|
||||
const trs = (tradesOn.get(date) || []).map(t => `
|
||||
<div style="margin-top:6px;padding-top:6px;border-top:1px dashed #CBD5E1">
|
||||
<b style="color:${t.side === 'BUY' ? COLORS[4] : COLORS[3]}">
|
||||
${t.side === 'BUY' ? '▲ 买入' : '▼ 卖出'}</b>
|
||||
<span style="font-family:monospace">${num(t.price, 3)} 元</span> ·
|
||||
${num(t.quantity, 0)} 股 · ${num(t.amount, 0)} 元<br/>
|
||||
<span style="color:#64748B;font-size:12px">${esc(t.reason_text)}</span>
|
||||
</div>`).join('');
|
||||
return `<div style="max-width:340px;white-space:normal">${head}${rows}${trs}</div>`;
|
||||
},
|
||||
},
|
||||
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 ? `<div class="callout warn" style="margin-top:12px">
|
||||
有 ${items.length} 笔成交发生在指标区间(${esc(d.range.start)} ~ ${esc(d.range.end)})之外:
|
||||
${esc(items.join(';'))}。<br/>
|
||||
这些成交只能按<b>成交价</b>标在「股价」面板上,
|
||||
股息率 / PE 等面板没有对应日期的取值,因此不标注(悬停对应日期仍可看到成交信息)。
|
||||
</div>` : '';
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 视图:Walk-forward ---------------- */
|
||||
|
||||
async function viewWalkforwards() {
|
||||
const d = await api('walkforwards');
|
||||
return `
|
||||
<h1 class="page">Walk-forward 样本外验证</h1>
|
||||
<div class="page-sub">每个窗口用训练段校准阈值、测试段冻结参数,是判断策略是否真正有效的核心依据。</div>
|
||||
<div class="callout warn">
|
||||
<b>判读要点</b>:单条路径的全期回测会系统性高估策略。
|
||||
请以<b>样本外均值</b>与<b>稳定性</b>(均值/标准差,<1 表示窗口间差异大于均值本身)为准。
|
||||
</div>
|
||||
${d.items.length ? `<div class="rec-list">${d.items.map(wfRec).join('')}</div>`
|
||||
: `<div class="empty">还没有 Walk-forward 记录。<br>
|
||||
运行 <code class="mono">python -m hdiv backtest --mode walkforward</code>(约 25 分钟)。</div>`}`;
|
||||
}
|
||||
|
||||
function wfRec(w) {
|
||||
const o = w.oos || {};
|
||||
const m = o.mean_return, wr = o.win_rate, dd = o.worst_drawdown;
|
||||
return `<div class="rec">
|
||||
<div class="rec-main">
|
||||
<div class="rec-title">
|
||||
<a href="#/walkforwards/${esc(w.wf_id)}">${esc(w.title)}</a>
|
||||
<span class="badge info">${esc(w.status || 'OK')}</span>
|
||||
</div>
|
||||
<div class="rec-meta">
|
||||
<span class="mono">${esc((w.created_at || '').slice(0, 16))}</span>
|
||||
<span>步进 ${w.step_months} 月</span>
|
||||
<span>数据版本 <span class="mono">${esc((w.data_version || '').slice(0, 10))}</span></span>
|
||||
</div>
|
||||
${m != null ? `<div class="rec-metrics">
|
||||
<span class="rec-metric">样本外均值<b class="${sign(m)}">${pct(m)}</b></span>
|
||||
<span class="rec-metric">样本外胜率<b>${pct(wr, 1)}</b></span>
|
||||
<span class="rec-metric">最差回撤<b class="loss">${pct(dd)}</b></span>
|
||||
<span class="rec-metric">有效窗口<b>${o.sample_count ?? '—'}/${o.window_count ?? '—'}</b></span>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
<div class="rec-actions">
|
||||
<button class="primary" data-act="open" data-id="${esc(w.wf_id)}" data-prefix="walkforwards">
|
||||
打开逐窗口结果</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function viewWalkforwardDetail(wfId) {
|
||||
let d;
|
||||
try { d = await api(`walkforwards/${wfId}`); }
|
||||
catch (e) {
|
||||
return `<div class="crumb"><a href="#/walkforwards">样本外</a></div>
|
||||
<div class="callout fail"><b>无法加载:</b>${esc(e.message)}</div>`;
|
||||
}
|
||||
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 `<tr>
|
||||
<td class="num">#${w.window_index}</td>
|
||||
<td class="num">${esc(w.train_start)} ~ ${esc(w.train_end)}</td>
|
||||
<td class="num">${esc(w.test_start)} ~ ${esc(w.test_end)}</td>
|
||||
<td class="num">${num(i.total_return != null ? i.total_return * 100 : null, 2)}%</td>
|
||||
<td class="num"><b>${num(i.cagr != null ? i.cagr * 100 : null, 2)}%</b></td>
|
||||
<td class="num">${num(i.sharpe, 2)}</td>
|
||||
<td class="num ${sign(o.total_return)}"><b>${num(o.total_return != null ? o.total_return * 100 : null, 2)}%</b></td>
|
||||
<td class="num ${sign(o.cagr)}">${num(o.cagr != null ? o.cagr * 100 : null, 2)}%</td>
|
||||
<td class="num">${num(w.benchmark_return != null ? w.benchmark_return * 100 : null, 2)}%</td>
|
||||
<td class="num ${sign(w.excess_return)}"><b>${num(w.excess_return != null ? w.excess_return * 100 : null, 2)}pp</b></td>
|
||||
<td class="num loss">${num(o.max_drawdown != null ? o.max_drawdown * 100 : null, 2)}%</td>
|
||||
<td class="num">${num(o.sharpe, 2)}</td>
|
||||
<td class="num">${num(o.trade_count, 0)}</td>
|
||||
<td class="num">P${num(w.frozen_params.entry_yield_percentile, 0)}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="crumb"><a href="#/walkforwards">样本外</a><span>/</span>${esc(d.strategy_id)}</div>
|
||||
<h1 class="page">${esc(d.title)}</h1>
|
||||
<div class="page-sub">
|
||||
${esc(d.scheme)} · 训练 ${d.train_years} 年 / 测试 ${d.test_years} 年 · 步进 ${d.step_months} 月 ·
|
||||
<span class="mono">${esc(d.wf_id)}</span>
|
||||
</div>
|
||||
|
||||
<div class="kpi-grid">
|
||||
<div class="kpi"><div class="label">样本外收益均值</div>
|
||||
<div class="value ${sign(s.oos_mean)}">${pct(s.oos_mean)}</div>
|
||||
<div class="note">中位数 ${pct(s.oos_median)}</div></div>
|
||||
<div class="kpi"><div class="label">样本外胜率</div>
|
||||
<div class="value">${pct(s.oos_win_rate, 1)}</div>
|
||||
<div class="note">${s.oos_returns.filter(x => x > 0).length} / ${s.oos_returns.length} 个窗口为正</div></div>
|
||||
<div class="kpi"><div class="label">基准均值(沪深300)</div>
|
||||
<div class="value">${pct(s.benchmark_mean)}</div>
|
||||
<div class="note">同期被动持有</div></div>
|
||||
<div class="kpi"><div class="label">超额收益均值</div>
|
||||
<div class="value ${sign(s.excess_mean)}">${num(s.excess_mean != null ? s.excess_mean * 100 : null, 2)}pp</div>
|
||||
<div class="note">超额胜率 ${pct(s.excess_win_rate, 1)}</div></div>
|
||||
<div class="kpi"><div class="label">稳定性</div>
|
||||
<div class="value ${s.oos_stability != null && s.oos_stability < 0 ? 'loss' : ''}">${num(s.oos_stability, 2)}</div>
|
||||
<div class="note">均值/标准差,<1 表示结论不稳</div></div>
|
||||
<div class="kpi"><div class="label">最差回撤</div>
|
||||
<div class="value loss">${pct(s.oos_worst_drawdown)}</div>
|
||||
<div class="note">样本外最深</div></div>
|
||||
<div class="kpi"><div class="label">窗口数</div>
|
||||
<div class="value">${s.window_count}</div>
|
||||
<div class="note">${d.scheme} 滚动</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>样本内 vs 样本外</h2>
|
||||
<div id="c_wf" class="chart"></div>
|
||||
<div class="callout" style="margin-top:12px">
|
||||
若<b>样本内收益明显高于样本外</b>,说明阈值是在训练段「拟合」出来的,
|
||||
样本外无法复现 —— 这就是过拟合的直接证据。<br>
|
||||
<b>超额 = 策略 − 基准(沪深300)</b>。本策略的典型形态是
|
||||
<b>牛市跑输、熊市跑赢</b>:请结合当年的市场环境判读,不要只看均值。<br>
|
||||
<b>注意区间长度</b>:训练段 5 年、测试段 1 年,因此上图统一用<b>年化</b>口径。
|
||||
直接比两者的累计收益会得出「样本内远高于样本外」的错误印象。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>逐窗口明细</h2>
|
||||
<div class="tw"><table class="data">
|
||||
<thead>
|
||||
<tr><th colspan="6" class="l" style="text-align:center">训练段(5 年 · 校准参照分布)</th>
|
||||
<th colspan="7" class="l" style="text-align:center;border-left:2px solid #DBEAFE">测试段(1 年 · 冻结参数)</th></tr>
|
||||
<tr><th>窗口</th><th>训练区间</th><th>测试区间</th>
|
||||
<th>收益</th><th>CAGR</th><th>Sharpe</th>
|
||||
<th style="border-left:2px solid #DBEAFE">收益</th><th>年化</th><th>基准</th>
|
||||
<th>超额</th><th>最大回撤</th>
|
||||
<th>Sharpe</th><th>成交</th><th>冻结阈值</th></tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table></div>
|
||||
<div class="callout" style="margin-top:12px">
|
||||
<b>冻结阈值</b>是该窗口在训练段校准出、并在测试段强制沿用的买入分位 ——
|
||||
各窗口若差异很大,说明策略对参数不稳定。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>可复现性</h2>
|
||||
<div class="tw"><table class="data"><tbody>
|
||||
<tr><td class="l">策略</td><td class="l mono">${esc(d.strategy_id)} v${esc(d.strategy_version)}</td></tr>
|
||||
<tr><td class="l">配置指纹</td><td class="l mono">${esc(d.config_hash || '—')}</td></tr>
|
||||
<tr><td class="l">数据版本</td><td class="l mono">${esc(d.data_version || '—')}</td></tr>
|
||||
<tr><td class="l">代码版本</td><td class="l mono">${esc(d.code_version || '—')}</td></tr>
|
||||
</tbody></table></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 = `<div class="callout warn">未知页面:<span class="mono">${esc(path)}</span>
|
||||
<br><a href="#/">返回概览</a></div>`;
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<a href="#/" data-nav="home">概览</a>
|
||||
<a href="#/universes" data-nav="universes">股票池筛选</a>
|
||||
<a href="#/backtests" data-nav="backtests">回测</a>
|
||||
<a href="#/walkforwards" data-nav="walkforwards">样本外</a>
|
||||
<a href="#/archive" data-nav="archive">归档</a>
|
||||
</nav>
|
||||
<div class="topbar-right">
|
||||
|
||||
Reference in New Issue
Block a user