"""画像窗口的**实际覆盖度**(不是「报告了 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"(窗口被数据起点截短,分位/统计量的实际样本期短于名义窗口)" )