本轮会话的三项正确性改造(均为「不报错、只让结果静默错」的类型):
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 除本轮修改外,
也含此前遗留的未提交改动(无法按文件切分)。
120 lines
4.8 KiB
Python
120 lines
4.8 KiB
Python
"""画像窗口的**实际覆盖度**(不是「报告了 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"(窗口被数据起点截短,分位/统计量的实际样本期短于名义窗口)"
|
||
)
|