说明:本提交**不是本轮会话所做**,而是工作区里此前遗留的未提交改动。
为把历史分开,先单独提交它,再提交本轮会话的修改。
包含:
- 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 项通过。
160 lines
5.6 KiB
Python
160 lines
5.6 KiB
Python
"""统一数值格式化测试。
|
||
|
||
**背景**:`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"
|