Files
ggx/src/hdiv/profile/coverage.py
T
simon 14ec0c6c86 修复:量价单位 / 未来函数守卫 / 实时画像闸门;行情回补到 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 除本轮修改外,
也含此前遗留的未提交改动(无法按文件切分)。
2026-10-04 12:47:17 +08:00

120 lines
4.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""画像窗口的**实际覆盖度**(不是「报告了 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"(窗口被数据起点截短,分位/统计量的实际样本期短于名义窗口)"
)