修复:量价单位 / 未来函数守卫 / 实时画像闸门;行情回补到 2005;手册补全流程

本轮会话的三项正确性改造(均为「不报错、只让结果静默错」的类型):

1) 修复 stock_daily 量价单位前后不一致
   - 现象:2015-2019 存 Tushare 原始单位(手/千元),2020 起存(股/元),2019 同日混合;
     而流动性阈值按「元」配置 → 早年门槛实际是「日均成交额 ≥ 200 亿元」,
     把 2015-2019 的股票池整体清空(实测 2016/2017/2018 各选出 0 只)。
   - 修复:写入端 sync/price.py 统一换算;读取端 units.normalize_ohlcv_units
     按行判定并幂等换算(price_history / avg_amount 都走它);
     审计新增 UNIT-OHLCV 防回归。
   - 效果:2016/2017/2018 的股票池变为 7/11/13 只。

2) 未来函数守卫(单次回测)
   - 股票池自带 asof:若晚于回测起点即**拒绝执行**(原先静默冻结套用),
     与 walk-forward 已有的拒绝理由一致;确需复现加 --allow-lookahead-universe,
     偏差写入 unimplemented_json。

3) 新增实时(PIT)个股画像闸门
   - profile/pit.py:每个决策日按当时可见数据重算过去 5 年画像,
     惰性(仅买入条件已触发的标的)、面板按 asof 缓存、
     规则不含财务指标时不查财报表;被剔除时产出 REJECT + 逐规则留痕。
   - 指标定义复用 ProfileBuilder._profile_one(与批量画像逐值等价的回归测试)。
   - profile/coverage.py:窗口覆盖率(按交易日历的真实开市天数),
     策略新增 entry.profile_gate.min_window_coverage(默认 0,不改变既有行为)。
   - core/metrics.py:闸门可用指标的唯一定义(配置期即校验,避免写错指标名静默失效)。

4) 行情回补到 2005(使 5/8/10 年窗口真正完整)
   - stock_daily / adjust_factor / daily_basic 补到 2005-01-04;
     hd_suspend / hd_limit 补到 2010-01-04。
   - 5 年窗口覆盖率:2018-05-18 由 67.0% → 99.1%,2016-12-30 由 39.8% → 99.0%;
     残差经逐日与 hd_suspend 交叉核实为真实停牌(16/16 命中)。
   - 审计 G2/G3 与断点续传原先用固定阈值(2000 / 1500 只),
     会把 2005-2009 的正常数据误判为异常 —— 改为按「当年应有上市股票数」成比例判定。
   - 节流修正:daily/adj_factor/daily_basic 限频 480 → 170(实测该 token 约 196/min 即被拒)。

5) 自我声明如实化
   - 原先「约束未生效」由「过滤后集合为空」判定,会把「这批股票恰好没停牌」
     误报成「hd_suspend 无数据」;改为按表级判定。
   - 补齐此前静默的「配置承诺但未实现」项:suspended_rule/limit_up_down_rule 的 defer、
     cash_mode=reinvest/reinvest_rule、handle_rights_issue、signal_to_execution、
     max_volume_pct、liquidity_limit_pct_adv —— 全部写入 unimplemented_json。

6) 手册:新增 §0「全流程操作(选股 → 画像 → 回测)」置于最前
   - 逐步说明「命令做了什么、数据从哪来、落了哪些库、有哪些坑」;
     含实时画像闸门 9 问 9 答、未来函数守卫表、成交与成本口径、验证 SQL。
   - 修正旧 §2.4 漏传 --universe-run(选了池子却没用于回测);
     修正两处声称「停牌顺延」「分红再投资」已实现的相反表述。

测试:403 项全部通过(含新增 test_units.py、test_profile_pit.py、
未实现声明诚实性测试、行序无关性回归测试)。

注意:本提交中 docs/*、README.md、src/hdiv/web/service.py 除本轮修改外,
也含此前遗留的未提交改动(无法按文件切分)。
This commit is contained in:
2026-10-04 12:47:17 +08:00
parent fb6608193b
commit 14ec0c6c86
25 changed files with 4543 additions and 209 deletions
+119
View File
@@ -0,0 +1,119 @@
"""画像窗口的**实际覆盖度**(不是「报告了 N 年」,而是「真的有 N 年数据」)。
**为什么需要它**:``window_slice(asof, 5)`` 的语义是「把已有数据切成最近 5 年」,
不是「保证有 5 年数据」。若数据起点晚于窗口左端,窗口会被**静默截短**,
而 ``_stat_row`` 只看 ``n_obs >= min(min_obs_days, 20)`` 就标 ``OK`` ——
20 个观测(约 1 个月)也算通过。
实测(600036.SH,``dv_yield``,5 年窗口):
| asof | 窗口内实际观测 | 应有权重 | 覆盖率 |
|---|---:|---:|---:|
| 2015-12-31 | 239 | 1212 | 19.7% |
| 2016-12-30 | 483 | 1212 | 39.9% |
| 2017-12-29 | 727 | 1212 | 60.0% |
| 2018-12-28 | 970 | 1212 | 80.0% |
| 2019-12-31 | 1214 | 1215 | 99.9% |
| 2020 起 | ≈1215 | ≈1215 | 100% |
而 ``n_obs=817`` 的 2018-05-18、四个窗口(0/5/8/10)**报出完全相同的 n_obs**
—— 这正是「被数据起点截断」的指纹。
本模块提供统一的分母(**交易日历的真实开市天数**,不是 243 这种近似),
供实时画像(``profile.pit``)与批量画像(``profile.builder``)共用。
"""
from __future__ import annotations
from datetime import date, timedelta
from typing import Any
#: 覆盖率低于该值时,画像页与 CLI 打印警告(不改变任何判定,只是不让人误以为有 5 年)
WARN_COVERAGE = 0.95
def window_start(asof: date, years: int) -> date:
"""窗口左端(与 ``factor.dividend_yield.window_slice`` 完全一致的口径)。"""
return asof - timedelta(days=int(years * 365.25))
def expected_trading_days(repo: Any, asof: date, years: int) -> int:
"""``(asof - N 年, asof]`` 内**应有**的交易日数(按交易日历)。
``years <= 0`` 表示全历史:没有可比的「应有」天数,返回 0 让调用方跳过覆盖率判定。
区间必须是**左开右闭** —— 与 ``factor.dividend_yield.window_slice`` 的
``trade_date > start`` 完全一致。``repo.trading_days`` 取的是闭区间,
所以左端点恰为交易日时要减 1;否则覆盖率永远差一天、达不到 100%,
会让 ``min_window_coverage = 1.0`` 变成「永远拒绝」。
"""
if years <= 0:
return 0
lo = window_start(asof, years)
days = repo.trading_days(lo, asof)
n = len(days)
if n and days[0] == lo:
n -= 1
return n
def coverage_ratio(n_obs: int, expected: int) -> float | None:
"""实际观测数 / 应有交易日数,封顶 1.0。``expected<=0`` 时返回 None(不适用)。"""
if expected <= 0:
return None
if n_obs >= expected:
return 1.0
return max(0.0, float(n_obs) / float(expected))
def expected_by_window(repo: Any, asof: date, windows: list[int]) -> dict[int, int]:
"""``{窗口年数: 应有交易日数}``(含 0 → 0)。"""
return {int(w): expected_trading_days(repo, asof, int(w)) for w in windows}
def summarise(stat_rows: list[dict[str, Any]], expected: dict[int, int]) -> dict[str, Any]:
"""把一批 stat 行按窗口汇总覆盖率(供 CLI/报告打印警告)。
只统计 :data:`DAILY_OBSERVATION_METRICS` —— 其余指标的 ``n_obs`` 是财年数或 1,
用交易日当分母会算出「0.4%」这种量纲错误的覆盖率。
返回 ``{"windows": {年数: {"min": 最低覆盖率, "n": 行数}}, "worst": (年数, 比率)}``。
"""
from hdiv.core.metrics import DAILY_OBSERVATION_METRICS
agg: dict[int, list[float]] = {}
for r in stat_rows:
if r.get("metric_code") not in DAILY_OBSERVATION_METRICS:
continue
wy = int(r.get("window_years") or 0)
exp = expected.get(wy, 0)
c = coverage_ratio(int(r.get("n_obs") or 0), exp)
if c is None:
continue
agg.setdefault(wy, []).append(c)
windows = {
wy: {"min": min(vals), "n": len(vals)} for wy, vals in sorted(agg.items())
}
worst: tuple[int, float] | None = None
for wy, info in windows.items():
if worst is None or info["min"] < worst[1]:
worst = (wy, info["min"])
return {"windows": windows, "worst": worst}
def format_warning(summary: dict[str, Any]) -> str | None:
"""覆盖率不足时的一句话说明(否则 None)。
逐个列出**所有**不足的窗口,而不是只报最差的那个 ——
否则「5 年已齐、只有 10 年不足」也会被说成「数据不足」,容易误导。
"""
windows = (summary or {}).get("windows") or {}
short = [(wy, info["min"]) for wy, info in sorted(windows.items())
if info["min"] < WARN_COVERAGE]
if not short:
return None
detail = "、".join(f"{wy} 年窗口 {cov:.1%}" for wy, cov in short)
return (
f"窗口数据不足:{detail}"
f"(窗口被数据起点截短,分位/统计量的实际样本期短于名义窗口)"
)