功能:每日动态股票池回测(--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:
+175
-3
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user