修复:量价单位 / 未来函数守卫 / 实时画像闸门;行情回补到 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
+101
View File
@@ -96,11 +96,36 @@ class PathsConfig(StrictModel):
log_dir: str = "logs"
class SyncConfig(StrictModel):
"""行情同步的「完整性」判定口径(决定断点续传会不会重拉整段历史)。
一个交易日被视为**已完整同步**,要求当日股票数 ≥
``max(min_symbols_floor, min_symbols_ratio × 当年应有上市股票数)``。
**为什么不能只用一个绝对阈值**:A 股 2005 年只有约 1,350 只股票,
2010 年约 1,700 只。若固定要求 1,500 只,2005-2009 的**每一个交易日**
都会被判成「未完成」,于是断点续传完全失效 —— 回补中断一次就要从
第一天重新拉,且每次重跑都会把整段早年历史再拉一遍。
"""
min_symbols_floor: int = 200
min_symbols_ratio: float = 0.6
@model_validator(mode="after")
def _check(self) -> SyncConfig:
if self.min_symbols_floor < 1:
raise SchemaValidationError("sync.min_symbols_floor 必须为正")
if not 0.0 < self.min_symbols_ratio <= 1.0:
raise SchemaValidationError("sync.min_symbols_ratio 必须落在 (0, 1]")
return self
class DataSourceConfig(StrictModel):
version: int = 1
database: DatabaseConfig
tushare: TushareConfig = Field(default_factory=TushareConfig)
paths: PathsConfig = Field(default_factory=PathsConfig)
sync: SyncConfig = Field(default_factory=SyncConfig)
# ---------------------------------------------------------------------------
@@ -292,6 +317,8 @@ class SufficiencyConfig(StrictModel):
class TtmDividendConfig(StrictModel):
window_days: int = 365
grace_days: int = 45
# 是否消除除权间隔不规整造成的毛刺(重叠虚高 / 断档虚低)
smooth_spikes: bool = True
@model_validator(mode="after")
def _check(self) -> TtmDividendConfig:
@@ -479,6 +506,7 @@ class ScheduleConfig(StrictModel):
class PercentileReferenceConfig(StrictModel):
mode: Literal["rolling", "frozen"] = "rolling"
lookback_years: int = 5
min_observations: int = 250
@model_validator(mode="after")
def _check(self) -> PercentileReferenceConfig:
@@ -602,11 +630,84 @@ class ScaleStep(StrictModel):
return self
class ProfileGateRule(StrictModel):
"""一条实时画像闸门规则。
语义:``<metric> 的 <stat> <op> <value>`` 必须成立,否则不买。
指标名与分位可用性在**配置期**校验 —— 写错一个指标名若拖到运行时,
只会得到「无法验证 → 保守不买」,表现为策略再也不交易,极难定位。
"""
metric: str
stat: Literal["current_value", "current_percentile"] = "current_value"
op: Literal[">=", "<=", ">", "<"] = ">="
value: float
@model_validator(mode="after")
def _check(self) -> ProfileGateRule:
from hdiv.core.metrics import GATE_METRICS, PERCENTILE_METRICS
if self.metric not in GATE_METRICS:
head = self.metric.split("_")[0]
near = sorted(m for m in GATE_METRICS if head and head in m)
raise SchemaValidationError(
f"profile_gate 规则引用了未知指标 {self.metric!r}。"
f"可选指标见 hdiv/core/metrics.py;相近的有 {near[:6]}"
)
if self.stat == "current_percentile" and self.metric not in PERCENTILE_METRICS:
raise SchemaValidationError(
f"{self.metric} 是标量指标,没有历史分位,不能用 "
f"stat=current_percentile。有分位的指标:{sorted(PERCENTILE_METRICS)}"
)
return self
class ProfileGateConfig(StrictModel):
"""实时(PIT)个股画像闸门。
在每个决策日、**买入条件已经触发之后**,用「当时可见的数据」重算画像,
不通过的票直接剔除。跨股票共享的面板按时点缓存,代价与「触发次数」成正比,
而不是与「回测区间 × 股票数」成正比。
"""
#: 是否启用。关闭时回测行为与启用前完全一致(可用于复现历史结果)
enabled: bool = False
#: 画像统计窗口(年)。0 = 全历史;其余必须是 config/profile.yml 的 windows_years 之一
window_years: int = 5
#: 数据缺失/样本不足(无法验证)时:reject = 保守不买,pass = 放行
on_unverifiable: Literal["reject", "pass"] = "reject"
#: 窗口**实际覆盖率**下限(1.0 = 名义 5 年就必须真有 5 年数据)。
#: 0 = 不因覆盖率淘汰(默认,保持改造前行为)。
#:
#: 为什么需要它:`window_slice(asof, 5)` 只是「把已有数据切成最近 5 年」,
#: 数据起点晚于窗口左端时窗口会被静默截短 —— 实测 2018-05-18 的「5 年」
#: 窗口只有 3.4 年(817/1215 个交易日,67%),而画像仍报 OK。
min_window_coverage: float = 0.0
rules: list[ProfileGateRule] = Field(default_factory=list)
@model_validator(mode="after")
def _check(self) -> ProfileGateConfig:
if self.window_years < 0:
raise SchemaValidationError("profile_gate.window_years 不能为负")
if not 0.0 <= self.min_window_coverage <= 1.0:
raise SchemaValidationError(
f"profile_gate.min_window_coverage 必须落在 [0, 1],"
f"当前 {self.min_window_coverage}"
)
if self.enabled and not self.rules:
raise SchemaValidationError(
"profile_gate.enabled=true 但 rules 为空 —— 空闸门等于每次都要"
"算一遍画像再无条件放行。请补齐规则,或把 enabled 设为 false。"
)
return self
class EntryConfig(StrictModel):
yield_percentile: float
require_universe_pass: bool = True
require_risk_pass: bool = True
scale_in: list[ScaleStep] = Field(default_factory=list)
profile_gate: ProfileGateConfig = Field(default_factory=ProfileGateConfig)
@model_validator(mode="after")
def _check(self) -> EntryConfig: