修复:量价单位 / 未来函数守卫 / 实时画像闸门;行情回补到 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:
+125
-4
@@ -11,6 +11,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from hdiv.cli import build_parser
|
||||
@@ -107,10 +108,12 @@ def test_documented_actions_exist(parser, cmd: str, expected: set[str]) -> None:
|
||||
"cmd,flags",
|
||||
[
|
||||
("sync", {"--only-missing", "--limit", "--symbols", "--apis",
|
||||
"--interleaved", "--start", "--end", "--no-resume", "--no-weight"}),
|
||||
"--interleaved", "--start", "--end", "--no-resume", "--no-weight",
|
||||
"--basic-start", "--basic-end"}),
|
||||
("universe", {"-c", "--config", "--asof", "--no-persist", "--no-html"}),
|
||||
("profile", {"--universe-run", "--symbols", "--asof", "--html-limit"}),
|
||||
("backtest", {"-s", "--strategy", "--mode", "--start", "--end", "--universe-run"}),
|
||||
("backtest", {"-s", "--strategy", "--mode", "--start", "--end", "--universe-run",
|
||||
"--allow-lookahead-universe"}),
|
||||
("sensitivity", {"-s", "--strategy", "--sweep"}),
|
||||
("strategy", {"-f", "--file", "--other"}),
|
||||
("audit", {"--no-persist", "--no-html"}),
|
||||
@@ -140,13 +143,22 @@ def test_backtest_universe_run_flag(parser) -> None:
|
||||
"""股票池 ↔ 回测 的关联入口:``--universe-run`` 必须存在且可解析。"""
|
||||
args = parser.parse_args(["backtest", "--universe-run", "abc123"])
|
||||
assert args.universe_run == "abc123"
|
||||
assert args.allow_lookahead_universe is False, "默认不得放行未来函数"
|
||||
args2 = parser.parse_args(["backtest"])
|
||||
assert args2.universe_run is None
|
||||
|
||||
|
||||
def test_backtest_lookahead_override_flag(parser) -> None:
|
||||
"""放行未来函数必须是一个显式开关,不能是默认行为。"""
|
||||
a = parser.parse_args(
|
||||
["backtest", "--universe-run", "abc123", "--allow-lookahead-universe"]
|
||||
)
|
||||
assert a.allow_lookahead_universe is True
|
||||
|
||||
|
||||
@pytest.mark.db
|
||||
def test_frozen_universe_is_used_when_run_id_given() -> None:
|
||||
"""指定 --universe-run 时引擎必须使用该股票池,而不是重新筛选。"""
|
||||
"""显式放行时,冻结股票池在各调仓日必须完全相同。"""
|
||||
from datetime import date
|
||||
|
||||
from hdiv.backtest.engine import BacktestEngine
|
||||
@@ -167,7 +179,8 @@ def test_frozen_universe_is_used_when_run_id_given() -> None:
|
||||
pytest.skip(f"数据库不可用:{exc}")
|
||||
|
||||
eng = BacktestEngine.from_strategy(
|
||||
"config/strategy/high_dividend_v1.yml", universe_run_id=rid
|
||||
"config/strategy/high_dividend_v1.yml", universe_run_id=rid,
|
||||
allow_lookahead_universe=True,
|
||||
)
|
||||
assert eng.universe_run_id == rid
|
||||
ctx = eng._prepare(eng.repo.trading_days(date(2024, 1, 1), date(2024, 6, 28)),
|
||||
@@ -177,6 +190,56 @@ def test_frozen_universe_is_used_when_run_id_given() -> None:
|
||||
assert all(x == sets[0] for x in sets), "冻结股票池在各调仓日必须完全相同"
|
||||
|
||||
|
||||
@pytest.mark.db
|
||||
def test_future_universe_is_rejected_by_default() -> None:
|
||||
"""回归:股票池 asof 晚于回测起点 = 未来函数,必须默认拒绝。
|
||||
|
||||
早期实现允许用 2025 年选出的股票池跑 2015 起的单次回测,2015-01-06
|
||||
就按那份事后名单成交 —— 与 walk-forward 已明确拒绝的做法自相矛盾。
|
||||
"""
|
||||
from datetime import date
|
||||
|
||||
from hdiv.backtest.engine import BacktestEngine
|
||||
from hdiv.core.config import load_config
|
||||
from hdiv.core.errors import HdivError
|
||||
from hdiv.data import db
|
||||
|
||||
db.load_dotenv_once()
|
||||
try:
|
||||
cfg = load_config("datasource")
|
||||
df = db.read_sql(
|
||||
"SELECT run_id, asof_date FROM hd_universe_run WHERE deleted_at IS NULL "
|
||||
"ORDER BY asof_date DESC LIMIT 1", cfg=cfg,
|
||||
)
|
||||
if df.empty:
|
||||
pytest.skip("没有筛选记录")
|
||||
rid = df["run_id"].iloc[0]
|
||||
uasof = pd.to_datetime(df["asof_date"].iloc[0]).date()
|
||||
except Exception as exc:
|
||||
pytest.skip(f"数据库不可用:{exc}")
|
||||
|
||||
eng = BacktestEngine.from_strategy(
|
||||
"config/strategy/high_dividend_v1.yml", universe_run_id=rid
|
||||
)
|
||||
early = date(uasof.year - 5, 1, 1)
|
||||
with pytest.raises(HdivError) as ei:
|
||||
eng._prepare(eng.repo.trading_days(early, date(uasof.year - 5, 3, 31)), verbose=False)
|
||||
msg = str(ei.value)
|
||||
assert "晚于回测起点" in msg
|
||||
assert "--allow-lookahead-universe" in msg, "错误信息必须给出放行开关"
|
||||
|
||||
# 起点晚于股票池 asof 时是合法的:此时股票池属于「事前信息」
|
||||
later = date(uasof.year + 1, 1, 2)
|
||||
eng2 = BacktestEngine.from_strategy(
|
||||
"config/strategy/high_dividend_v1.yml", universe_run_id=rid
|
||||
)
|
||||
days = eng2.repo.trading_days(later, date(later.year, 3, 31))
|
||||
if len(days) >= 2:
|
||||
ctx = eng2._prepare(days, verbose=False)
|
||||
assert ctx["universe_by_refresh"], "asof 不晚于起点时应正常使用该股票池"
|
||||
assert eng2.lookahead_universe_note is None
|
||||
|
||||
|
||||
def test_backtest_mode_choices(parser) -> None:
|
||||
"""手册只承诺 single / walkforward 两种模式。"""
|
||||
sub = _subparsers(parser)["backtest"]
|
||||
@@ -301,3 +364,61 @@ def test_html_flag_exists_on_all_report_producing_commands() -> None:
|
||||
for cmd in ("universe", "profile", "backtest", "audit", "sensitivity"):
|
||||
args = p.parse_args([cmd])
|
||||
assert getattr(args, "html", None) is False, f"hdiv {cmd} --html 默认应为 False"
|
||||
|
||||
|
||||
def test_walkforward_rejects_universe_run() -> None:
|
||||
"""回归:--universe-run 与 walk-forward 时序不兼容,必须明确拒绝。
|
||||
|
||||
股票池自带 asof(如 2025-01-21),而 walk-forward 窗口从 2015 年就开始训练;
|
||||
把未来时点选出的股票池套到更早的窗口上等于用未来信息选股。
|
||||
早期实现没有该参数,于是 --universe-run 被**静默忽略** ——
|
||||
用户以为按自己的股票池跑了,实际跑的是逐窗口自筛选。
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
r = subprocess.run(
|
||||
[sys.executable, "-m", "hdiv", "backtest",
|
||||
"--universe-run", "whatever", "--mode", "walkforward"],
|
||||
capture_output=True, text=True,
|
||||
env={**os.environ, "PYTHONPATH": "src"},
|
||||
)
|
||||
assert r.returncode == 1
|
||||
out = r.stdout + r.stderr
|
||||
assert "不支持 --universe-run" in out, out[:400]
|
||||
assert "未来" in out, "应说明原因(未来函数)"
|
||||
assert "Traceback" not in out
|
||||
|
||||
|
||||
def test_walkforward_runner_has_no_universe_param() -> None:
|
||||
"""再确认一层:runner 本身不接受冻结股票池,避免日后被误加回去。"""
|
||||
import inspect
|
||||
|
||||
from hdiv.backtest.walk_forward import WalkForwardRunner
|
||||
|
||||
sig = inspect.signature(WalkForwardRunner.__init__)
|
||||
assert "universe_run_id" not in sig.parameters, (
|
||||
"WalkForwardRunner 不应接受 universe_run_id —— "
|
||||
"冻结股票池与 walk-forward 的时序纪律冲突"
|
||||
)
|
||||
|
||||
|
||||
def test_no_future_warning_on_selector() -> None:
|
||||
"""回归:object 列的 fillna 曾触发 pandas Downcasting FutureWarning。
|
||||
|
||||
用 -W error::FutureWarning 跑一遍筛选路径,确保不再产生该警告
|
||||
(pandas 未来版本会改变行为,届时结果可能静默变化)。
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
r = subprocess.run(
|
||||
[sys.executable, "-W", "error::FutureWarning", "-m", "hdiv",
|
||||
"universe", "--asof", "2025-03-21", "--no-persist", "--no-html"],
|
||||
capture_output=True, text=True,
|
||||
env={**os.environ, "PYTHONPATH": "src"}, timeout=600,
|
||||
)
|
||||
assert "FutureWarning" not in (r.stdout + r.stderr), \
|
||||
f"仍存在 FutureWarning:{(r.stdout + r.stderr)[-400:]}"
|
||||
|
||||
Reference in New Issue
Block a user