功能: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:
2026-10-04 12:47:10 +08:00
parent fce725e13c
commit fb6608193b
21 changed files with 2579 additions and 125 deletions
+4 -4
View File
@@ -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)
+12 -6
View File
@@ -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:
+93 -30
View File
@@ -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)
+6 -4
View File
@@ -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"):
+155
View File
@@ -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
+5 -10
View File
@@ -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:
+20 -9
View File
@@ -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}%"
+5 -4
View File
@@ -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:
+8 -1
View File
@@ -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)
+2 -1
View File
@@ -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:
+28 -9
View File
@@ -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 与波动
+36 -4
View File
@@ -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 = []
+494
View File
@@ -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
View File
@@ -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,