功能: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:
@@ -0,0 +1,250 @@
|
||||
"""TTM 股息率毛刺消除测试。
|
||||
|
||||
**背景**:A 股相邻两次除权的间隔经常不是 365 天。硬 365 天窗口于是在每年
|
||||
除权日附近制造两种日历假象:
|
||||
|
||||
- **重叠虚高**:间隔 < 365 时新旧分红同时在窗口内。实测招商银行 2015-07-03
|
||||
股息率 0.620 → 1.290(+108%),10 天后回落到 0.670。
|
||||
- **断档虚低**:间隔 > 365 时旧的已到期而新的未入场。实测中国神华
|
||||
2016-07-04:0.740 → 0.320(−57%)。
|
||||
|
||||
两者都会污染「历史分位」这一核心信号,且筛选器用的也是同一个数
|
||||
(直接决定选股),因此必须消除。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from hdiv.factor.dividend_yield import (
|
||||
build_dps_events,
|
||||
ttm_dps_at,
|
||||
ttm_dps_series,
|
||||
ttm_params,
|
||||
)
|
||||
|
||||
TTM = 365
|
||||
GRACE = 45
|
||||
|
||||
|
||||
def _events(pairs: list[tuple[str, float]]) -> pd.DataFrame:
|
||||
"""构造分红事件表:(除权日, 金额)。"""
|
||||
return pd.DataFrame({
|
||||
"ex_date": pd.to_datetime([d for d, _ in pairs]),
|
||||
"imp_ann_date": pd.to_datetime([d for d, _ in pairs]),
|
||||
"cash_div_tax": [a for _, a in pairs],
|
||||
})
|
||||
|
||||
|
||||
def _daily(start: str, end: str) -> pd.DatetimeIndex:
|
||||
return pd.date_range(start, end, freq="D")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 核心:两种毛刺都要消除
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_overlap_spike_is_removed() -> None:
|
||||
"""间隔 360 天:新分红入场时旧的不应再计入(消除 +100% 虚高)。"""
|
||||
d = _daily("2020-01-01", "2023-12-31")
|
||||
ev = _events([("2021-06-01", 1.0), ("2022-05-27", 1.2), ("2023-05-22", 1.4)])
|
||||
|
||||
raw = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
|
||||
smooth_spikes=False), index=d)
|
||||
sm = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
|
||||
smooth_spikes=True), index=d)
|
||||
|
||||
# 未平滑时:2022-05-27 当刻涨到 1.0+1.2=2.2,5 天后旧的到期回落
|
||||
assert raw.max() > 2.1, f"未平滑应出现重叠虚高,实际 max={raw.max()}"
|
||||
# 平滑后:不应出现两笔相加
|
||||
assert sm.max() <= 1.45, f"平滑后不应双算,实际 max={sm.max()}"
|
||||
# 且切换当天不跳变
|
||||
after = sm.loc[pd.Timestamp("2022-05-27"):].iloc[:5]
|
||||
assert after.max() / after.min() - 1 < 0.05, "接管当天不应有跳变"
|
||||
|
||||
|
||||
def test_gap_dip_is_filled() -> None:
|
||||
"""间隔 370 天:旧的到期后应继续计到新的入场(消除断档虚低)。"""
|
||||
d = _daily("2020-01-01", "2023-12-31")
|
||||
ev = _events([("2021-06-01", 1.0), ("2022-06-06", 1.2)]) # 间隔 370 天
|
||||
|
||||
raw = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
|
||||
smooth_spikes=False), index=d)
|
||||
sm = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
|
||||
smooth_spikes=True), index=d)
|
||||
|
||||
# 只看「第一笔到第二笔」这段(尾部无后继本就该归零,属正确行为)
|
||||
win = slice(pd.Timestamp("2021-06-01"), pd.Timestamp("2022-06-06"))
|
||||
raw_a, sm_a = raw.loc[win], sm.loc[win]
|
||||
# 未平滑:2022-06-01 旧的到期、新的还没来 → 归零 5 天
|
||||
assert raw_a.min() == 0.0, "未平滑应出现断档归零"
|
||||
# 平滑后:同区间不应归零
|
||||
assert sm_a.min() > 0.9, f"平滑后不应断档,实际 min={sm_a.min()}"
|
||||
|
||||
|
||||
def test_intra_year_multiple_payments_are_not_merged() -> None:
|
||||
"""年内多次分红(间隔 180 天)必须都保留 —— 否则会把中期分红误删。"""
|
||||
d = _daily("2021-01-01", "2023-12-31")
|
||||
ev = _events([
|
||||
("2022-06-01", 0.3), ("2022-11-28", 0.7),
|
||||
("2023-05-29", 0.3), ("2023-11-25", 0.7),
|
||||
])
|
||||
sm = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
|
||||
smooth_spikes=True), index=d)
|
||||
# 年中确实应同时含两笔(0.3 + 0.7 = 1.0)
|
||||
peak = sm.loc[pd.Timestamp("2023-06-01"):pd.Timestamp("2023-11-20")]
|
||||
assert peak.max() > 0.95, f"年内两笔分红应同时计入,实际 max={peak.max()}"
|
||||
|
||||
|
||||
def test_true_cessation_still_goes_to_zero() -> None:
|
||||
"""真停发必须如实归零,不能因为平滑就永远挂着旧分红。"""
|
||||
d = _daily("2020-01-01", "2025-12-31")
|
||||
ev = _events([("2021-06-01", 1.0), ("2023-06-01", 1.0)]) # 中间空了两年
|
||||
sm = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
|
||||
smooth_spikes=True), index=d)
|
||||
# 2021 那笔在 2022-06-01 + 45 天宽限后必须归零
|
||||
gap = sm.loc[pd.Timestamp("2022-09-01"):pd.Timestamp("2023-05-31")]
|
||||
assert gap.max() == 0.0, f"停发期间应归零,实际 max={gap.max()}"
|
||||
|
||||
|
||||
def test_smoothing_can_be_disabled() -> None:
|
||||
"""smooth_spikes=False 应精确复现旧的「硬窗口 + 归零才兜底」行为。"""
|
||||
d = _daily("2020-01-01", "2023-12-31")
|
||||
ev = _events([("2021-06-01", 1.0), ("2022-05-27", 1.2)])
|
||||
off = ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, smooth_spikes=False)
|
||||
# 旧行为:重叠期双算
|
||||
assert off.max() >= 2.1
|
||||
on = ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True)
|
||||
assert on.max() < off.max()
|
||||
|
||||
|
||||
def test_build_dps_events_matches_series_expectations() -> None:
|
||||
"""事件表经 build_dps_events 规范化后仍可用。"""
|
||||
raw = pd.DataFrame({
|
||||
"symbol": ["X"] * 3,
|
||||
"ex_date": ["2021-06-01", "2022-05-27", "2023-05-22"],
|
||||
"imp_ann_date": ["2021-05-25", "2022-05-20", "2023-05-15"],
|
||||
"cash_div_tax": [1.0, 1.2, 1.4],
|
||||
})
|
||||
e = build_dps_events(raw)["X"]
|
||||
d = _daily("2021-01-01", "2023-12-31")
|
||||
out = ttm_dps_series(d, e, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True)
|
||||
assert len(out) == len(d) and out.max() <= 1.45
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 口径统一:筛选 / 画像 / 回测 / Web 必须用同一份参数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ttm_params_is_single_source_of_truth() -> None:
|
||||
"""四处调用点必须都从 ttm_params() 取参,不得各自硬编码。"""
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[1] / "src" / "hdiv"
|
||||
# 回测引擎曾硬编码 ttm_days=365, grace_days=45
|
||||
eng = (root / "backtest" / "engine.py").read_text(encoding="utf-8")
|
||||
assert "ttm_days=365, grace_days=45" not in eng, "引擎仍在硬编码 TTM 参数"
|
||||
assert "ttm_params()" in eng
|
||||
# walk-forward 与 web 曾用函数默认值
|
||||
for rel in ("backtest/walk_forward.py", "web/analysis.py"):
|
||||
text = (root / rel).read_text(encoding="utf-8")
|
||||
assert "ttm_params()" in text, f"{rel} 未使用统一参数"
|
||||
# 因子层自身
|
||||
f = inspect.getsource(__import__(
|
||||
"hdiv.factor.dividend_yield", fromlist=["x"]))
|
||||
assert "def ttm_params" in f
|
||||
|
||||
|
||||
def test_ttm_dps_at_matches_series_right_endpoint() -> None:
|
||||
"""单点求值(筛选器用)必须与序列右端点一致。"""
|
||||
d = _daily("2020-01-01", "2022-12-31")
|
||||
ev = _events([("2021-06-01", 1.0), ("2022-05-27", 1.2)])
|
||||
asof = pd.Timestamp("2021-12-31").date()
|
||||
one = ttm_dps_at(asof, ev)
|
||||
ser = ttm_dps_series(pd.DatetimeIndex([pd.Timestamp(asof)]), ev,
|
||||
ttm_days=TTM, grace_days=GRACE, smooth_spikes=True)
|
||||
assert one is not None
|
||||
assert abs(one - float(ser[0])) < 1e-9
|
||||
|
||||
|
||||
def test_ttm_params_reads_config() -> None:
|
||||
from hdiv.core.config import load_config
|
||||
|
||||
w, g, sm = ttm_params()
|
||||
c = load_config("profile").ttm_dividend
|
||||
assert (w, g, sm) == (c.window_days, c.grace_days, c.smooth_spikes)
|
||||
|
||||
|
||||
def test_config_exposes_smooth_spikes_switch() -> None:
|
||||
"""开关必须暴露在 YAML 里,用户可自行关闭。"""
|
||||
from hdiv.core.config import load_config
|
||||
|
||||
assert hasattr(load_config("profile").ttm_dividend, "smooth_spikes")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("grace", [0, 10, 45, 90])
|
||||
def test_smoothing_never_produces_negative_or_nan(grace: int) -> None:
|
||||
d = _daily("2020-01-01", "2023-12-31")
|
||||
ev = _events([("2021-06-01", 1.0), ("2022-06-06", 1.2), ("2023-06-01", 1.4)])
|
||||
out = ttm_dps_series(d, ev, ttm_days=TTM, grace_days=grace, smooth_spikes=True)
|
||||
assert np.isfinite(out).all()
|
||||
assert (out >= 0).all()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 透传一致性:包装函数必须接收并转发所有参数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_wrapper_signature_forwards_all_params() -> None:
|
||||
"""回归:`dividend_yield_series` 是 `ttm_dps_series` 的包装。
|
||||
|
||||
曾经只给**调用方**加了 `smooth_spikes`,却忘了在包装函数签名里声明,
|
||||
于是 walk-forward 直接 `TypeError` 崩在第一个窗口 —— 而测试全绿,
|
||||
因为测试没走 walk-forward 那条路径。
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from hdiv.factor import dividend_yield as dy
|
||||
|
||||
inner = set(inspect.signature(dy.ttm_dps_series).parameters) - {"dates", "events"}
|
||||
outer = set(inspect.signature(dy.dividend_yield_series).parameters) - {"close", "events"}
|
||||
missing = inner - outer
|
||||
assert not missing, (
|
||||
f"dividend_yield_series 未转发参数 {sorted(missing)};"
|
||||
"调用方传了就会 TypeError"
|
||||
)
|
||||
# 且必须真的往下传
|
||||
src = inspect.getsource(dy.dividend_yield_series)
|
||||
for name in inner:
|
||||
assert f"{name}={name}" in src, f"包装函数未把 {name} 传给 ttm_dps_series"
|
||||
|
||||
|
||||
def test_wrapper_accepts_smooth_spikes() -> None:
|
||||
"""直接以关键字调用,确保签名真的可用(不只是字符串包含)。"""
|
||||
from hdiv.factor.dividend_yield import dividend_yield_series
|
||||
|
||||
d = _daily("2021-01-01", "2022-12-31")
|
||||
close = pd.Series(10.0, index=d)
|
||||
ev = _events([("2021-06-01", 1.0), ("2022-05-27", 1.2)])
|
||||
for flag in (True, False):
|
||||
out = dividend_yield_series(close, ev, ttm_days=365, grace_days=45,
|
||||
smooth_spikes=flag)
|
||||
assert not out.empty
|
||||
assert "dividend_yield" in out.columns
|
||||
|
||||
|
||||
def test_all_ttm_callers_pass_the_unified_params() -> None:
|
||||
"""五处调用点都必须显式传 smooth_spikes,不能靠默认值(否则与配置脱钩)。"""
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[1] / "src" / "hdiv"
|
||||
for rel in ("profile/builder.py", "backtest/engine.py",
|
||||
"backtest/walk_forward.py", "web/analysis.py"):
|
||||
src = (root / rel).read_text(encoding="utf-8")
|
||||
assert "smooth_spikes" in src, f"{rel} 未传 smooth_spikes(会与配置脱钩)"
|
||||
@@ -0,0 +1,159 @@
|
||||
"""统一数值格式化测试。
|
||||
|
||||
**背景**:`config/report.yml: layout.decimals` 长期是摆设 —— `ratio` 与
|
||||
`money` 从未被读取,各报告模块各自硬编码小数位(7 处),
|
||||
前端也把百分比写死 2 位。结果是「改了配置不生效」。
|
||||
本测试钉住「配置必须真的驱动输出」。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from hdiv.report.format import NumFmt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 语义:百分比小数位 = ratio - 2
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ratio,expect",
|
||||
[(2, "6%"), (4, "6.17%"), (6, "6.1715%"), (8, "6.171491%")],
|
||||
)
|
||||
def test_percent_decimals_derive_from_ratio(ratio: int, expect: str) -> None:
|
||||
"""比率保留 ratio 位后乘 100,恰好少两位 —— 这是与用户确认的语义。"""
|
||||
f = NumFmt(ratio=ratio)
|
||||
assert f.pct(0.06171491) == expect
|
||||
assert f.percent == max(0, ratio - 2)
|
||||
|
||||
|
||||
def test_ratio_str_keeps_ratio_decimals() -> None:
|
||||
assert NumFmt(ratio=4).ratio_str(0.06171491) == "0.0617"
|
||||
assert NumFmt(ratio=6).ratio_str(0.06171491) == "0.061715"
|
||||
|
||||
|
||||
def test_ratio_below_two_does_not_go_negative() -> None:
|
||||
"""ratio=1 时百分比不能出现负小数位(会抛异常)。"""
|
||||
f = NumFmt(ratio=1)
|
||||
assert f.percent == 0
|
||||
assert f.pct(0.0617) == "6%"
|
||||
|
||||
|
||||
def test_missing_values_render_as_dash() -> None:
|
||||
f = NumFmt()
|
||||
for v in (None, float("nan")):
|
||||
assert f.pct(v) == "—"
|
||||
assert f.ratio_str(v) == "—"
|
||||
assert f.yi(v) == "—"
|
||||
|
||||
|
||||
def test_non_numeric_falls_back_to_str() -> None:
|
||||
"""传进来已格式化的字符串不应崩,也不应二次加工。"""
|
||||
f = NumFmt()
|
||||
assert f.pct("已格式化") == "已格式化"
|
||||
|
||||
|
||||
def test_pp_and_plus_signs() -> None:
|
||||
f = NumFmt(ratio=4)
|
||||
assert f.pct_pp(0.1211) == "+12.11pp"
|
||||
assert f.pct_pp(-0.0324) == "-3.24pp"
|
||||
assert f.pct(0.0401, plus=True) == "+4.01%"
|
||||
|
||||
|
||||
def test_by_unit_dispatch() -> None:
|
||||
f = NumFmt(ratio=4, money=2)
|
||||
assert f.by_unit(0.0617, "pct") == "6.17%"
|
||||
assert f.by_unit(123456789.0, "money") == "1.23亿"
|
||||
assert f.by_unit(16.72, "years") == "17"
|
||||
assert f.by_unit(0.0617, "ratio") == "0.0617"
|
||||
assert f.by_unit(2838, "int") == "2,838"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 配置必须真的驱动输出
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_from_config_reads_report_yml() -> None:
|
||||
from hdiv.core.config import load_config
|
||||
|
||||
cfg = load_config("report")
|
||||
f = NumFmt.from_config(cfg)
|
||||
d = cfg.layout.decimals
|
||||
assert (f.ratio, f.money, f.price) == (d.ratio, d.money, d.price)
|
||||
assert f.percent == max(0, d.ratio - 2)
|
||||
|
||||
|
||||
def test_from_config_survives_broken_config() -> None:
|
||||
"""配置不可用时回落默认值,而不是让报告生成崩掉。"""
|
||||
|
||||
class Boom:
|
||||
@property
|
||||
def layout(self):
|
||||
raise RuntimeError("配置坏了")
|
||||
|
||||
f = NumFmt.from_config(Boom())
|
||||
assert f.ratio == 4
|
||||
|
||||
|
||||
def test_renderer_uses_config_not_hardcoded_defaults() -> None:
|
||||
"""回归:渲染器的 fmt_pct 曾默认 2 位且从不读 decimals.ratio。"""
|
||||
import inspect
|
||||
|
||||
from hdiv.report.renderer import Renderer
|
||||
|
||||
src = inspect.getsource(Renderer.fmt_pct)
|
||||
assert "NumFmt" in src or "self.fmt" in src, "fmt_pct 应走统一格式化器"
|
||||
assert ":.2f}%" not in src, "fmt_pct 不应再硬编码 2 位"
|
||||
|
||||
|
||||
def test_no_hardcoded_percent_format_in_report_modules() -> None:
|
||||
"""五个报告模块都不应再有硬编码的百分比精度。"""
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[1] / "src" / "hdiv" / "report"
|
||||
offenders = []
|
||||
for name in ("profile_report", "backtest_report", "universe_report",
|
||||
"sensitivity_report", "walkforward_report"):
|
||||
text = (root / f"{name}.py").read_text(encoding="utf-8")
|
||||
for i, line in enumerate(text.splitlines(), 1):
|
||||
if ":.2f}%" in line or ":.2f}pp" in line:
|
||||
offenders.append(f"{name}:{i}")
|
||||
assert not offenders, f"仍硬编码百分比精度:{offenders}"
|
||||
|
||||
|
||||
def test_frontend_precision_endpoint_exists() -> None:
|
||||
"""前端也必须由配置驱动(曾把百分比写死 2 位)。"""
|
||||
from hdiv.web.server import ROUTES
|
||||
|
||||
assert any(pat.match("/api/config/display") for _m, pat, _f in ROUTES), \
|
||||
"缺少 /api/config/display,前端无法获知配置的显示精度"
|
||||
|
||||
|
||||
def test_no_hardcoded_percent_anywhere_in_src() -> None:
|
||||
"""整个 src/ 都不应再有硬编码的百分比精度(含接口层与 CLI 输出)。
|
||||
|
||||
曾散落 20 余处,其中「成交理由里的股息率」是用户直接看到的那种。
|
||||
唯一允许的例外是 format.py 自身的文档说明。
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[1] / "src" / "hdiv"
|
||||
offenders = []
|
||||
for f in root.rglob("*.py"):
|
||||
if f.name == "format.py":
|
||||
continue
|
||||
for i, line in enumerate(f.read_text(encoding="utf-8").splitlines(), 1):
|
||||
if ":.2f}%" in line or "100:.2f" in line:
|
||||
offenders.append(f"{f.relative_to(root)}:{i}")
|
||||
assert not offenders, f"仍硬编码百分比精度:{offenders}"
|
||||
|
||||
|
||||
def test_frontend_uses_config_precision() -> None:
|
||||
from pathlib import Path
|
||||
|
||||
js = (Path(__file__).resolve().parents[1] / "web" / "app.js").read_text(encoding="utf-8")
|
||||
assert "FMT" in js and "config/display" in js, "前端未接入配置驱动精度"
|
||||
assert "FMT.percent" in js, "百分比应使用配置的 percent"
|
||||
@@ -560,3 +560,62 @@ def test_dividend_records_include_base_share() -> None:
|
||||
|
||||
src = inspect.getsource(Repo.dividend_records)
|
||||
assert "base_share" in src, "dividend_records 必须选出 base_share"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 重跑覆盖同一条记录
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_universe_run_id_is_deterministic() -> None:
|
||||
"""回归:run_id 不得含时间戳,否则同参数重跑会不断累积重复记录。
|
||||
|
||||
早期实现把 datetime.now() 编进指纹,同一 asof 最多累积了 11 条内容相同的记录。
|
||||
现在的语义是「同一份配置 + 同一时点 → 同一个 run_id → 重跑原地覆盖」。
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from hdiv.universe import selector
|
||||
|
||||
src = inspect.getsource(selector.UniverseSelector)
|
||||
i = src.find("run_id = stable_id(")
|
||||
assert i != -1, "未找到 run_id 生成处"
|
||||
# 取到该语句结束的分号行(不能用第一个 ')',那会截断在 config_hash(self.config) 里)
|
||||
end = src.find("\n )", i)
|
||||
assert end != -1, "未找到 run_id 语句结尾"
|
||||
block = src[i:end]
|
||||
assert "datetime.now" not in block, f"run_id 指纹仍含时间戳:{block}"
|
||||
for must in ("config_hash", "effective", "self.config.name"):
|
||||
assert must in block, f"run_id 指纹缺少 {must}:{block}"
|
||||
|
||||
|
||||
@pytest.mark.db
|
||||
def test_universe_rerun_overwrites_same_record() -> None:
|
||||
"""同参数重跑不新增记录,且成员行数等于候选数(无重复堆积)。"""
|
||||
from hdiv.core.config import load_config
|
||||
from hdiv.data import db
|
||||
from hdiv.data.sync.base import stable_id
|
||||
|
||||
db.load_dotenv_once()
|
||||
cfg = load_config("datasource")
|
||||
df = db.read_sql(
|
||||
"SELECT r.run_id, r.candidate_count, r.asof_date, r.config_hash, r.name, "
|
||||
" COUNT(m.id) AS member_rows "
|
||||
"FROM hd_universe_run r LEFT JOIN hd_universe_member m ON m.run_id = r.run_id "
|
||||
"GROUP BY r.run_id HAVING member_rows > 0 "
|
||||
"ORDER BY r.created_at DESC LIMIT 5",
|
||||
cfg=cfg,
|
||||
)
|
||||
if df.empty:
|
||||
pytest.skip("没有筛选记录")
|
||||
checked = 0
|
||||
for _, r in df.iterrows():
|
||||
expect = stable_id("universe", r["name"], str(r["asof_date"]), r["config_hash"])
|
||||
if r["run_id"] != expect:
|
||||
continue # 确定化之前的历史记录,跳过
|
||||
checked += 1
|
||||
assert int(r["member_rows"]) == int(r["candidate_count"]), (
|
||||
f"run_id={r['run_id'][:10]} 成员行数 {r['member_rows']} "
|
||||
f"应等于候选数 {r['candidate_count']}(出现重复堆积)"
|
||||
)
|
||||
assert checked > 0, "未找到确定化之后生成的筛选记录,无法验证"
|
||||
|
||||
+386
-12
@@ -26,10 +26,12 @@ from hdiv.web.server import ROUTES
|
||||
#: 路径样例用于匹配路由正则;改前端时需同步此表。
|
||||
FRONTEND_CALLS: list[tuple[str, str]] = [
|
||||
("GET", "/api/health"),
|
||||
("GET", "/api/config/display"),
|
||||
("GET", "/api/summary"),
|
||||
("GET", "/api/universes"),
|
||||
("GET", "/api/universes/abc123"),
|
||||
("GET", "/api/universes/abc123/members"),
|
||||
("GET", "/api/universes/abc123/members/600519.SH"),
|
||||
("GET", "/api/universes/abc123/backtests"),
|
||||
("PATCH", "/api/universes/abc123"),
|
||||
("GET", "/api/stocks/600519.SH"),
|
||||
@@ -38,8 +40,18 @@ FRONTEND_CALLS: list[tuple[str, str]] = [
|
||||
("GET", "/api/backtests/abc123/metrics"),
|
||||
("GET", "/api/backtests/abc123/equity"),
|
||||
("GET", "/api/backtests/abc123/trades"),
|
||||
# 净值曲线右轴可叠加的基准指数
|
||||
("GET", "/api/indices"),
|
||||
("GET", "/api/backtests/abc123/signals"),
|
||||
("PATCH", "/api/backtests/abc123"),
|
||||
# 回测内分析:任意日持仓 + 个股买卖点
|
||||
("GET", "/api/backtests/abc123/portfolio"),
|
||||
("GET", "/api/backtests/abc123/position-dates"),
|
||||
("GET", "/api/backtests/abc123/stocks"),
|
||||
("GET", "/api/backtests/abc123/stocks/600519.SH"),
|
||||
# Walk-forward 样本外
|
||||
("GET", "/api/walkforwards"),
|
||||
("GET", "/api/walkforwards/abc123"),
|
||||
]
|
||||
|
||||
|
||||
@@ -87,18 +99,25 @@ def test_members_endpoint_defaults_to_selected() -> None:
|
||||
"未指定 passed 时应视为 1(仅入选)"
|
||||
|
||||
|
||||
def test_every_route_has_a_frontend_or_cli_consumer() -> None:
|
||||
"""反向检查:后端不应暴露无人使用的接口(便于发现遗留死接口)。"""
|
||||
known_paths = {p for _m, p in FRONTEND_CALLS}
|
||||
orphans = []
|
||||
for _m, pat, fn in ROUTES:
|
||||
# 用契约表中的样例路径试探该路由是否有消费者
|
||||
sample = pat.pattern.replace("^", "").replace("$", "")
|
||||
sample = re.sub(r"\(\?P<\w+>\[[^\]]+\]\+?\)", "abc123", sample)
|
||||
if not any(pat.match(p) for p in known_paths) and "/stocks/" not in sample:
|
||||
orphans.append((_m, pat.pattern))
|
||||
# /stocks/ 由画像页使用;/universes/{id}/members/{sym} 为可选下钻
|
||||
assert len(orphans) <= 2, f"疑似无人使用的接口:{orphans}"
|
||||
def test_every_route_is_covered_by_the_contract() -> None:
|
||||
"""反向检查:每条后端路由都必须出现在契约表里。
|
||||
|
||||
这样契约表就是「前后端接口清单」的唯一事实来源:
|
||||
新增接口忘了登记会被发现,删接口忘了清契约也会被发现。
|
||||
|
||||
早期版本给两个「可选下钻」接口开了后门(阈值 <= 2),
|
||||
结果新增的三个接口漏登记却被放行 —— 所以现在零容忍。
|
||||
"""
|
||||
known = {p for _m, p in FRONTEND_CALLS}
|
||||
uncovered = []
|
||||
for method, pat, _fn in ROUTES:
|
||||
if not any(m == method and pat.match(p) for m, p in FRONTEND_CALLS) and \
|
||||
not any(pat.match(p) for p in known):
|
||||
uncovered.append((method, pat.pattern))
|
||||
assert not uncovered, (
|
||||
f"以下路由未登记在 FRONTEND_CALLS 中:{uncovered}\n"
|
||||
"新增接口时请同步更新契约表,否则前端改动无法被发现。"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -326,6 +345,44 @@ def test_all_api_payloads_are_json_serializable() -> None:
|
||||
json.dumps(p, ensure_ascii=False, cls=_Encoder) # 不应抛异常
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_equity_index_overlay_is_date_aligned() -> None:
|
||||
"""净值曲线右轴叠加的指数必须与日期**逐点对齐**。
|
||||
|
||||
类目轴上每个类目一个点:指数序列只要少一天,
|
||||
整条指数线就会相对净值曲线整体错位,画出错误的对比。
|
||||
"""
|
||||
from hdiv.core.errors import HdivError
|
||||
from hdiv.web import service
|
||||
|
||||
rid = _sample_backtest_run()
|
||||
if not rid:
|
||||
pytest.skip("没有可用的回测")
|
||||
items = service.list_indices()
|
||||
if not items:
|
||||
pytest.skip("hd_index_daily 没有指数行情")
|
||||
assert [x["code"] for x in items if x["is_default"]] == [service.DEFAULT_INDEX_CODE], \
|
||||
"应恰好把默认指数(沪深300)标成 default"
|
||||
|
||||
base = service.get_backtest_equity(rid)
|
||||
if not base["dates"]:
|
||||
pytest.skip("该回测没有净值曲线")
|
||||
assert base["index"] is None, "不传 index 时不应凭空叠加指数"
|
||||
|
||||
for it in items:
|
||||
ix = service.get_backtest_equity(rid, index_code=it["code"])["index"]
|
||||
assert ix["code"] == it["code"] and ix["name"]
|
||||
assert len(ix["close"]) == len(base["dates"]), f"{it['code']} 未与净值曲线对齐"
|
||||
vals = [v for v in ix["close"] if v is not None]
|
||||
# 叠加的是指数点位,不是净值;量级错了说明取错了列
|
||||
assert not vals or min(vals) > 10, f"{it['code']} 取值不像指数点位:{vals[:3]}"
|
||||
json.dumps(ix, allow_nan=False)
|
||||
|
||||
# 库里没有的指数应当明确报错,而不是画一条空线
|
||||
with pytest.raises(HdivError):
|
||||
service.get_backtest_equity(rid, index_code="999999.XX")
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_reason_text_is_human_readable() -> None:
|
||||
"""成交理由必须渲染成人话,而不是丢一坨 JSON 给前端。"""
|
||||
@@ -573,3 +630,320 @@ def test_site_build_does_not_clobber_spa() -> None:
|
||||
site.sync_frontend(verbose=False)
|
||||
html = (project_root() / "output" / "index.html").read_text(encoding="utf-8")
|
||||
assert "app/app.js" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 回测内分析:任意日持仓 + 个股买卖点
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sample_backtest_run() -> str | None:
|
||||
from hdiv.core.config import load_config
|
||||
from hdiv.data import db
|
||||
|
||||
df = db.read_sql(
|
||||
"SELECT r.run_id FROM hd_backtest_run r "
|
||||
"JOIN hd_backtest_position p ON p.run_id = r.run_id "
|
||||
"WHERE r.mode = 'single' "
|
||||
"GROUP BY r.run_id ORDER BY COUNT(*) DESC LIMIT 1",
|
||||
cfg=load_config("datasource"),
|
||||
)
|
||||
return None if df.empty else str(df["run_id"].iloc[0])
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_position_dates_is_compact_by_default() -> None:
|
||||
"""默认只返回日期字符串:带全字段会让响应从约 30KB 涨到 460KB。"""
|
||||
from hdiv.web import analysis
|
||||
|
||||
rid = _sample_backtest_run()
|
||||
if not rid:
|
||||
pytest.skip("没有带持仓的回测")
|
||||
d = analysis.position_dates(rid)
|
||||
assert "dates" in d and d["dates"], "应返回日期数组"
|
||||
assert "items" not in d, "默认不应返回逐日全字段明细"
|
||||
assert d["count"] == len(d["dates"])
|
||||
assert d["dates"] == sorted(d["dates"]), "日期应升序"
|
||||
# 紧凑形式必须显著更小
|
||||
import json
|
||||
|
||||
compact = len(json.dumps(d, ensure_ascii=False).encode())
|
||||
full = len(json.dumps(analysis.position_dates(rid, detail=True),
|
||||
ensure_ascii=False).encode())
|
||||
assert compact < full / 3, f"紧凑形式应远小于明细({compact} vs {full})"
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_portfolio_falls_back_to_previous_trading_day() -> None:
|
||||
"""非交易日应回退到之前最近的有快照交易日,并如实标注。"""
|
||||
from hdiv.web import analysis
|
||||
from hdiv.core.errors import HdivError
|
||||
|
||||
rid = _sample_backtest_run()
|
||||
if not rid:
|
||||
pytest.skip("没有带持仓的回测")
|
||||
dates = analysis.position_dates(rid)["dates"]
|
||||
d = analysis.portfolio_on_date(rid, dates[-1])
|
||||
assert d["date"] == dates[-1] and not d["adjusted"]
|
||||
|
||||
# 区间内但非交易日(用周末构造)
|
||||
import datetime as _dt
|
||||
|
||||
mid = _dt.date.fromisoformat(dates[len(dates) // 2])
|
||||
weekend = mid + _dt.timedelta(days=(5 - mid.weekday()) % 7 + 1)
|
||||
d2 = analysis.portfolio_on_date(rid, weekend.isoformat())
|
||||
assert d2["date"] <= weekend.isoformat()
|
||||
assert d2["adjusted"] is True, "非交易日应标注已回退"
|
||||
|
||||
# 早于首个快照应给出可理解错误
|
||||
with pytest.raises(HdivError, match="早于该回测的首个快照"):
|
||||
analysis.portfolio_on_date(rid, "1990-01-01")
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_portfolio_summary_is_internally_consistent() -> None:
|
||||
"""持仓汇总必须自洽:市值合计 = 逐股之和;权重合计 ≈ 仓位占比。"""
|
||||
from hdiv.web import analysis
|
||||
|
||||
rid = _sample_backtest_run()
|
||||
if not rid:
|
||||
pytest.skip("没有带持仓的回测")
|
||||
dates = analysis.position_dates(rid)["dates"]
|
||||
# 找一个有持仓的交易日
|
||||
for day in reversed(dates):
|
||||
d = analysis.portfolio_on_date(rid, day)
|
||||
if d["positions"]:
|
||||
break
|
||||
else:
|
||||
pytest.skip("没有非空持仓日")
|
||||
|
||||
s = sum(p["market_value"] or 0 for p in d["positions"])
|
||||
assert abs(s - d["summary"]["market_value"]) < 1.0
|
||||
assert d["summary"]["count"] == len(d["positions"])
|
||||
w = sum(p["weight"] or 0 for p in d["positions"])
|
||||
tv = d["equity"]["total_value"] or 0
|
||||
if tv:
|
||||
assert abs(w - d["summary"]["market_value"] / tv) < 0.02, \
|
||||
f"权重合计 {w:.4f} 应约等于仓位占比 {d['summary']['market_value']/tv:.4f}"
|
||||
# 每只股票都应带名称(JOIN stock)
|
||||
assert all(p["symbol"] for p in d["positions"])
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_stock_detail_series_and_trades() -> None:
|
||||
"""个股买卖点:序列长度一致、买卖点带完整成交信息。"""
|
||||
from hdiv.web import analysis
|
||||
|
||||
rid = _sample_backtest_run()
|
||||
if not rid:
|
||||
pytest.skip("没有带持仓的回测")
|
||||
stocks = analysis.run_stocks(rid)
|
||||
if not stocks:
|
||||
pytest.skip("该回测没有持仓股票")
|
||||
sym = stocks[0]["symbol"]
|
||||
|
||||
d = analysis.stock_detail(rid, sym)
|
||||
n = len(d["dates"])
|
||||
assert n > 0
|
||||
for k, v in d["series"].items():
|
||||
assert len(v) == n, f"序列 {k} 长度与日期不一致({len(v)} vs {n})"
|
||||
assert "close" in d["series"]
|
||||
|
||||
# 股息率必须在合理量级内(单位错误会让它变成 0 或几百)
|
||||
dv = [x for x in d["series"]["dv_yield"] if x is not None]
|
||||
if dv:
|
||||
assert max(dv) < 1.0, f"股息率不应超过 100%:{max(dv)}"
|
||||
assert min(dv) >= 0.0, "股息率不应为负"
|
||||
|
||||
for t in d["trades"]:
|
||||
assert t["side"] in {"BUY", "SELL"}
|
||||
assert t["price"] and t["price"] > 0
|
||||
assert t["quantity"] and t["quantity"] > 0
|
||||
assert t["amount"] and t["amount"] > 0
|
||||
assert t["reason_text"] and t["reason_text"] != ""
|
||||
assert d["stats"]["trade_count"] == len(d["trades"])
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_stock_detail_with_sell_trades_does_not_500() -> None:
|
||||
"""回归:有卖出的个股必须能打开。
|
||||
|
||||
卖出成交的 ``holding_days`` 在库里是 NaN 而**不是** None,
|
||||
老代码 ``int(r["holding_days"]) if ... is not None else None`` 会抛
|
||||
``ValueError: cannot convert float NaN to integer``,
|
||||
让个股详情接口 500 —— 22/35 只有卖出的个股整页打不开。
|
||||
"""
|
||||
from hdiv.web import analysis
|
||||
|
||||
rid = _sample_backtest_run()
|
||||
if not rid:
|
||||
pytest.skip("没有带持仓的回测")
|
||||
stocks = analysis.run_stocks(rid)
|
||||
sells = [s for s in stocks if (s.get("sell_count") or 0) > 0]
|
||||
if not sells:
|
||||
pytest.skip("该回测没有卖出成交")
|
||||
sym = sells[0]["symbol"]
|
||||
|
||||
d = analysis.stock_detail(rid, sym) # 老代码在这一行 500
|
||||
assert any(t["side"] == "SELL" for t in d["trades"]), "应至少有一笔卖出"
|
||||
for t in d["trades"]:
|
||||
hd = t["holding_days"]
|
||||
assert hd is None or isinstance(hd, int), f"holding_days 应为整数或 None:{hd!r}"
|
||||
assert hd is None or hd >= 0
|
||||
# NaN 会以非法 JSON 的形式漏到前端,这里一并卡住
|
||||
json.dumps(d, allow_nan=False)
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_stock_detail_respects_series_selection() -> None:
|
||||
"""勾选哪些指标就只算哪些(不为没勾的做无谓计算)。"""
|
||||
from hdiv.web import analysis
|
||||
from hdiv.core.errors import HdivError
|
||||
|
||||
rid = _sample_backtest_run()
|
||||
if not rid:
|
||||
pytest.skip("没有带持仓的回测")
|
||||
sym = analysis.run_stocks(rid)[0]["symbol"]
|
||||
d = analysis.stock_detail(rid, sym, series=["close", "roe"])
|
||||
assert set(d["series"]) == {"close", "roe"}
|
||||
assert "pe_ttm" not in d["series"]
|
||||
# 无效指标应报错而不是静默忽略
|
||||
with pytest.raises(HdivError):
|
||||
analysis.stock_detail(rid, sym, series=["不存在的指标"])
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_roe_series_is_stepwise_not_interpolated() -> None:
|
||||
"""ROE 必须按公告日对齐成阶梯(插值会造出当时不存在的值)。"""
|
||||
from hdiv.web import analysis
|
||||
|
||||
rid = _sample_backtest_run()
|
||||
if not rid:
|
||||
pytest.skip("没有带持仓的回测")
|
||||
sym = analysis.run_stocks(rid)[0]["symbol"]
|
||||
d = analysis.stock_detail(rid, sym, series=["roe"])
|
||||
vals = [x for x in d["series"]["roe"] if x is not None]
|
||||
if len(vals) < 50:
|
||||
pytest.skip("ROE 样本不足")
|
||||
# 阶梯序列的不同取值数应远少于样本数(季度更新,约 4 次/年)
|
||||
distinct = len(set(round(v, 6) for v in vals))
|
||||
assert distinct < len(vals) / 5, \
|
||||
f"ROE 取值数 {distinct} 相对样本 {len(vals)} 过多,疑似插值而非阶梯"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Walk-forward 前端可见性
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_walkforward_list_and_detail() -> None:
|
||||
"""Walk-forward 记录必须在接口层可见(此前完全没有入口)。"""
|
||||
from hdiv.web import service
|
||||
|
||||
items = service.list_walkforwards()
|
||||
if not items:
|
||||
pytest.skip("没有 walk-forward 记录")
|
||||
w = items[0]
|
||||
assert w["wf_id"] and w["window_count"] > 0
|
||||
assert w["title"], "应有可读标题"
|
||||
assert w["strategy"]["conditions"], "应带策略条件说明"
|
||||
assert "oos" in w and w["oos"].get("window_count") == w["window_count"]
|
||||
|
||||
d = service.get_walkforward(w["wf_id"])
|
||||
assert d is not None
|
||||
assert len(d["windows"]) == w["window_count"]
|
||||
for win in d["windows"]:
|
||||
# 每个窗口都必须有训练段与测试段
|
||||
assert win["train_start"] and win["test_start"]
|
||||
assert win["train_run_id"] and win["test_run_id"]
|
||||
assert "in_sample" in win and "out_of_sample" in win
|
||||
s = d["summary"]
|
||||
assert len(s["oos_returns"]) == w["window_count"]
|
||||
assert s["oos_mean"] is not None
|
||||
assert 0.0 <= s["oos_win_rate"] <= 1.0
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_walkforward_summary_is_consistent() -> None:
|
||||
"""汇总必须与逐窗口数据自洽(曾靠 metric 行数反推导致胜率算错)。"""
|
||||
from hdiv.web import service
|
||||
|
||||
items = service.list_walkforwards()
|
||||
if not items:
|
||||
pytest.skip("没有 walk-forward 记录")
|
||||
for w in items[:3]:
|
||||
d = service.get_walkforward(w["wf_id"])
|
||||
rets = [x["out_of_sample"].get("total_return") for x in d["windows"]]
|
||||
rets = [x for x in rets if x is not None]
|
||||
if not rets:
|
||||
continue
|
||||
assert abs(d["summary"]["oos_mean"] - sum(rets) / len(rets)) < 1e-9
|
||||
expect_win = sum(1 for x in rets if x > 0) / len(rets)
|
||||
assert abs(d["summary"]["oos_win_rate"] - expect_win) < 1e-9
|
||||
# 列表页的汇总应与详情页一致
|
||||
assert abs((w["oos"]["mean_return"] or 0) - d["summary"]["oos_mean"]) < 1e-9
|
||||
|
||||
|
||||
def test_walkforward_frontend_page_exists() -> None:
|
||||
"""前端必须有 walk-forward 页与导航入口。"""
|
||||
js = (project_root() / "web" / "app.js").read_text(encoding="utf-8")
|
||||
html = (project_root() / "web" / "index.html").read_text(encoding="utf-8")
|
||||
assert "viewWalkforwards" in js and "viewWalkforwardDetail" in js
|
||||
assert "#/walkforwards" in html, "导航缺「样本外」入口"
|
||||
assert "mountWalkforwardDetail" in js, "详情页应挂载对比图"
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_walkforward_exposes_benchmark_and_excess() -> None:
|
||||
"""回归:基准指标存为 benchmark_<code>,曾被 benchmark_code='' 过滤掉,
|
||||
导致页面上看不到最重要的「超额收益」。"""
|
||||
from hdiv.web import service
|
||||
|
||||
items = service.list_walkforwards()
|
||||
if not items:
|
||||
pytest.skip("没有 walk-forward 记录")
|
||||
d = service.get_walkforward(items[0]["wf_id"])
|
||||
s = d["summary"]
|
||||
assert s.get("benchmark_mean") is not None, "缺少基准均值"
|
||||
assert s.get("excess_mean") is not None, "缺少超额收益均值"
|
||||
assert s.get("excess_win_rate") is not None
|
||||
|
||||
got = 0
|
||||
for w in d["windows"]:
|
||||
if w["benchmark_return"] is None:
|
||||
continue
|
||||
got += 1
|
||||
assert w["benchmark_code"], "应记录基准代码"
|
||||
o = w["out_of_sample"].get("total_return")
|
||||
if o is not None:
|
||||
assert abs(w["excess_return"] - (o - w["benchmark_return"])) < 1e-9, \
|
||||
"超额必须等于 策略收益 − 基准收益"
|
||||
# 基准行不得混进策略指标里
|
||||
assert not any(k.startswith("benchmark::") for k in w["out_of_sample"])
|
||||
assert got > 0, "没有任何窗口带基准收益"
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_walkforward_frozen_params_record_calibration() -> None:
|
||||
"""冻结参数必须记录「校准出的绝对阈值」,而不只是配置里的分位。
|
||||
|
||||
训练段的作用是把相对分位(P75)转成绝对股息率;若只有分位、
|
||||
没有绝对阈值,说明训练段实际上没做校准。
|
||||
"""
|
||||
from hdiv.web import service
|
||||
|
||||
items = service.list_walkforwards()
|
||||
if not items:
|
||||
pytest.skip("没有 walk-forward 记录")
|
||||
d = service.get_walkforward(items[0]["wf_id"])
|
||||
abs_entries = []
|
||||
for w in d["windows"]:
|
||||
f = w["frozen_params"]
|
||||
assert "entry_yield_percentile" in f, "应保留分位口径"
|
||||
assert f.get("absolute_entry_yield"), f"窗口 {w['window_index']} 缺校准阈值"
|
||||
assert f.get("calibration_obs"), "应记录校准样本数"
|
||||
abs_entries.append(f["absolute_entry_yield"])
|
||||
# 各窗口的绝对阈值应随市场水平变化(全相同说明没真校准)
|
||||
assert len(set(round(x, 6) for x in abs_entries)) > 1, \
|
||||
"各窗口校准出的绝对阈值完全相同,疑似未真正校准"
|
||||
|
||||
Reference in New Issue
Block a user