功能: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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user