功能:每日动态股票池回测(--mode daily)+ 每日增量同步 + PIT 批量取数层

说明:本提交是工作区中此前的未提交工作(在 14ec0c6 之后产生),**非本次会话所写**,
按用户要求整理并推送。已做安全检查(无明文凭据、无大文件、.env/logs/output 仍被忽略),
并完成可执行范围内的测试验证(见「测试」一节)。

## 新增能力

1) `hdiv backtest --mode daily --start <日期>`
   - src/hdiv/backtest/daily.py:两趟式(先逐日选股,再复用既有引擎模拟)
   - 每个交易日按当日可见数据重建股票池(PIT),每个交易日判断买卖点
   - `pool_exit_action`:hold(只减不加、不因掉出池子而清仓)/ sell(掉出即清仓)
   - `profile_on_trade`:买卖决策发生时计算并留痕个股画像,**不区分是否在当日池内**
     (卖出/减仓同样留痕,否则「为什么卖」缺证据)
   - 与 walkforward 的分工:daily 是一条连续路径的推演,不是过拟合检验;
     因此不使用训练段、不冻结分布,阈值口径一律 rolling
   - 拒绝 `--universe-run`(daily 的定义就是逐日重筛,冻结池与之矛盾)

2) PIT 批量取数层 src/hdiv/universe/pit.py
   - PitRepo 继承 Repo,**只重写取数**(按区块批量预载 + 逐日内存切片),
     派生逻辑(最新一期财报合并、单位归一化、支付率口径等)一行不重写
     —— 以保证与逐日单点查询**结果等价**
   - 候选集预剪枝:用「不可能通过」的边界条件提前排除,文档论证为精确等价而非近似
   - src/hdiv/universe/daily.py:每日动态筛选器(仍然调用既有 selector 与四个 Filter)

3) 每日增量同步 `hdiv sync daily`
   - src/hdiv/data/sync/daily.py:只抓「库里还没有的那几天」,
     按「当日股票数 ≥ 当年规模阈值」判定缺口,不重拉历史、不覆盖既有行;
     支持 `--dry-run` 先看待抓清单
   - deploy/daily-sync.sh、deploy/install-sync-schedule.sh、
     deploy/com.hddiv.sync.plist.example(launchd 每天 17:00)
   - 新表 hd_daily_universe(逐日入选成员留痕)+ sql/hd_daily_universe.sql + schema.py
     (该表已存在于库中,`ddl plan` 返回 0 个待执行动作)

4) Web 与文档
   - 前端支持 daily 模式记录下钻(web/app.js、web/app.css、web/index.html、
     web/favicon.svg)
   - README / docs/user-guide.md / docs/implementation-status.md 同步更新:
     三种回测模式的取舍、daily 的成本说明(6.7 年约 1.5 小时)与调优手段

## 测试

tests/ 共 500 项(新增 tests/test_daily.py 43 项、tests/test_sync_daily.py 36 项)。

已验证通过:
- 排除上述两个新文件的 **421 项:全部通过(pytest 退出码 0)**
- 两个新文件的**非 DB 单元测试 60 项:全部通过**

未能在合理时间内跑完:
- 两个新文件中 **19 项 DB 标记的重型测试**。实测瓶颈是一条**无界全表扫描**:
  `SELECT ... FROM hd_cashflow WHERE ann_date <= :asof ORDER BY symbol, end_date, ann_date`
  (31 万行,无 symbol/报告期下限)。全量套件跑到 161 项时已耗时 20 分钟、
  0 失败,按该速率预计需 3 小时以上,因此改为分档验证。
- 旁证:库中存在 3 次成功的 daily 端到端运行(2026-10-05 10:05 / 10:32 / 11:03,
  区间 2024-03-01~03-15),说明该路径可正常完成。

## 已知待改进

- 上述 `hd_cashflow`(及同类「按 ann_date 上界取全历史」)的查询缺
  symbol / 报告期下限,是 daily 模式的主要性能瓶颈,建议下一轮优化。
This commit is contained in:
2026-10-05 11:57:13 +08:00
parent 14ec0c6c86
commit cf6d4d2c56
36 changed files with 7385 additions and 285 deletions
+175 -3
View File
@@ -10,7 +10,7 @@
from __future__ import annotations
from datetime import date
from datetime import date, timedelta
import numpy as np
import pandas as pd
@@ -22,6 +22,8 @@ from hdiv.backtest.engine import (
Signal,
_months_between,
_round_lot,
build_yield_series,
dividend_handling_notes,
reconcile,
)
from hdiv.backtest.walk_forward import WalkForwardRunner, _add_months, _add_years
@@ -364,6 +366,164 @@ def test_engine_uses_next_open_no_lookahead() -> None:
)
# ---------------------------------------------------------------------------
# 分红与公司行为(plan.md §30/§31)
# ---------------------------------------------------------------------------
def _div_ctx(day: date, rows: list[dict]) -> dict:
return {"div_by_date": {day: rows}}
def _held(
symbol: str = "X.SH",
*,
quantity: float = 1000.0,
price: float = 10.0,
first_buy: date | None = None,
) -> Position:
d = first_buy or date(2020, 1, 2)
return Position(
symbol=symbol, quantity=quantity, avg_cost=price, cost_basis=quantity * price,
first_buy_date=d, last_buy_date=d,
)
def test_pure_stock_dividend_is_not_dropped(engine) -> None:
"""10 送 10(无现金分红)必须照常调整股数,不得静默丢弃。
纯送转的 ``cash_div_tax`` 是 NULL/0,而 ``stk_div`` > 0。价格是不复权价,
除权日必然下跌;若按现金分红判空整行跳过,就会凭空记出一笔亏损。
实测本库 2015-2026 区间内高股息池成员有 824 笔纯送转。
"""
day = date(2024, 6, 20)
pos = _held(quantity=1000.0, price=10.0)
ledger: list[dict] = []
cash = engine._apply_dividends(
day,
{"X.SH": pos},
_div_ctx(day, [{"symbol": "X.SH", "cash_div_tax": None, "stk_div": 1.0,
"stk_bo_rate": 1.0, "stk_co_rate": None}]),
0.0,
ledger,
)
# 股价腰斩到 5 元、股数翻倍到 2000 股 → 市值不变
assert pos.quantity == pytest.approx(2000.0)
assert pos.quantity * 5.0 == pytest.approx(10000.0), "10 送 10 前后市值必须不变"
assert pos.avg_cost == pytest.approx(5.0), "总成本不变,每股成本须随股数下降"
assert cash == 0.0, "纯送转不产生现金"
assert ledger[0]["shares_added"] == pytest.approx(1000.0)
assert ledger[0]["stock_div_applied"] is True
def test_stock_dividend_written_as_zero_cash_is_applied(engine) -> None:
"""``cash_div_tax`` 写成 0(而非 NULL)的纯转增同样不能丢。"""
day = date(2024, 6, 20)
pos = _held()
engine._apply_dividends(
day,
{"X.SH": pos},
_div_ctx(day, [{"symbol": "X.SH", "cash_div_tax": 0.0, "stk_div": 0.5,
"stk_co_rate": 0.5}]),
0.0,
[],
)
assert pos.quantity == pytest.approx(1500.0)
assert pos.avg_cost == pytest.approx(10000.0 / 1500.0)
def test_cash_and_stock_dividend_are_independent(engine) -> None:
"""同一行既有现金又有送转:两者都要入账,互不影响。"""
day = date(2024, 6, 20)
pos = _held(first_buy=day - timedelta(days=800)) # 持股 > 1 年 → 免征红利税
ledger: list[dict] = []
cash = engine._apply_dividends(
day,
{"X.SH": pos},
_div_ctx(day, [{"symbol": "X.SH", "cash_div_tax": 0.5, "stk_div": 0.3,
"stk_bo_rate": 0.3}]),
0.0,
ledger,
)
assert cash == pytest.approx(500.0), "持股 > 1 年免征红利税,全额入账"
assert pos.quantity == pytest.approx(1300.0)
assert ledger[0]["gross"] == pytest.approx(500.0)
assert ledger[0]["tax"] == pytest.approx(0.0)
assert ledger[0]["shares_added"] == pytest.approx(300.0)
def test_empty_dividend_row_is_skipped(engine) -> None:
"""既无现金也无送转(数据异常行)才是该跳过的行,且不留账。"""
day = date(2024, 6, 20)
pos = _held()
ledger: list[dict] = []
engine._apply_dividends(
day, {"X.SH": pos},
_div_ctx(day, [{"symbol": "X.SH", "cash_div_tax": 0.0, "stk_div": 0.0}]),
0.0, ledger,
)
assert ledger == []
assert pos.quantity == pytest.approx(1000.0)
def test_dividend_cash_joins_the_investable_pool(engine) -> None:
"""分红现金必须与初始资金同一个现金池 —— 能直接用于买入,不被隔离。
这是「分红再投资」的实际含义:除权日入账 → 下次调仓按目标权重再配置。
"""
day = date(2024, 6, 20)
positions = {"X.SH": _held("X.SH", quantity=10_000.0, price=1.0,
first_buy=day - timedelta(days=800))} # 免税
px = pd.DataFrame(
{"open": [1.0], "close": [1.0]}, index=pd.DatetimeIndex([pd.Timestamp(day)])
)
ctx = {
"div_by_date": {day: [{"symbol": "X.SH", "cash_div_tax": 0.10, "stk_div": None}]},
"px_by_sym": {"X.SH": px, "Y.SH": px},
"suspend": set(),
"limits": {},
}
# 起点现金为 0:下面买得成,只可能来自这笔分红
cash = engine._apply_dividends(day, positions, ctx, 0.0, [])
assert cash == pytest.approx(1000.0), "10000 股 × 每股 0.10 元"
sig = Signal(
symbol="Y.SH", signal_date=day, kind="BUY", target_weight=0.10,
yield_value=0.08, yield_percentile=80.0, price=None, reason={},
)
fill, cash_after, skip = engine._execute(sig, day, cash, positions, ctx, 0)
assert fill is not None, f"分红现金未能用于买入:{skip}"
assert fill.quantity > 0
assert cash_after < cash
assert cash_after >= 0.0
def test_dividend_handling_notes_match_each_mode() -> None:
"""声明口径必须与实现逐档对应:已实现的组合不得留声明,未实现的必须声明。
背景:`cash_mode: reinvest` 的实际行为一直是「分红现金回落到可投资现金池、
下次调仓按目标权重再配置」,却长期被声明成「未实现」—— 声明与行为两头都不准。
"""
def notes(**kw) -> str:
bt = load_config("backtest").model_copy(deep=True)
for k, v in kw.items():
setattr(bt.dividend, k, v)
return " ".join(dividend_handling_notes(bt))
# 已实现:可投资现金池 + 目标权重再配置,且不涉及未实现的配股
assert notes(cash_mode="reinvest", reinvest_rule="portfolio_rebalance",
handle_stock_dividend=True, handle_rights_issue=False) == ""
# 未实现:同股再投 / 永久留存 / 移出组合 / 不处理送转 / 配股,逐条都要声明
assert "未实现 reinvest_rule" in notes(reinvest_rule="same_stock_next_open",
handle_rights_issue=False)
assert "cash_mode=hold" in notes(cash_mode="hold", handle_rights_issue=False)
assert "cash_mode=cash_out" in notes(cash_mode="cash_out", handle_rights_issue=False)
assert "handle_stock_dividend" in notes(handle_stock_dividend=False,
handle_rights_issue=False)
assert "配股" in notes(handle_rights_issue=True)
@pytest.mark.db
def test_engine_dividends_are_creditable() -> None:
"""持有期间应确实收到现金分红(高股息策略的核心收益来源)。"""
@@ -542,7 +702,13 @@ def _trigger_ctx(sym: str = "000001.SZ", n: int = 280) -> dict:
ev = pd.DataFrame([{
"ex_date": days[0], "imp_ann_date": days[0], "cash_div_tax": 1.0,
}])
return {"px_by_sym": {sym: px}, "events": {sym: ev}, "_last_day": days[-1].date()}
px_by_sym = {sym: px}
events = {sym: ev}
# P2 之后引擎从 ctx["yield_by_sym"] 取预计算的股息率序列;这里用**同一个**
# 生产函数构造,避免测试自己算一套(那就成了两套口径)。
return {"px_by_sym": px_by_sym, "events": events,
"yield_by_sym": build_yield_series(px_by_sym, events),
"_last_day": days[-1].date()}
def _pass_gate(*_a, **_k) -> dict:
@@ -689,5 +855,11 @@ def test_unimplemented_declarations_are_honest() -> None:
# ① 不得把「无停牌」误报成「无数据」(约束表在 2010 起有数据)
assert "无数据" not in decl, f"误报数据缺失:{decl}"
# ② 必须如实声明「配置承诺但未实现」的项
for must in ("defer", "分红再投资", "配股", "成交量占比"):
for must in ("defer", "配股", "成交量占比"):
assert must in decl, f"漏报未实现项 {must}:{decl}"
# ③ 另一头也要准:**已实现**的组合不得留声明。当前配置
# (cash_mode=reinvest + reinvest_rule=portfolio_rebalance)的实际行为是
# 「分红现金回落到可投资现金池、下次调仓按目标权重再配置」,声明它
# 「未实现」会让使用者误以为分红现金被隔离成了不可投资资金。
assert "分红再投资" not in decl, f"把已实现的分红再投资误报成未实现:{decl}"
assert "reinvest_rule" not in decl, f"把已实现的再投资规则误报成未实现:{decl}"