功能:每日动态股票池回测(--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:
@@ -3,7 +3,8 @@
|
||||
A 股 **高股息 + 安全边际 + 估值均值回归** 策略的研究与回测系统。
|
||||
|
||||
从 Point-in-Time 股票筛选 → 个股特性画像 → 策略定义 → 历史回测 →
|
||||
Walk-forward 样本外验证 → 绩效与敏感性分析 → **统一 Web 前端**,全链路打通。
|
||||
Walk-forward 样本外验证 → **每日动态股票池推演** → 绩效与敏感性分析 →
|
||||
**统一 Web 前端**,全链路打通。
|
||||
|
||||
采用**前后端分离**:前端为 `output/` 下的单页应用(hash 路由,nginx 直接托管),
|
||||
后端为 `hdiv web` 提供的 REST API(nginx 反代 `/api`)。
|
||||
@@ -26,6 +27,13 @@ export PYTHONPATH=src
|
||||
.venv/bin/python -m hdiv sync index
|
||||
.venv/bin/python -m hdiv sync trading --start 2019-01-01
|
||||
|
||||
# 日常增量:只抓「库里还没有的那几天」,不重拉历史
|
||||
.venv/bin/python -m hdiv sync daily --dry-run # 先看待抓清单
|
||||
.venv/bin/python -m hdiv sync daily # 真抓
|
||||
|
||||
# 注册为每天 17:00 的 launchd 定时任务
|
||||
./deploy/install-sync-schedule.sh install
|
||||
|
||||
# 审计 → 打开 output/index.html
|
||||
.venv/bin/python -m hdiv audit
|
||||
```
|
||||
@@ -54,7 +62,7 @@ templates/ HTML 模板(Jinja2,离线)
|
||||
assets/ 图表库(ECharts,本地化,不依赖 CDN)
|
||||
output/ ★ 报告输出(部署这个目录)
|
||||
sql/ 建表 SQL 副本(供人工审查)
|
||||
tests/ 262 项自动化测试
|
||||
tests/ 439 项自动化测试(含每日动态股票池的取数等价性、预剪枝等价性、PIT 纪律与端到端)
|
||||
docs/ 文档
|
||||
```
|
||||
|
||||
@@ -111,6 +119,26 @@ docs/ 文档
|
||||
|
||||
---
|
||||
|
||||
## 三种回测模式(不要混用)
|
||||
|
||||
| 模式 | 命令 | 回答的问题 |
|
||||
|---|---|---|
|
||||
| 单条路径 | `hdiv backtest [--universe-run <id>]` | 某个固定池/周期重筛下的全期表现 |
|
||||
| 样本外 | `hdiv backtest --mode walkforward` | 参数在**未知未来**能否复现(过拟合检验) |
|
||||
| **每日动态池** | `hdiv backtest --mode daily --start 2020-01-05` | 从某天起**每个交易日重新选股**连续推演会怎样 |
|
||||
|
||||
`--mode daily` 的特点是**股票池每天都在变**:每个交易日按当时可见数据重建池子
|
||||
(PIT),每个交易日判断买卖点;持仓掉出当日池子默认**只减不加**(不清仓),
|
||||
买卖决策都会留下个股画像证据,每日入选成员落库到 `hd_daily_universe`。
|
||||
|
||||
> **成本要说清楚**:逐日全市场筛选是重活 —— 6.7 年(约 1600 个交易日)约需
|
||||
> **1.5 小时**;1 年约 20 分钟,3 个月约 5 分钟。命令启动时会打印预计时长。
|
||||
> 要缩短时间,可缩短区间,或把 `config/backtest.yml` 的
|
||||
> `daily.universe_refresh_days` 调大(例如 5 = 每周选股、每日判断买卖 ——
|
||||
> 这是真实的语义取舍)。详见[使用手册 §5.7b](docs/user-guide.md)。
|
||||
|
||||
---
|
||||
|
||||
## Web 部署(前后端分离)
|
||||
|
||||
```bash
|
||||
|
||||
+43
-5
@@ -50,18 +50,56 @@ walk_forward:
|
||||
# 测试期禁止重新调参:引擎层硬约束,train 段产出的参数对象冻结后传入
|
||||
freeze_params_in_test: true
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 每日动态股票池(backtest --mode daily)
|
||||
#
|
||||
# 与上面的 walk_forward 是**两种不同的检验**,不要混用:
|
||||
# walk_forward —— 切多个 (train, test) 窗口检验过拟合(样本外能否复现);
|
||||
# daily —— 从 --start 起跑**一条连续路径**,每个交易日重新选股、
|
||||
# 每个交易日判断买卖,回答「动态股票池下实际会怎样」。
|
||||
# daily 模式不使用训练段,也就没有「冻结分布」——阈值口径一律 rolling(PIT)。
|
||||
# ------------------------------------------------------------
|
||||
daily:
|
||||
# 股票池重建频率(交易日):1 = 每个交易日按当日可见数据重新筛选
|
||||
universe_refresh_days: 1
|
||||
# 信号评估频率(交易日):1 = 每个交易日评估买卖点
|
||||
signal_frequency_days: 1
|
||||
# 持仓掉出当日股票池后怎么办:
|
||||
# hold —— 只减不加(默认):不再买入/加仓,但不因「掉出池子」而清仓,
|
||||
# 仍按股息率分位规则决定减仓/卖出。池子回答「能买什么」,
|
||||
# 不回答「必须卖什么」。
|
||||
# sell —— 掉出即视为卖出信号,次日开盘清仓。
|
||||
pool_exit_action: hold
|
||||
# 买卖决策发生时计算并留痕个股画像(**不区分是否在当日池内**)。
|
||||
# 卖出/减仓同样触发画像 —— 否则「为什么卖」缺证据。
|
||||
profile_on_trade: true
|
||||
# 是否把每日入选成员写入 hd_daily_universe(供事后查「某天为什么是这些股票」)
|
||||
persist_daily_universe: true
|
||||
# 批量预载的分块年数(内存控制):行情/每日指标按年分块载入,用完即弃
|
||||
chunk_years: 1
|
||||
# 逐日选股的进度打印间隔(交易日)
|
||||
progress_every_days: 20
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# 分红处理(plan.md §30/§31)
|
||||
# ------------------------------------------------------------
|
||||
dividend:
|
||||
# reinvest —— 现金分红按规则再投资(plan.md §31 模式 B)
|
||||
# hold —— 现金留存不再投资
|
||||
# cash_out —— 分红移出组合
|
||||
# reinvest —— 现金分红**回落到可投资现金池**:与初始资金同一个 cash 变量,
|
||||
# 下次调仓时按目标权重再配置(默认;plan.md §31 模式 B 的权重口径)
|
||||
# hold —— 分红永久留存、不参与后续买入(未实现,会写入 unimplemented)
|
||||
# cash_out —— 分红移出组合(未实现,会写入 unimplemented)
|
||||
cash_mode: reinvest
|
||||
reinvest_rule: same_stock_next_open
|
||||
# portfolio_rebalance —— 下次调仓时按目标权重再配置(已实现,默认)
|
||||
# same_stock_next_open —— 按同一只股票次日开盘再投资(未实现,会写入 unimplemented)
|
||||
reinvest_rule: portfolio_rebalance
|
||||
# 红利税**总闸**;分档税率在 config/cost.yml 的 dividend_tax
|
||||
# (两者必须同时为真才计税)
|
||||
apply_dividend_tax: true
|
||||
# 是否处理送股/转增/配股(plan.md §30)
|
||||
# 送股/转增:按 stk_div 调整股数、总成本不变(plan.md §30)
|
||||
# false 时股数不调整,而不复权价照常除权下跌 → 会写入 unimplemented
|
||||
handle_stock_dividend: true
|
||||
# 配股:**未实现**(无配股价/比例数据)。true = 配置声称要处理,
|
||||
# 每次 run 都会在 unimplemented_json 里声明这个缺口
|
||||
handle_rights_issue: true
|
||||
|
||||
# ------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<!--
|
||||
com.hddiv.sync — 「高股息回测」每日增量数据抓取(macOS launchd)
|
||||
|
||||
这是模板,不要直接使用。安装请执行:
|
||||
./deploy/install-sync-schedule.sh install
|
||||
|
||||
行为:每天 17:00(收盘后 2 小时,Tushare 当日数据已出)执行
|
||||
deploy/daily-sync.sh
|
||||
它只抓「库里还没有的那几天」,已完整的历史一天都不重拉。
|
||||
|
||||
与 com.hddiv.web 的区别:
|
||||
web 是常驻服务(KeepAlive + RunAtLoad);本任务是**定时一次性**任务,
|
||||
因此必须 RunAtLoad=false + KeepAlive=false,否则会被无限拉起。
|
||||
|
||||
睡眠/关机期间的错过:launchd 会在机器唤醒或用户登录后补跑一次
|
||||
(StartCalendarInterval 的语义),补跑同样只抓缺口,不会重复入库。
|
||||
|
||||
占位符(由 install-sync-schedule.sh 渲染):
|
||||
__PROJECT_ROOT__ 项目根目录绝对路径
|
||||
-->
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.hddiv.sync</string>
|
||||
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>__PROJECT_ROOT__/deploy/daily-sync.sh</string>
|
||||
</array>
|
||||
|
||||
<key>WorkingDirectory</key>
|
||||
<string>__PROJECT_ROOT__</string>
|
||||
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>PYTHONPATH</key>
|
||||
<string>src</string>
|
||||
<key>LANG</key>
|
||||
<string>zh_CN.UTF-8</string>
|
||||
</dict>
|
||||
|
||||
<!-- 每天 17:00;改时间就改这两个整数(Hour 为 24 小时制) -->
|
||||
<key>StartCalendarInterval</key>
|
||||
<dict>
|
||||
<key>Hour</key>
|
||||
<integer>17</integer>
|
||||
<key>Minute</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
|
||||
<key>RunAtLoad</key>
|
||||
<false/>
|
||||
<key>KeepAlive</key>
|
||||
<false/>
|
||||
|
||||
<!-- 脚本自带日志(logs/daily-sync.log);这里只兜住脚本自身启动失败 -->
|
||||
<key>StandardOutPath</key>
|
||||
<string>__PROJECT_ROOT__/logs/daily-sync.launchd.log</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>__PROJECT_ROOT__/logs/daily-sync.launchd.log</string>
|
||||
</dict>
|
||||
</plist>
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# 每日增量数据抓取(「缺几天就抓几天」)
|
||||
#
|
||||
# 用法:
|
||||
# ./deploy/daily-sync.sh # 抓全部目标
|
||||
# ./deploy/daily-sync.sh --dry-run # 只看待抓清单,不调用接口
|
||||
# ./deploy/daily-sync.sh --only price # 只补行情
|
||||
#
|
||||
# 由 launchd 在每天 17:00 调用(见 deploy/install-sync-schedule.sh),
|
||||
# 也可以手工执行排障。退出码:0 = 全部成功;1 = 至少一个目标失败。
|
||||
#
|
||||
# 为什么锁 + 日志轮转是必需的:
|
||||
# - 重叠运行:Tushare 限频是按接口算的,两个实例互相抢额度会让两边都变慢,
|
||||
# 还会把同一天的数据写两遍(幂等,但纯属浪费)。用 pid 锁直接跳过;
|
||||
# - 日志只增不减:每天一条,一年就是几百 KB 到数 MB,超过 5 MB 自动转 .1。
|
||||
# ============================================================
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PYTHON="${PROJECT_ROOT}/.venv/bin/python"
|
||||
LOG_DIR="${PROJECT_ROOT}/logs"
|
||||
LOG="${LOG_DIR}/daily-sync.log"
|
||||
LOCK_DIR="${LOG_DIR}/.daily-sync.lock"
|
||||
MAX_LOG_BYTES=$((5 * 1024 * 1024))
|
||||
|
||||
mkdir -p "${LOG_DIR}"
|
||||
|
||||
# ---- 单实例锁(pid 存活性判定,避免残留锁永久阻塞)----
|
||||
if [[ -d "${LOCK_DIR}" ]]; then
|
||||
holder="$(cat "${LOCK_DIR}/pid" 2>/dev/null || true)"
|
||||
if [[ -n "${holder}" ]] && kill -0 "${holder}" 2>/dev/null; then
|
||||
echo "$(date '+%F %T') [跳过] 上一次同步(pid=${holder})仍在运行" >> "${LOG}"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf "${LOCK_DIR}"
|
||||
fi
|
||||
mkdir -p "${LOCK_DIR}"
|
||||
echo $$ > "${LOCK_DIR}/pid"
|
||||
trap 'rm -rf "${LOCK_DIR}"' EXIT
|
||||
|
||||
# ---- 日志轮转 ----
|
||||
if [[ -f "${LOG}" ]] && [[ "$(wc -c < "${LOG}")" -gt ${MAX_LOG_BYTES} ]]; then
|
||||
mv -f "${LOG}" "${LOG}.1"
|
||||
fi
|
||||
|
||||
if [[ ! -x "${PYTHON}" ]]; then
|
||||
echo "$(date '+%F %T') [错误] 虚拟环境不可用:${PYTHON}" >> "${LOG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rc=0
|
||||
{
|
||||
echo "============================================================"
|
||||
echo "$(date '+%F %T') 每日增量同步开始 ${*:-(默认全部目标)}"
|
||||
cd "${PROJECT_ROOT}"
|
||||
export PYTHONPATH="${PROJECT_ROOT}/src"
|
||||
export LANG="${LANG:-zh_CN.UTF-8}"
|
||||
# 日志里只要进度与错误:httpx 每个请求一行 INFO 会把日志淹掉
|
||||
# (同步进度本身走 print,不受日志级别影响)。需要排障时 HDIV_LOG_LEVEL=INFO。
|
||||
LOG_LEVEL="${HDIV_LOG_LEVEL:-WARNING}"
|
||||
# 某个目标失败不影响其余目标(run 内部逐个捕获),这里只汇总退出码。
|
||||
"${PYTHON}" -m hdiv --log-level "${LOG_LEVEL}" sync daily "$@" || rc=$?
|
||||
echo "$(date '+%F %T') 每日增量同步结束,exit=${rc}"
|
||||
} >> "${LOG}" 2>&1
|
||||
|
||||
exit "${rc}"
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# 把「每日 17:00 增量抓数据」注册为 macOS launchd 用户级定时任务
|
||||
#
|
||||
# 用法:
|
||||
# ./deploy/install-sync-schedule.sh install 安装(幂等,可重复执行)
|
||||
# ./deploy/install-sync-schedule.sh uninstall 停止并移除
|
||||
# ./deploy/install-sync-schedule.sh status 查看状态、下次触发时间与最近日志
|
||||
# ./deploy/install-sync-schedule.sh reinstall 重新渲染 plist 并重载
|
||||
# ./deploy/install-sync-schedule.sh run 立刻按计划表跑一次(排障用)
|
||||
# ./deploy/install-sync-schedule.sh dry-run 立刻跑一次「只看清单不抓数」
|
||||
#
|
||||
# 为什么用 launchd 而不是 cron:
|
||||
# - 本机已经用 launchd 托管 web 服务(install-service.sh),同一套机制更好排查;
|
||||
# - macOS 上 cron 需要额外的 Full Disk Access 授权,且睡眠错过的任务**不会补跑**,
|
||||
# 而 launchd 的 StartCalendarInterval 会在唤醒后补跑 —— 对「每天补缺口」的任务
|
||||
# 来说,补跑是刚需(漏一天就等于多一天缺口)。
|
||||
#
|
||||
# 若你不用 launchd,也可以用 crontab 一行搞定:
|
||||
# 0 17 * * * cd <项目根> && PYTHONPATH=src .venv/bin/python -m hdiv sync daily \
|
||||
# >> logs/daily-sync.log 2>&1
|
||||
# ============================================================
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
TEMPLATE="${PROJECT_ROOT}/deploy/com.hddiv.sync.plist.example"
|
||||
TARGET="${HOME}/Library/LaunchAgents/com.hddiv.sync.plist"
|
||||
LABEL="com.hddiv.sync"
|
||||
RUNNER="${PROJECT_ROOT}/deploy/daily-sync.sh"
|
||||
LOG="${PROJECT_ROOT}/logs/daily-sync.log"
|
||||
|
||||
die() { echo "错误: $*" >&2; exit 1; }
|
||||
|
||||
is_loaded() { launchctl list 2>/dev/null | grep -q "[[:space:]]${LABEL}$"; }
|
||||
|
||||
render() {
|
||||
[[ -f "${TEMPLATE}" ]] || die "缺少模板:${TEMPLATE}"
|
||||
[[ -x "${RUNNER}" ]] || die "缺少可执行脚本:${RUNNER}"
|
||||
mkdir -p "${PROJECT_ROOT}/logs" "${HOME}/Library/LaunchAgents"
|
||||
# 路径含中文,用 | 作 sed 分隔符避免转义问题
|
||||
sed -e "s|__PROJECT_ROOT__|${PROJECT_ROOT}|g" "${TEMPLATE}" > "${TARGET}"
|
||||
if grep -q "__" "${TARGET}"; then
|
||||
die "plist 中仍有未替换的占位符:$(grep -o '__[A-Z_]*__' "${TARGET}" | sort -u | tr '\n' ' ')"
|
||||
fi
|
||||
plutil -lint "${TARGET}" >/dev/null || die "plist 格式非法:${TARGET}"
|
||||
echo "已渲染 ${TARGET}"
|
||||
}
|
||||
|
||||
cmd_install() {
|
||||
render
|
||||
if is_loaded; then
|
||||
echo "任务已加载,先卸载再加载以应用新配置…"
|
||||
launchctl unload -w "${TARGET}" 2>/dev/null || true
|
||||
fi
|
||||
launchctl load -w "${TARGET}"
|
||||
cmd_status
|
||||
}
|
||||
|
||||
cmd_uninstall() {
|
||||
if [[ -f "${TARGET}" ]]; then
|
||||
launchctl unload -w "${TARGET}" 2>/dev/null || true
|
||||
rm -f "${TARGET}"
|
||||
echo "已停止并移除 ${TARGET}"
|
||||
else
|
||||
echo "${TARGET} 不存在,跳过"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
echo "=== launchd 定时任务 ==="
|
||||
if is_loaded; then
|
||||
launchctl list | grep "${LABEL}" | awk '{printf " PID=%s 上次退出码=%s %s\n", $1, $2, $3}'
|
||||
echo " (PID 为 - 表示当前没有正在运行的实例;退出码 0 = 上次同步成功)"
|
||||
else
|
||||
echo " 未加载。执行 ./deploy/install-sync-schedule.sh install 安装"
|
||||
fi
|
||||
echo "=== 计划 ==="
|
||||
echo " 每天 17:00 执行 ${RUNNER}"
|
||||
echo " 睡眠/关机错过会在唤醒后补跑一次"
|
||||
echo "=== 最近日志(${LOG})==="
|
||||
if [[ -f "${LOG}" ]]; then
|
||||
tail -12 "${LOG}"
|
||||
else
|
||||
echo " 尚无日志(任务还没跑过)"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_run() {
|
||||
echo "立即执行一次(等价于 17:00 触发)…"
|
||||
"${RUNNER}" "$@"
|
||||
echo "完成,退出码 $?。日志:${LOG}"
|
||||
}
|
||||
|
||||
case "${1:-install}" in
|
||||
install) cmd_install ;;
|
||||
reinstall) render; launchctl unload -w "${TARGET}" 2>/dev/null || true
|
||||
launchctl load -w "${TARGET}"; cmd_status ;;
|
||||
uninstall) cmd_uninstall ;;
|
||||
status) cmd_status ;;
|
||||
run) shift || true; cmd_run "$@" ;;
|
||||
dry-run) shift || true; cmd_run --dry-run "$@" ;;
|
||||
*) die "未知动作:$1(可用:install|reinstall|uninstall|status|run|dry-run)" ;;
|
||||
esac
|
||||
@@ -1,15 +1,23 @@
|
||||
# 实施状态报告
|
||||
|
||||
> 对应 `docs/development-plan.md`(计划)与 `docs/plan.md`(需求)
|
||||
> 更新:2026-10-02
|
||||
> 更新:2026-10-04
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话结论
|
||||
|
||||
**系统已端到端可运行**:从 Point-in-Time 股票池筛选 → 个股画像 → 策略定义 →
|
||||
回测 → Walk-forward → 绩效分析 → 参数敏感性 → 固定格式 HTML 报告,
|
||||
全链路打通并通过 403 项自动化测试。`plan.md §50` 的 14 项验收能力**全部具备**。
|
||||
回测 → Walk-forward → **每日动态股票池推演(`--mode daily`)** → 绩效分析 →
|
||||
参数敏感性 → 固定格式 HTML 报告,
|
||||
全链路打通并通过 **439 项自动化测试**。`plan.md §50` 的 14 项验收能力**全部具备**。
|
||||
|
||||
**2026-10-04 新增「每日动态股票池」**(详见 §10):
|
||||
`backtest --mode daily --start <日期>` 从给定日期起**每个交易日**重新选股(PIT)、
|
||||
每个交易日判断买卖点,持仓掉出当日池子默认**只减不加**,买卖决策都留个股画像证据,
|
||||
每日入选成员落库 `hd_daily_universe`。它与 walk-forward **不是替代关系** ——
|
||||
后者检验参数稳定性(过拟合),前者回答「动态池下这条路径长什么样」。
|
||||
代价是逐日全市场筛选:6.7 年区间约 **1.5 小时**(3 个月实测 5 分钟)。
|
||||
|
||||
**2026-10-03 完成三项正确性改造**(详见 §9):修复 `stock_daily` 量价单位不一致、
|
||||
拒绝「用未来时点的股票池跑更早区间」、新增**实时(PIT)个股画像闸门**。
|
||||
@@ -37,8 +45,9 @@
|
||||
| **G4 扩展财务** | ✅ 完成 | 财务指标 5,903 只(100%)、现金流 5,893、资产负债表/利润表 5,902;公告日齐全率 100%。缺失的 10 只均为 1990 年代退市股(Tushare 无报表) |
|
||||
| **G5 基准指数** | ✅ 完成 | `hd_index_daily` 7 个指数 / 40,083 行;沪深300 覆盖 2002 起 |
|
||||
| **G6 停牌/涨跌停** | ✅ 完成 | `hd_suspend` **468,388 行**、`hd_limit` **14,837,155 行**,均覆盖 **2010-01-04** 起(2026-10-04 回补,各 +400,623 / +5,787,253 行) |
|
||||
| **日常增量** | ✅ 完成 | `hdiv sync daily`(缺几天抓几天)+ `deploy/install-sync-schedule.sh` 注册每天 17:00 的 launchd 任务;2026-10-05 首次运行补齐 21 个交易日(行情 17.8 万行、指数权重 1,800 行、财报 6.5 万行),耗时约 2 分钟 |
|
||||
|
||||
### 1.2 数据库(30 张 `hd_*` 表)
|
||||
### 1.2 数据库(31 张 `hd_*` 表)
|
||||
|
||||
全部建表完成,结构迁移幂等(连续两次 `ddl apply` 均返回 0 个动作)。
|
||||
**只增不删**由三层保证:SQL 安全钩子 + 源码扫描测试 + 迁移的前置条件守卫。
|
||||
@@ -51,14 +60,17 @@
|
||||
| 数据安全 | `data/db.py`(SQL 钩子)、`data/ddl.py`(幂等 DDL) | ✅ |
|
||||
| 单位归一化 | `data/units.py`(万元/万股/百分数 → 元/股/小数) | ✅ |
|
||||
| PIT 取数 | `data/repo.py`(唯一取数出口) | ✅ |
|
||||
| 同步器 | `data/sync/`(分红/财报/指数/行情/停牌涨跌停) | ✅ |
|
||||
| 同步器 | `data/sync/`(分红/财报/指数/行情/停牌涨跌停 + `daily.py` 每日增量) | ✅ |
|
||||
| 数据审计 | `data/audit.py`(15 项检查,含量价单位一致性) | ✅ |
|
||||
| 股票池 | `universe/selector.py` + 4 个 Filter | ✅ |
|
||||
| PIT 批量取数 | `universe/pit.py`(`PitRepo`:与 `Repo` 逐值一致,逐日筛选的基座) | ✅ |
|
||||
| 每日选股 | `universe/daily.py`(逐日重建池 + 保守预剪枝) | ✅ |
|
||||
| 因子 | `factor/dividend_yield.py` | ✅ |
|
||||
| 个股画像 | `profile/builder.py` | ✅ |
|
||||
| 个股画像 | `profile/builder.py`、`profile/pit.py`(实时画像) | ✅ |
|
||||
| 策略管理 | `strategy/registry.py` | ✅ |
|
||||
| 回测引擎 | `backtest/engine.py` | ✅ |
|
||||
| Walk-forward | `backtest/walk_forward.py` | ✅ |
|
||||
| **每日动态股票池** | `backtest/daily.py`(`--mode daily`) | ✅ |
|
||||
| 绩效分析 | `analysis/performance.py` | ✅ |
|
||||
| 敏感性 | `analysis/sensitivity.py` | ✅ |
|
||||
| 报告渲染 | `report/`(7 类报告 + 离线校验) | ✅ |
|
||||
@@ -469,6 +481,11 @@ export PYTHONPATH=src
|
||||
.venv/bin/python -m hdiv sync trading --start 2019-01-01
|
||||
HDIV_ALLOW_BACKFILL=1 .venv/bin/python -m hdiv sync backfill # 2015-2018 回补
|
||||
|
||||
# 2b) 日常增量:缺几天抓几天(首次全量之后,每天只需要这一条)
|
||||
.venv/bin/python -m hdiv sync daily --dry-run
|
||||
.venv/bin/python -m hdiv sync daily
|
||||
./deploy/install-sync-schedule.sh install # 注册为每天 17:00 的 launchd 任务
|
||||
|
||||
# 3) 数据审计(含 HTML)
|
||||
.venv/bin/python -m hdiv audit
|
||||
|
||||
@@ -771,7 +788,7 @@ A 股相邻两次除权间隔经常 ≠ 365 天,硬 365 天窗口因此在每
|
||||
|---|---|
|
||||
| `test_config.py` | 配置正向加载 + 非法配置必须被拒(含 `profile_gate` 未知指标/标量分位/空规则) |
|
||||
| `test_safety.py` | SQL 安全钩子(含 11 类删除语句、只读白名单、前缀约束);源码扫描无删除语句、无 qlib import |
|
||||
| `test_schema.py` | 30 张表结构、前缀、幂等性、**唯一键列不得可空**(NULL 绕过唯一约束) |
|
||||
| `test_schema.py` | 31 张表结构、前缀、幂等性、**唯一键列不得可空**(NULL 绕过唯一约束) |
|
||||
| `test_sync.py` | 单位转换、NaN→NULL、分红去重键、财报 PIT 丢弃、限频器 |
|
||||
| `test_units.py` | **量价单位判定与幂等归一化**、同日混合单位、缺列不猜 |
|
||||
| `test_universe.py` | **单位换算与量级检测**、行业豁免、年报均值口径、分红宽限期、滤网索引契约 |
|
||||
@@ -1047,4 +1064,277 @@ A 股相邻两次除权间隔经常 ≠ 365 天,硬 365 天窗口因此在每
|
||||
(回补前的四个 run 为 `1a7e5b72…` / `e6e65382…` / `697a2ecd…` / `ae296c0f…`,
|
||||
仍保留在库中,可用于核对「回补是否改变了某项结论」。)
|
||||
|
||||
---
|
||||
|
||||
## 10. 每日动态股票池(`--mode daily`,2026-10-04 新增)
|
||||
|
||||
### 10.1 它补的是哪个空缺
|
||||
|
||||
原有两种回测各有各的问题,都回答不了「股票池每天都在变」这件事:
|
||||
|
||||
| | 原有 `--mode single` | 原有 `--mode walkforward` | **新增 `--mode daily`** |
|
||||
|---|---|---|---|
|
||||
| 股票池 | 每 12 个月重筛一次(`universe_refresh_months`) | 每个窗口按 asof 重筛 | **每个交易日**重筛 |
|
||||
| 信号 | 每月评估(`signal_frequency_months`) | 每月 | **每个交易日** |
|
||||
| 时间结构 | 一条 `[start, end]` | 多个 `(train, test)` | 一条 `[start, latest]` |
|
||||
| 阈值口径 | rolling | 测试段冻结训练分布 | rolling(无训练段) |
|
||||
| 目的 | 全期表现 | **过拟合检验** | **单路径连续推演** |
|
||||
|
||||
`daily` **不是** walk-forward 的替代品:它没有训练段,因此**不检验**参数稳定性。
|
||||
它检验的是另一件事 —— 「动态股票池下这条路径长什么样」。
|
||||
|
||||
### 10.2 动态池的语义(这是本功能的核心决定)
|
||||
|
||||
| 决定 | 取值 | 理由 |
|
||||
|---|---|---|
|
||||
| 持仓掉出当日池子 | **只减不加**(`pool_exit_action: hold`) | 池子回答「今天能**买**什么」,不回答「必须卖什么」。掉出即清仓会把「市场先生没报价」误判成「公司变坏了」 |
|
||||
| 买卖决策的画像 | **买卖都算,且不区分是否在池内** | 卖出更需要「当时它长什么样」的证据;池外持仓被卖出时尤其如此 |
|
||||
| 每日选股留痕 | 落库 `hd_daily_universe` | 事后可回答「某天为什么是这些股票」 |
|
||||
| 可选:掉出即清仓 | `pool_exit_action: sell` | 供对比用,不是默认 |
|
||||
|
||||
### 10.3 实测(2024-03-01 ~ 2024-03-29,21 个交易日;另有 58 日区间见 §10.4)
|
||||
|
||||
```
|
||||
选股 21/21 个决策日,池内 21~24 只,累计出现过的股票 25 只
|
||||
股票池变动:累计进入 4 次 / 移出 7 次(平均每日 0.2 进 0.3 出)
|
||||
市场候选 5353~5359 只 → 预剪枝后实际筛选 183~184 只 → 入选 21~24 只
|
||||
绩效:−5.92% / 最大回撤 −8.71% / 成交 14 笔 / 对账残差 −0.0000 ✓
|
||||
画像:计算 244 次(缓存命中 75),画像剔除 31 次
|
||||
落库:hd_daily_universe 477 行 / 21 个时点
|
||||
|
||||
58 日区间(2024-01-02 ~ 2024-03-29)的信号分布:
|
||||
信号:TRIM 284 / ADD 261 / BUY 183 / REJECT 163 / HOLD 45
|
||||
skip_reason:NO_CASH 334、BELOW_MIN_TRADE 284、PROFILE_GATE 163、
|
||||
ALREADY_AT_TARGET 110、OUT_OF_UNIVERSE 45
|
||||
画像留痕:773 条信号带 reason_json.profile
|
||||
```
|
||||
|
||||
- **`HOLD 45` + `skip_reason=OUT_OF_UNIVERSE`** 就是「掉出池子、只减不加」的直接证据:
|
||||
45 次「本想加仓但被池子挡住」,全部留有原因,可在前端「未成交信号」查看。
|
||||
- **773 条信号带画像留痕**:买卖决策都附上了当日可见数据算出的画像。
|
||||
|
||||
**两个「候选」必须分开记**(否则页面上同一个词有两种含义):
|
||||
|
||||
| 列 | 含义 |
|
||||
|---|---|
|
||||
| `hd_daily_universe.listed_count` | 当日**市场候选数**(未预剪枝),如 5359 |
|
||||
| `hd_daily_universe.candidate_count` | 当日**实际参与筛选**的候选数(已预剪枝),如 183 |
|
||||
|
||||
预剪枝是纯性能开关,不该让可见数字的含义随之改变 —— 这是本轮自查抓到的两个
|
||||
**静默数据缺陷**之一,另一个是 `dividend_yield` 整列为 NULL(详见 §10.5 第 6 条)。
|
||||
|
||||
### 10.4 性能:怎么把 5~8 小时压到可接受
|
||||
|
||||
单次 `UniverseSelector.run` 实测 **10~18 秒**(其中 `financial_panel` 独占 6.2 秒,
|
||||
且是**表量级**开销、与查哪天无关)。逐日 1600 次 = 5~8 小时,不可用。
|
||||
|
||||
三处改造(**都不改变判定口径**):
|
||||
|
||||
| 改造 | 效果 | 口径保证 |
|
||||
|---|---|---|
|
||||
| `PitRepo` 批量预载(只覆盖最底层取数方法) | 单次筛选 10~18s → 2.0s | `test_pit_repo_matches_direct_repo` 逐值比对 |
|
||||
| 财务按「已公告条数」缓存 + 存储预排序 | 模拟每 asof 4s → 0.38s | 同上(缓存键是充分统计量) |
|
||||
| 候选集保守预剪枝(交易所/板块/上市年限/市值上界) | 候选 5903 → 412 只 | `test_prune_does_not_change_selection` 锁定入选集合完全相同 |
|
||||
|
||||
另一处**顺带修掉的真实缺陷**:`ttm_params()` 每次调用都走 `load_config`
|
||||
(重新读盘 + YAML 解析 + pydantic 校验,约 16ms),而它在筛选器里是**逐股**调用的。
|
||||
cProfile 实测:6 个交易日里 `load_config` 被调用 **1008 次、共 16.6 秒**,
|
||||
占整个筛选时间的 **22%**,而每次返回的是完全相同的一份配置。
|
||||
已改为使用本模块早有的带缓存入口 `get_config`。这不是「优化」,是修一个明显的浪费。
|
||||
|
||||
**实测速率与取舍**:
|
||||
|
||||
| 阶段 | 实测 | 依据 |
|
||||
|---|---|---|
|
||||
| 逐日选股 | **2.66 秒/交易日** | 2026-08-01 起 43 个交易日用 114 秒;全区间 1635 日实测 2.38 秒/日(65 分钟) |
|
||||
| 模拟(修正前) | 3.45 秒/交易日 | 21 日窗口,池内约 22 只、**面板仅 25 只** |
|
||||
| 模拟(修正前,全区间) | **6.5 小时仍未结束** | 面板 123 只;跑到 6.5h 后中止 |
|
||||
| 模拟(修正后) | **3.04 秒/交易日** | 43 日窗口,池内 30~36 只、**面板 41 只**(直接计时,2026-10-05 复测) |
|
||||
| 全区间(修正后) | 选股 65 分钟 + 模拟 **约 1.5~2.5 小时** | **推算**:按 3.04 秒/日 × 1635 日 ≈ 1.4 小时,但全区间面板为 123 只、后期池内约 59 只,会比该窗口更慢 |
|
||||
|
||||
**2026-10-04 追加修正的性能缺陷**:`PitProfileService._compute` 原先
|
||||
「先按日期剪裁全市场面板(50 万行)、再筛出当前这一只股票」,实测 **174 毫秒/次**;
|
||||
两个条件互相独立,改为「先筛股票、再剪裁」后 **11 毫秒/次(16 倍)**。
|
||||
每次画像快照都要付两遍(`_price` 与 `_basics`)—— 这正是「21 日窗口(面板 25 只)」
|
||||
与「全区间(面板 123 只)」差约 4 倍的原因:**剪裁成本与面板里的股票数成正比**。
|
||||
等价性由 `tests/test_profile_pit.py` **27 passed** 锁定(含实时画像 vs 批量画像逐值比对)。
|
||||
|
||||
**选股结果可复用(已实测)**:逐日选股按「策略 + 区间 + 筛选配置 + **重建频率**」的指纹
|
||||
缓存到 `output/cache/daily_pools_<key>.json`(键不含时间戳;重建频率必须进键 ——
|
||||
1 日 / 5 日 筛出的池子是不同的输入,混用会静默给出错的股票池)。
|
||||
同一区间第二次运行会打印「复用已缓存的选股结果」并**跳过 114 秒的选股**,
|
||||
且指标与首次完全一致(0.29% / CAGR 1.73% / 回撤 −3.93% / Sharpe −0.03 / 17 笔 ——
|
||||
两次运行逐项相同);`--refresh-pools` 强制重筛。
|
||||
缓存目录可随时整个删掉(它只是缓存,删了下次重筛)。
|
||||
|
||||
### 10.4a 程序设计层面的提速(P1/P2/P4/P6,2026-10-05 实施并验收)
|
||||
|
||||
四项都**不改变判定规则**,逐项做了等价性验证;但实测收益与最初的估计**差很多**,
|
||||
如实记录如下(这正是「不要靠估计,要测」的又一例):
|
||||
|
||||
| 项 | 改动 | 等价性验证 | **实测收益** |
|
||||
|---|---|---|---|
|
||||
| **P4** | 选股的 `dividend_records` 按候选股收窄(原先每天把 2 万余行分红建 dict,只为查其中一两百只) | 5 个样本日入选集合**逐只相同**(`test_dividend_scope_does_not_change_selection`);收窄结果是精确子集 | 选股 **2.34 → 2.02 秒/日(−14%)** |
|
||||
| **P1** | 画像内部日期统一 `datetime64`(原先逐 asof 把 object 的 `datetime.date` 再转一遍) | 分红窗口谓词 4 个时点**逐行相同**(61/71/81/93 行) | 与 P2 合计仅 **−2%** |
|
||||
| **P2** | 每只股票整段股息率序列只算一次(原先每个评估日按前缀重算,O(交易日数²)) | 整段 vs 前缀 **20/20 点一致**(`test_ttm_dps_series_prefix_equals_full`) | 同上;区间越长收益越大 |
|
||||
| **P6** | 价格只取一次并交给画像复用;`dividend_events` 走常驻内存 | `dividend_events` 与直连**逐值一致**(4 个区间含 28,301 行);外部传入 price 与自取**画像逐值一致** | 一次性,约 −2% |
|
||||
|
||||
**为什么 P1/P2 几乎没省**:最初的判断依据是 cProfile —— 它显示日期转换占模拟 28%。
|
||||
但那个 profile 里混进了 `_prepare` 的**一次性取数**(约 30 万行 DB 读取),
|
||||
把占比算高了。改成按组件直接计时后,模拟阶段的开销分布是:
|
||||
|
||||
| 组件 | 单次成本 | 折算(43 日窗口) | 占比 |
|
||||
|---|---:|---:|---:|
|
||||
| `_profile_one`(每次画像快照算约 40 个指标) | **185 ms × 593 次** | 99.6 s | **78%** |
|
||||
| `_context`(每个 asof 重建财报面板) | **374 ms × 43 次** | 16.1 s | 13% |
|
||||
| 其余(盯市、分红入账、收益率切片…) | — | 约 12 s | 9% |
|
||||
|
||||
**结论**:模拟阶段的 91% 集中在这两处,而它们**都不是** P1/P2 触及的地方。
|
||||
所以下一步该动的是:
|
||||
|
||||
1. **把财报面板按 `symbols` 收窄后再建**(`_latest_financial` / `annual_financial_history`
|
||||
等现在都是「先建全市场、再取子集」,而缓存键里没有 symbol 集合 ——
|
||||
年报季可见条数天天变,于是天天重建全市场)。预计 `_context` 374 ms → 数十毫秒,
|
||||
即模拟 **−13%**;风险低(窗口函数是逐股票的,先筛 symbol 不改变任何一只股票的取值)。
|
||||
2. **闸门按「公告/分红可见性指纹」缓存 + 画像留痕改在成交时落**
|
||||
(闸门那 4 个指标与价格无关、一年只变几次;而 593 次快照里绝大多数是为
|
||||
「信号留痕」付的,最终只有 17 笔真正成交)。预计模拟 **−60~70%**;
|
||||
但**会改变留痕位置**(`hd_backtest_signal` 不再带画像、`hd_backtest_trade` 带上),
|
||||
需要使用者确认。用户要的「所有成交个股的实时画像」在这个方案下**反而更准确**。
|
||||
|
||||
> 另有一处**外部数据变化**必须记下:本次 A/B 期间,每日同步任务把 `stock_daily`
|
||||
> 从 2026-09-04 补到了 **2026-09-30**(+94,398 行),使同一区间 2026-08-01 起的结果
|
||||
> 从 **+0.29%** 变成 **−2.54%**(持仓数量、成交价、每日股票池均逐行相同,
|
||||
> 只有最后 4 周的价格是新的)。这不是代码问题,但它再次说明
|
||||
> **比较两次回测之前必须确认底层数据没变**。
|
||||
|
||||
### 10.4b 提速方案:把「每日」改成「每 N 日」(2026-10-05 新增)
|
||||
|
||||
原先两个频率只能改 YAML,现已接到命令行:
|
||||
|
||||
| 参数 | 作用对象 | 默认 |
|
||||
|---|---|---|
|
||||
| `--every-n-days N` | 股票池**重建**频率(选股那一趟) | 1(每个交易日) |
|
||||
| `--signal-every-n-days N` | 买卖**判断**频率(模拟那一趟) | 1(每个交易日) |
|
||||
|
||||
**设计要点:两个旋钮都不改判定规则,只改「多久看一次」。** 所以它们是
|
||||
「粗粒度版本」而不是「同一策略的加速版」—— 程序启动时会明确打印这句话并提示
|
||||
「结果不可与逐日口径直接比较」,避免有人拿 5 日口径的数字去和逐日口径比。
|
||||
|
||||
实测(2026-08-01 起 43 个交易日,**同一区间**):
|
||||
|
||||
| 口径 | 选股 | 模拟 | 画像计算次数 |
|
||||
|---|---:|---:|---:|
|
||||
| 选股每 1 日 / 信号每 1 日 | 114 秒 | 131 秒 | 537 |
|
||||
| 选股每 5 日 / 信号每 5 日 | 43 秒 | 约 28 秒 | 127 |
|
||||
|
||||
由此把模拟耗时拆成两项(**这是本节的关键修正**):每交易日的记账约 0.05 秒
|
||||
(很小),**每次「评估买卖」约 3.0 秒**(收益率序列 + 逐笔决策画像)——
|
||||
只有后者随信号频率下降。早先的模型把整段模拟都写成「随交易日数增长」,
|
||||
会把粗粒度方案的耗时**高估**约 1.6 倍。
|
||||
|
||||
按此模型,6.7 年(1635 交易日)的预计耗时:
|
||||
|
||||
| 方案 | 预计 |
|
||||
|---|---:|
|
||||
| 逐日 / 逐日(默认) | 约 2.6 小时 |
|
||||
| `--every-n-days 5` | 约 1.7 小时 |
|
||||
| `--signal-every-n-days 5` | 约 1.5 小时 |
|
||||
| `--every-n-days 5 --signal-every-n-days 5` | **约 35 分钟** |
|
||||
| `--every-n-days 21 --signal-every-n-days 21` | 约 12 分钟 |
|
||||
|
||||
> 上表是**推算**(单点实测只有 43 个交易日那两行)。全区间面板 123 只、
|
||||
> 后期池内约 59 只,实际会**更慢**。命令启动时会按同一模型打印本次预计时长。
|
||||
|
||||
### 10.5 已知限制(如实声明)
|
||||
|
||||
1. **6.7 年区间未实跑到底**:选股阶段实测跑完(65 分钟),模拟阶段未跑完
|
||||
(修正前跑到 6.5 小时中止)。修正后的总耗时是**推算**(约 3.5~4.5 小时),
|
||||
不是实测值。已实测到底的只有 3 个月区间(5 分钟)与选股阶段。
|
||||
2. **预剪枝依赖「市值上界」这一判据**:`min_avg_amount_20d` **刻意没有预剪枝** ——
|
||||
`stock_daily` 的量价单位在 2015-2019 是「手/千元」、2020 起是「股/元」,
|
||||
用 `MAX(amount)` 做上界会在早年低估 1000 倍、误剪本该通过的股票。
|
||||
宁可少一项优化,也不接受会改变结果的上界。
|
||||
3. **不落库逐股淘汰原因**:daily 只落库每日**入选**成员。要查「某只股票为什么没选上」,
|
||||
需用 `hdiv universe --asof <日期>` 单独跑一天。
|
||||
4. **起点即路径**:不同起点的 daily 结果不可直接比较策略优劣(早年池子、
|
||||
估值水平都不同)。
|
||||
5. **不支持 `--universe-run`**:固定池与「每日动态」定义互斥,且冻结名单自带未来信息。
|
||||
|
||||
6. **提交前自查抓到两个静默数据缺陷**(都属于「回测照跑、只是数是错的」那一类):
|
||||
- **`hd_daily_universe.dividend_yield` 整列为 NULL**:该列不在
|
||||
`UniverseSelector.run()` 返回的 `selected` 里 —— 股息率是**滤网算出来的**
|
||||
`values` 字段,不在行情/财报列里。只从 `selected` 取列就会静默写 NULL。
|
||||
已改为「滤网 values 优先、selected 列兜底」(`total_mv`/`roe_avg` 反向)。
|
||||
- **`candidate_count` 在开/关预剪枝时含义不同**:预剪枝前它是 5359、
|
||||
预剪枝后变成 183,而列名与页面文案都没变。已新增 `listed_count` 列,
|
||||
两者分开记录(并加 `ddl` 幂等 `ADD COLUMN`)。
|
||||
|
||||
教训与 §4.6 的第 3 条同源:**口径要靠测试与自查锁住,不能靠「看起来有数」**。
|
||||
|
||||
7. **顺带修掉一个既有的崩溃(非本功能引入)**:任何**短区间**回测
|
||||
(实测 15 个交易日)里 `sharpe` 不可计算为 `None`,而引擎与 CLI 的汇总打印
|
||||
直接做 `:.2f` / `:.2%` 格式化 → `TypeError: unsupported format string passed
|
||||
to NoneType`,以完整 traceback 结束。这是把「指标不可计算」这一**正常状态**
|
||||
说成了程序缺陷,违反了本项目「HdivError 只打印信息、不打 traceback」的约定。
|
||||
已改为统一的容忍 `None` 的格式化(`engine._pct` / `engine._num`,显示「—」),
|
||||
引擎与 CLI 的四处打印一并处理,`walk_forward` 的窗口打印同样修掉。
|
||||
由 `tests/test_daily.py::TestMetricFormatting` 锁定(含源码扫描,防止回退)。
|
||||
|
||||
### 10.6 提交前的验证清单
|
||||
|
||||
| 验证 | 方式 | 结果 |
|
||||
|---|---|---|
|
||||
| 取数口径与直连一致 | `tests/test_daily.py::test_pit_repo_matches_direct_repo` | 11 个方法逐值一致 |
|
||||
| 缓存键是充分统计量 | `::test_pit_repo_caches_are_exact` | 命中与冷算逐值相同 |
|
||||
| `symbols` 子集精确 | `::test_pit_repo_symbols_subset_is_exact` | 与直连一致 |
|
||||
| 区间外报错而非空表 | `::test_pit_repo_out_of_range_raises` | 抛 `HdivError` |
|
||||
| 预剪枝不改变入选 | `::test_prune_does_not_change_selection` | 5 个样本日入选集合完全相同 |
|
||||
| 预剪枝集合是上界 | `::test_prune_set_keeps_every_actual_member` | 实际成员全在保留集内 |
|
||||
| 每日选股无未来函数 | `::test_daily_pool_is_point_in_time` | 参照终点推后一年,同日选股逐只相同 |
|
||||
| 端到端落库 | `::test_daily_run_persists_members_and_run` | mode=daily、`hd_daily_universe` 有数据 |
|
||||
| 新增参数默认关闭 | `::TestEngineDefaultsAreOff` | single/walkforward 行为逐字不变 |
|
||||
| 既有模式无回归 | `pytest tests/ --ignore=tests/test_daily.py -q` | **408 项全通过**(本轮最后一次全量:439 项 100%) |
|
||||
| 契约与安全扫描 | `test_cli_contract` / `test_safety` / `test_schema` / `test_web` | 全通过(表数 30→31 已同步) |
|
||||
| 前端语法与接口契约 | `node --check web/app.js` + `FRONTEND_CALLS` | 通过(新路由已登记) |
|
||||
| 资金对账 | 实测 run 的 `residual` | `−0.0000 ✓` |
|
||||
| DDL 幂等 | `ddl plan` 连续两次 | `共 0 个待执行动作` |
|
||||
| 画像取数优化等价 | `pytest tests/test_profile_pit.py` | **27 passed**(含实时画像 vs 批量画像逐值比对) |
|
||||
|
||||
**本轮未完成的验证(必须诚实标注)**:
|
||||
|
||||
| 未验证项 | 原因 |
|
||||
|---|---|
|
||||
| **2020-01-05 起至今的完整回测结果** | 未跑:按约定「不做长时间测试」。修正后的全区间耗时是**推算**(见 §10.4);已实测到底的是 2026-08-01 起 43 个交易日(4 分钟) |
|
||||
| 浏览器里的实际渲染 | 已用 JavaScriptCore 做语法检查、经 HTTP 确认静态产物含新卡片、并逐项核对接口返回的数据结构;但**没有真机打开页面** |
|
||||
|
||||
**已补验(2026-10-05 复测,开发机重启后)**:
|
||||
|
||||
| 项目 | 结果 |
|
||||
|---|---|
|
||||
| 选股缓存复用 | ✅ 第二次运行打印「复用已缓存的选股结果」,跳过 114 秒选股,指标与首次**逐项相同** |
|
||||
| 前端「成交个股的实时画像」数据源 | ✅ `list_backtest_trades` 与 `analysis.stock_detail` 均返回 `reason.profile`(`test_stock_detail_exposes_decision_time_profile` 通过) |
|
||||
| `hd_daily_universe` 三列非空 | ✅ 1152 行,`dividend_yield`/`listed_count`/`roe_avg` **零 NULL** |
|
||||
| 动态池语义 | ✅ 该 run 有 `OUT_OF_UNIVERSE` 55 次(掉出池子只减不加)、`HOLD` 55 条 |
|
||||
| `test_pit_profile_caches_are_bounded` | ✅ 通过(修掉了该用例自身的 `PitRepo` 未导入问题) |
|
||||
| 一条命令给出前端位置 | ✅ `test_cli_points_at_the_frontend` 通过 |
|
||||
| `scripts/daily_result.py` | 已删除 —— 用户要的是「一条命令 + 前端看明细」,多一个导出脚本等于要求第二条命令 |
|
||||
|
||||
> **恢复后要做的**:`.venv/bin/python -m hdiv backtest --mode daily --start 2020-01-05`,
|
||||
> 然后打开 Web 前端(`./deploy/serve.sh start-dev`)→「回测记录」→ 选中该 run
|
||||
> 看明细。**不需要第二条命令**:绩效、净值、持仓、逐笔成交、
|
||||
> 成交个股的实时画像、每日股票池都在页面里。
|
||||
|
||||
**一次命令的产出(全部落库,前端可下钻)**:
|
||||
|
||||
| 表 | 内容 |
|
||||
|---|---|
|
||||
| `hd_backtest_run` | 运行头(mode=daily、区间、资金、可复现四元组、未建模声明) |
|
||||
| `hd_backtest_equity` | 逐日净值 / 现金 / 持仓市值 / 回撤 / 基准净值 → **综合曲线** |
|
||||
| `hd_backtest_metric` | 总收益 / CAGR / 最大回撤 / Sharpe / Sortino / Calmar / 换手 / 逐年 |
|
||||
| `hd_backtest_position` | 逐日持仓明细 |
|
||||
| `hd_backtest_trade` | **全部成交** + `reason_json`(触发理由 + 决策时点画像) |
|
||||
| `hd_backtest_signal` | 全部信号(含未成交原因:画像未通过 / 掉出池子 / 现金不足…) |
|
||||
| `hd_daily_universe` | **每日选股**留痕(逐日入选成员 + 入选时因子快照) |
|
||||
|
||||
|
||||
|
||||
+365
-14
@@ -36,6 +36,11 @@
|
||||
cd ~/project/高股息回测
|
||||
export PYTHONPATH=src
|
||||
|
||||
# ⓪ 数据:首次全量同步见 §2.3;此后每天只需要这一条(缺几天抓几天)
|
||||
.venv/bin/python -m hdiv sync daily --dry-run # 先看待抓清单
|
||||
.venv/bin/python -m hdiv sync daily # 真抓(只补缺口)
|
||||
./deploy/install-sync-schedule.sh install # 注册为每天 17:00 自动执行
|
||||
|
||||
# ① 选股:按 2025-01-01 当时可见的数据筛选(实际落到交易日 2024-12-31)
|
||||
.venv/bin/python -m hdiv universe --asof 2025-01-01
|
||||
# → 记下打印的 run_id,例如 02485801b2805cbae0e02db66c7bc946
|
||||
@@ -193,7 +198,8 @@ CLI 会打印覆盖率,例如:
|
||||
若闸门启用:另按最长窗口预载画像面板
|
||||
④ 逐日循环 ↓
|
||||
④a 开盘 → 执行**昨日**收盘产生的信号,成交价 = 次日开盘价 ± 滑点
|
||||
④b 盘中 → 除权除息:现金分红入账(按持股期限扣红利税)、送转股增加股数
|
||||
④b 盘中 → 除权除息:现金分红入账(按持股期限扣红利税)、送股/转增调整股数
|
||||
(纯送转无现金分红也照常调股数,不会被丢弃)
|
||||
④c 收盘 → 每月一次评估信号(signal_frequency_months=1):
|
||||
· 算当日股息率 = TTM 每股分红 ÷ 不复权收盘价
|
||||
· 算历史分位 = 当前值在「(当日−5年, 当日]」分布中的占比
|
||||
@@ -228,6 +234,7 @@ CLI 会打印覆盖率,例如:
|
||||
|---|---|
|
||||
| `--universe-run` 且**股票池 asof > 回测首个交易日** | **拒绝执行**,退出码 1,错误信息给出三种正确做法 |
|
||||
| `--universe-run` 用在 `--mode walkforward` | **拒绝执行**(训练窗口比股票池时点更早) |
|
||||
| `--universe-run` 用在 `--mode daily` | **拒绝执行**(固定池与「每日动态」定义互斥,且冻结名单自带未来信息) |
|
||||
| `--universe-run` 且 asof ≤ 起点 | 正常执行(股票池属于**事前信息**) |
|
||||
| 确需复现带未来信息的旧结果 | 显式加 `--allow-lookahead-universe`;偏差会写入 `unimplemented_json` |
|
||||
|
||||
@@ -245,9 +252,9 @@ CLI 会打印覆盖率,例如:
|
||||
| 整手 | 买入按 100 股取整 |
|
||||
| 涨跌停 | 开盘即封板 → 该信号**跳过**(记 `skip_reason`) |
|
||||
| 停牌 | 该信号**跳过**(`backtest.yml` 写的 `defer` **未实现**,不会顺延) |
|
||||
| 分红 | 除权日入账,**留存为现金**(`cash_mode: reinvest` 未实现),下次调仓按目标权重再配置 |
|
||||
| 送转股 | 已实现:股数按 `stk_div` 增加、成本不变 |
|
||||
| 配股 | **未实现**(`handle_rights_issue` 不生效) |
|
||||
| 分红 | 除权日入账(税后),进的是**与初始资金同一个可投资现金池**,下次调仓按目标权重再配置(`reinvest` + `portfolio_rebalance`,已实现;`hold`/`cash_out` 未实现) |
|
||||
| 送股 / 转增 | 已实现:股数按 `stk_div` 增加、**总成本不变**(每股成本随之下降)。**纯送转**(如 10 送 10:股价腰斩、股数翻倍,无现金分红)同样处理,不会被丢弃 |
|
||||
| 配股 | **未实现**(`handle_rights_issue` 不生效;库里也没有配股价/比例数据) |
|
||||
| 部分成交 / 成交量占比 | **未实现**(按信号全额成交,受资金与权重上限约束) |
|
||||
|
||||
> 以上「未实现」的项都会**逐条写入 `hd_backtest_run.unimplemented_json`**,
|
||||
@@ -298,8 +305,9 @@ CLI 也会直接打印对账残差与 `✓`。
|
||||
|---|---|---|---|
|
||||
| ① 选股 | `hdiv universe --asof <日期>` | `hd_universe_run` / `hd_universe_member` | `asof` 会归一化到交易日;`--no-persist` 后无法被回测引用 |
|
||||
| ② 画像 | `hdiv profile --universe-run <id>` | `hd_profile_run` / `_stat` / `_series` / `_score` | 画像**不参与回测**;窗口可能被数据起点截短(看覆盖率警告) |
|
||||
| ③ 回测 | `hdiv backtest [--universe-run <id>] [--start]` | `hd_backtest_run` / `_equity` / `_position` / `_trade` / `_signal` / `_metric` | **股票池 asof 晚于起点会被拒绝**;`defer`/`reinvest` 等未实现项在 `unimplemented_json` 里 |
|
||||
| ③ 回测 | `hdiv backtest [--universe-run <id>] [--start]` | `hd_backtest_run` / `_equity` / `_position` / `_trade` / `_signal` / `_metric` | **股票池 asof 晚于起点会被拒绝**;`defer`、配股等未实现项在 `unimplemented_json` 里 |
|
||||
| ④ 验证 | `hdiv backtest --mode walkforward` | `hd_walkforward_run` / `_window` | 必须与 `--universe-run` 分开用;约 25~80 分钟 |
|
||||
| ④b 动态池推演 | `hdiv backtest --mode daily --start <日期>` | `hd_backtest_run(mode=daily)` / `_equity` / `_trade` / `_signal` + **`hd_daily_universe`** | **每个交易日全市场筛选**,6.7 年约 1.5 小时;不支持 `--universe-run`;见 §5.7b |
|
||||
| ⑤ 调参 | `hdiv sensitivity --sweep "..."` | `hd_sensitivity_run` / `_point` | 样本不足时噪声会被误读为过拟合 |
|
||||
|
||||
---
|
||||
@@ -828,7 +836,11 @@ period: { start: 2015-01-01, end: latest }
|
||||
| `fill.price` | `next_open` | 信号次日开盘成交 |
|
||||
| `fill.limit_up_down_rule` | `skip` | 涨跌停时跳过(`defer` 分支**未实现**) |
|
||||
| `fill.suspended_rule` | `defer` | ⚠️ **未实现**:实际行为是**跳过**,不会顺延(见 §0.3) |
|
||||
| `dividend.cash_mode` | `reinvest` | ⚠️ **未实现**:实际行为是**留存为现金**(等价 `hold`),见 §0.3 |
|
||||
| `dividend.cash_mode` | `reinvest` | 分红现金**回落到可投资现金池**(与初始资金同一个 `cash` 变量),下次调仓按目标权重再配置。`hold`/`cash_out` **未实现** |
|
||||
| `dividend.reinvest_rule` | `portfolio_rebalance` | 已实现:调仓时按目标权重再配置。`same_stock_next_open`(同股再投)**未实现** |
|
||||
| `dividend.apply_dividend_tax` | `true` | 红利税**总闸**(分档税率在 `cost.yml` 的 `dividend_tax`;两者同时为真才计税) |
|
||||
| `dividend.handle_stock_dividend` | `true` | 送股/转增按 `stk_div` 调整股数、总成本不变(`false` 会写入 `unimplemented`) |
|
||||
| `dividend.handle_rights_issue` | `true` | ⚠️ **未实现**:配股缴款/股数变动不入账(见 §0.3) |
|
||||
|
||||
---
|
||||
|
||||
@@ -892,6 +904,9 @@ hdiv ddl verify # 校验库中表结构是否符合代码定义
|
||||
## 5.2 `sync` — 数据同步
|
||||
|
||||
```bash
|
||||
hdiv sync daily [--dry-run] [--asof YYYY-MM-DD] [--lookback-days 45] \
|
||||
[--only price trading index dividend financial] \
|
||||
[--no-financial] [--financial-limit 500] [--json]
|
||||
hdiv sync dividend [--symbols ...] [--only-missing] [--limit N]
|
||||
hdiv sync financial [--interleaved] [--only-missing] [--apis ...] [--limit N]
|
||||
hdiv sync index [--no-weight] [--start YYYYMMDD]
|
||||
@@ -903,6 +918,7 @@ hdiv sync backfill [--start 2015-01-01] [--end 2018-12-31] \
|
||||
|
||||
| 目标 | 说明 | 首次耗时 |
|
||||
|---|---|---:|
|
||||
| `daily` | **日常增量:缺几天就抓几天**(下方 §5.2.1) | ~1 分钟 |
|
||||
| `dividend` | 分红送转全明细(逐只股票) | ~35 分钟 |
|
||||
| `financial` | 四张财务报表;**加 `--interleaved` 按股票交错拉取**(推荐) | ~3 小时 |
|
||||
| `index` | 基准指数行情 + 成分股权重 | ~2 分钟 |
|
||||
@@ -918,6 +934,81 @@ hdiv sync backfill [--start 2015-01-01] [--end 2018-12-31] \
|
||||
> **`daily_basic` 仍停在 2015** —— 画像里的 PE/PB/股息率照样拿不到早年数据。
|
||||
> 现在 `--basic-start` 缺省时跟随 `--start`。
|
||||
|
||||
### 5.2.1 `sync daily` — 每日增量(只补缺口)
|
||||
|
||||
首次全量同步是一次性的事;**此后每天该跑的只有这一条命令**。
|
||||
它只抓「库里还没有的那几天」,已完整的历史一天都不重拉。
|
||||
|
||||
```bash
|
||||
hdiv sync daily --dry-run # 只看待抓清单,不调用接口、不写库
|
||||
hdiv sync daily # 真抓
|
||||
hdiv sync daily --only price # 只补行情三表
|
||||
hdiv sync daily --json # 机器可读结果(给监控/告警用)
|
||||
```
|
||||
|
||||
**外部数据源与本地表的对应关系**(只有这些表是「抓来的」,其余 `hd_*`
|
||||
(`hd_universe_*` / `hd_profile_*` / `hd_backtest_*` / `hd_strategy*` / `hd_report`
|
||||
/ `hd_data_audit` / `hd_sync_log`)都是本项目自己算出来或记的账):
|
||||
|
||||
| 本地表 | Tushare 接口 | 分区方式 | 缺口判定 |
|
||||
|---|---|---|---|
|
||||
| `stock_daily` | `daily` | `trade_date` | 当日股票数 ≥ 当年规模阈值 |
|
||||
| `adjust_factor` | `adj_factor` | `trade_date` | 同上 |
|
||||
| `daily_basic` | `daily_basic` | `trade_date` | 同上 |
|
||||
| `hd_suspend` | `suspend_d` | `trade_date` | 有行即视为已同步 |
|
||||
| `hd_limit` | `stk_limit` | `trade_date` | 当日股票数 ≥ 当年规模阈值 |
|
||||
| `hd_index_daily` | `index_daily` | `(指数, trade_date)` | 每个指数各自的最后一天 |
|
||||
| `hd_dividend` | `dividend` | `ann/imp_ann/ex/record_date` | 四个日期列都查过才算同步 |
|
||||
| `hd_fina_indicator` | `fina_indicator` | `ts_code` | 缺股票 / 报告期滞后 |
|
||||
| `hd_cashflow` | `cashflow` | `ts_code` | 同上 |
|
||||
| `hd_balancesheet` | `balancesheet` | `ts_code` | 同上 |
|
||||
| `hd_income` | `income` | `ts_code` | 同上 |
|
||||
| `index_weight` | `index_weight` | 月度区间 | 最后一个权重日之后 |
|
||||
|
||||
> **只读表的写入**:`stock_daily` / `adjust_factor` / `daily_basic` 是 qlib 的既有表,
|
||||
> 写入受 `StatementGuard` 保护。`sync daily` 会在进程内自动打开 `HDIV_ALLOW_BACKFILL`
|
||||
> 并向 `hd_sync_log` 记账;写入一律 `INSERT IGNORE`,**冲突行完全不改动**。
|
||||
|
||||
> **为什么财报四表不能按天补**:实测 `fina_indicator` / `income` / `balancesheet` /
|
||||
> `cashflow` 只传 `period` / `ann_date` / `start_date` 而不传 `ts_code` 时,
|
||||
> 服务端一律返回 `50101 必填参数, ts_code` —— 只能按股票拉。所以这四张表改为
|
||||
> 「先补完全没数据的股票,再按**报告期水位**补滞后股票」,单次有上限
|
||||
> (`--financial-limit`,默认 500 只,按市值降序),积压会在随后每天自动排空。
|
||||
> 报告期水位按披露截止日推算:年报/一季报 4-30、半年报 8-31、三季报 10-31。
|
||||
|
||||
> **为什么分红可以按天补**:`dividend` 接口支持 `ann_date` / `imp_ann_date` /
|
||||
> `ex_date` / `record_date` 四种日期参数(实测可用)。因此不必像早期实现那样
|
||||
> 逐只股票重拉全历史(5,900 次调用),每天最多 4 次调用;四个日期列都查,
|
||||
> 避免漏掉「预案日已过、除权日未到」的记录。
|
||||
|
||||
> **回溯窗口**:`--lookback-days`(默认 45)决定「往前找多少天的缺口」。
|
||||
> 更早的历史空洞属于**回补**而不是每日增量,用 `hdiv audit` 发现、
|
||||
> 用 `hdiv sync backfill` 处理。窗口存在是为了「昨夜失败今晨自愈」。
|
||||
|
||||
#### 定时执行(每天 17:00)
|
||||
|
||||
```bash
|
||||
./deploy/install-sync-schedule.sh install # 注册 launchd 定时任务
|
||||
./deploy/install-sync-schedule.sh status # 状态 + 最近日志
|
||||
./deploy/install-sync-schedule.sh dry-run # 立刻跑一次「只看清单」
|
||||
./deploy/install-sync-schedule.sh uninstall # 移除
|
||||
```
|
||||
|
||||
- 计划模板:`deploy/com.hddiv.sync.plist.example`(`StartCalendarInterval` = 17:00);
|
||||
- 执行包装:`deploy/daily-sync.sh`(单实例锁 + 日志轮转 + 退出码);
|
||||
- 日志:`logs/daily-sync.log`(脚本自身)与 `logs/daily-sync.launchd.log`(启动失败兜底)。
|
||||
|
||||
> **为什么用 launchd 而不是 cron**:macOS 上 cron 睡眠期间错过的任务**不会补跑**,
|
||||
> 而 launchd 的 `StartCalendarInterval` 会在唤醒后补跑一次 —— 对「每天补缺口」
|
||||
> 的任务来说补跑是刚需(漏一天就多一天缺口,且缺口会一直留着)。
|
||||
> 本机 web 服务已经用 launchd 托管,同一套机制更好排查。
|
||||
>
|
||||
> **非 launchd 环境**(Linux 服务器)等价的一行 crontab:
|
||||
> ```
|
||||
> 0 17 * * * cd <项目根> && PYTHONPATH=src .venv/bin/python -m hdiv sync daily >> logs/daily-sync.log 2>&1
|
||||
> ```
|
||||
|
||||
|
||||
## 5.3 `audit` — 数据审计
|
||||
|
||||
```bash
|
||||
@@ -1022,6 +1113,225 @@ hdiv backtest --universe-run <run_id> # 用指定股票池(冻结)并建
|
||||
> 「画像剔除 1027 次」= 有多少个买入信号被实时画像拦下。它们全部以
|
||||
> `REJECT` 记录在库,可在前端「未成交信号」里逐条查看每条规则的实际值。
|
||||
|
||||
### 5.7b `--mode daily` — 每日动态股票池
|
||||
|
||||
```bash
|
||||
# 逐日口径(最细):从 2020-01-05 起,每个交易日重新选股、每个交易日判断买卖点
|
||||
hdiv backtest --mode daily --start 2020-01-05
|
||||
# → { 落库 hd_backtest_run(mode='daily') + 逐日 hd_daily_universe + 完整回测明细 }
|
||||
|
||||
# 提速(推荐先跑这个,约 35 分钟):股票池与买卖都按「每周」口径
|
||||
hdiv backtest --mode daily --start 2020-01-05 \
|
||||
--every-n-days 5 --signal-every-n-days 5
|
||||
```
|
||||
|
||||
> 两个 `--*-every-n-days` 只改变**多久看一次**(选股 / 判断买卖),
|
||||
> 不改变判定规则;调大它们得到的是**粗粒度版本**,结果不可与逐日口径直接比较。
|
||||
> 完整的可选方案与预计耗时见本节末尾「提速方案」。
|
||||
|
||||
**它和 `--mode walkforward` 是两种不同的检验,不能互相替代**:
|
||||
|
||||
| | `--mode walkforward` | `--mode daily` |
|
||||
|---|---|---|
|
||||
| 回答的问题 | 参数在样本外能否复现(**过拟合检验**) | 从某天起连续推演**会怎样** |
|
||||
| 时间结构 | 多个 `(train, test)` 滚动窗口 | 一条连续的 `[start, latest]` |
|
||||
| 阈值口径 | 测试段**冻结**训练段分布 | 一律 rolling(PIT 滚动窗口) |
|
||||
| 股票池 | 每个窗口/调仓日按 asof 重筛 | **每个交易日**按 asof 重筛 |
|
||||
| 持仓掉出股票池 | ——(每窗口独立重来) | `pool_exit_action` 决定(默认只减不加) |
|
||||
| 产出 | 多窗口样本外统计 | 一条净值 + **逐日选股** + 逐笔信号 |
|
||||
|
||||
> **两个都要看**:walkforward 说「这套参数在未知未来是否站得住」;
|
||||
> daily 说「动态股票池下这条路径长什么样」。只看其中一个都会误判。
|
||||
|
||||
**动态股票池的语义**(`config/backtest.yml: daily`):
|
||||
|
||||
| 配置 | 默认 | 含义 |
|
||||
|---|---|---|
|
||||
| `universe_refresh_days` | `1` | 每 N 个交易日重建股票池。`1` = 每个交易日 |
|
||||
| `signal_frequency_days` | `1` | 每 N 个交易日评估买卖点 |
|
||||
| `pool_exit_action` | `hold` | 持仓掉出当日池子:`hold` = **只减不加**(不清仓,仍按分位卖出);`sell` = 清仓 |
|
||||
| `profile_on_trade` | `true` | 买卖决策发生时计算并留痕个股画像(**不区分是否在池内**) |
|
||||
| `persist_daily_universe` | `true` | 把每日入选成员写入 `hd_daily_universe` |
|
||||
|
||||
**输出示例**(2024-03-01 ~ 2024-03-29,21 个交易日):
|
||||
|
||||
```
|
||||
每日动态股票池回测 HD_MR_V1 v1.0:2024-03-01 ~ 2024-03-29(21 个交易日)
|
||||
选股频率:每 1 个交易日(共 21 次筛选);信号频率:每 1 个交易日;池外持仓:hold
|
||||
已载入参照数据(财报/分红/交易日历):17.2s
|
||||
候选集预剪枝:5903 → 409 只(剔除 交易所 349、板块 0、上市年限 2958、市值 2187)
|
||||
区块 1/1 2024-03-01 ~ 2024-03-29(21 个交易日,已载入行情 533,109 行 / 19.6s)
|
||||
选股 21/21 56s(2.65s/日,预计剩余 0.0 分钟)2024-03-29 池内 21 只
|
||||
选股完成:21/21 个决策日选出非空股票池,成员数 21~24,累计出现过的股票 25 只
|
||||
实时画像:计算 244 次(缓存命中 75),涉及 21 个决策时点 | 画像剔除 31 次
|
||||
期初 1,000,000 → 期末 940,815 | 总收益 -5.92% | 最大回撤 -8.71% | 成交 14 笔
|
||||
累计现金分红 0(已扣红利税 0) | 对账残差 -0.0000 ✓
|
||||
每日选股留痕:hd_daily_universe 477 行(21 个时点)
|
||||
股票池变动:累计进入 4 次 / 移出 7 次(平均每日 0.2 进 0.3 出)
|
||||
2024-03-05: +0 -1 出 000538.SZ
|
||||
2024-03-07: +1 -0 进 000538.SZ
|
||||
2024-03-08: +0 -1 出 000538.SZ
|
||||
```
|
||||
|
||||
**耗时与取舍(★ 必读)**:逐日全市场筛选 + 逐笔决策画像,是本系统最重的计算。
|
||||
|
||||
| 阶段 | 实测 | 说明 |
|
||||
|---|---|---|
|
||||
| 逐日选股 | **约 2.0 秒/交易日** | 2026-08 窗口实测 2.02 秒/日(P4 优化后;优化前 2.34 秒/日)。全区间 1635 日曾实测 2.38 秒/日(65 分钟)—— 不含 P4 |
|
||||
| 模拟 | **约 3.0 秒/交易日** | 43 个交易日、池内 30~36 只、面板 41 只(直接计时)。其中 **78% 是逐笔决策的画像快照**(每次约 185 ms)、13% 是逐时点重建财报面板 |
|
||||
| 加起来 | **约 2~3 小时**(6.7 年,推算) | 选股 65 分钟 + 模拟约 1.5~2.5 小时(推算,非实测) |
|
||||
|
||||
模拟的耗时**与股票池规模、面板股票数都成正比**:2020 年池内约 15 只、
|
||||
2026 年约 50 只,所以后半段明显更慢。
|
||||
|
||||
**已修掉的性能缺陷(2026-10-04)**:`PitProfileService._compute` 原先
|
||||
「先按日期剪裁 50 万行的全市场面板、再筛出这一只股票」,实测 **174 毫秒/次**;
|
||||
两个过滤条件互相独立,交换顺序后只要 **11 毫秒/次(16 倍)**。
|
||||
它每次画像快照都要付两遍(价格面板 + 每日指标面板)。等价性由
|
||||
`tests/test_profile_pit.py::test_pit_profile_matches_batch_builder`(实时画像 vs 批量画像
|
||||
逐值比对)与 27 项 PIT 用例锁定。
|
||||
|
||||
> **请注意**:全区间(6.7 年)修正后的总耗时是**推算**,不是实测 ——
|
||||
> 已实测到底的是 43 个交易日(4 分钟)。命令启动时会按实测速率给出预计值,
|
||||
> 运行中每 20 个交易日打印实测速率与 ETA。
|
||||
|
||||
### 提速方案(按「收益 / 失真代价」排序,可任选或叠加)
|
||||
|
||||
两个**频率旋钮**已接到命令行上(此前只能改 YAML):
|
||||
|
||||
| 参数 | 含义 | 默认 |
|
||||
|---|---|---|
|
||||
| `--every-n-days N` | 股票池每 N 个交易日**重建**一次 | `1`(每个交易日)= config 的 `daily.universe_refresh_days` |
|
||||
| `--signal-every-n-days N` | 买卖每 N 个交易日**判断**一次 | `1`(每个交易日)= config 的 `daily.signal_frequency_days` |
|
||||
|
||||
**它们不改变任何判定规则**,只改变「多久看一次」。所以调大它们得到的是
|
||||
**粗粒度版本**,不是同一策略的加速版 —— 交易机会与换手都会下降,
|
||||
结果**不可**与逐日口径直接比较(程序启动时会明确提示这一点)。
|
||||
|
||||
下表是 6.7 年区间(1635 个交易日)的**预计**耗时,按 2026-08-01 起 43 个交易日的
|
||||
实测速率推算(选股 2.6 秒/次、每次信号评估 3.0 秒、每交易日记账 0.05 秒):
|
||||
|
||||
| 方案 | 在 `--start 2020-01-05` 基础上加 | 预计耗时 | 失真代价 |
|
||||
|---|---|---:|---|
|
||||
| 逐日(当前默认) | — | **约 2.6 小时** | 无 |
|
||||
| 只放粗选股 | `--every-n-days 5` | 约 1.7 小时 | 池子每周更新一次;买卖仍逐日判断 |
|
||||
| 只放粗信号 | `--signal-every-n-days 5` | 约 1.5 小时 | 买卖每周判断一次;池子仍逐日重筛 |
|
||||
| **两者都放粗** | `--every-n-days 5 --signal-every-n-days 5` | **约 35 分钟** | 每周口径(建议先跑这个看结论) |
|
||||
| 每 10 日 | `--every-n-days 10 --signal-every-n-days 10` | 约 20 分钟 | 双周口径 |
|
||||
| ≈ 原月频 | `--every-n-days 21 --signal-every-n-days 21` | 约 12 分钟 | 与改造前 `--mode single` 的月频同量级,但股票池**动态重建**(这正是新功能的价值) |
|
||||
| 季频 | `--every-n-days 63 --signal-every-n-days 63` | 约 7 分钟 | 最粗,只适合快速看方向 |
|
||||
|
||||
> **这些数是推算不是实测**:单点实测是 43 个交易日(逐日 114+131 秒;
|
||||
> 每 5 日 43+约 28 秒)。全区间面板为 123 只(测试窗口 41 只)、后期池内约 59 只,
|
||||
> 所以实际会**更慢**。命令启动时会按同一模型打印本次的预计时长。
|
||||
|
||||
**建议**:先用 `--every-n-days 5 --signal-every-n-days 5`(约 35 分钟)拿结论;
|
||||
若某条结论对频率敏感,再把**信号**频率收紧回 1(逐日判断、每周选股,约 1.5 小时)
|
||||
做对照 —— 「多久判断一次买卖」才是真正改变收益路径的那个旋钮。
|
||||
|
||||
**其他可选方案**(不动上面两个频率):
|
||||
|
||||
| 做法 | 省多少 | 代价 |
|
||||
|---|---|---|
|
||||
| **复用选股缓存**(默认行为) | 重跑同区间**省掉整个选股阶段**(实测 43 日省 114 秒;全区间约 65 分钟) | 无。键只含输入、不含时间戳;缓存损坏会自动退回重筛 |
|
||||
| 缩短区间 `--start 2023-01-01` | 近似按比例下降 | 只看到那一段;起点不同结果本就不可比 |
|
||||
| `daily.profile_on_trade: false` | 模拟阶段约降 1/4(只保留闸门触发时的画像) | **前端「成交个股的实时画像」会变空** —— 与「所有成交个股实时画像」直接冲突 |
|
||||
| 策略文件 `entry.profile_gate.enabled: false` | 模拟阶段最大的一项开销消失(每次信号评估约 3.0 秒 → 约 0.6 秒) | 去掉一层风险控制,**改变了策略本身**;README 的样本外结论基于「闸门开」 |
|
||||
| `--refresh-pools` | 只会**更慢**(强制丢弃缓存重筛) | 无(用途是怀疑缓存时强制重算) |
|
||||
|
||||
**为什么能跑得动**:单次 `UniverseSelector.run` 约 10~18 秒,逐日 1600 次就是
|
||||
5~8 小时。daily 模式做了三件事把它降到可接受范围,且**都不改变判定口径**:
|
||||
|
||||
1. **批量预载**(`hdiv/universe/pit.py`):行情/每日指标按年分块一次性取回,
|
||||
逐日在内存切片;财务四表与分红常驻。``PitRepo`` 继承 ``Repo``,
|
||||
**只覆盖最底层的取数方法**,所有派生逻辑(最新一期财报合并、ROE 年化、
|
||||
单位归一化、支付率)一行未改 —— 口径由
|
||||
`tests/test_daily.py::test_pit_repo_matches_direct_repo` 逐值锁定;
|
||||
2. **可见性缓存**:财务面板按「已公告财报条数」缓存 —— 条数相同则可见集合相同,
|
||||
因此这是**精确**键。年报季几乎每天失效,靠预排序把每次重建压到 0.4 秒;
|
||||
3. **候选集预剪枝**:只剔除「在整个区间内**不可能**通过市场滤网」的股票
|
||||
(交易所/板块/上市年限/市值上界)。被剔除者在原流程里必然在第一个滤网被淘汰,
|
||||
所以最终入选逐只相同 —— 由
|
||||
`tests/test_daily.py::test_prune_does_not_change_selection` 锁定。
|
||||
|
||||
> **流动性刻意没有预剪枝**:`stock_daily` 的量价单位在 2015-2019 是「手/千元」、
|
||||
> 2020 起是「股/元」,用 `MAX(amount)` 做上界会在早年低估 1000 倍,**误剪掉
|
||||
> 本该通过的股票**。宁可少一项优化,也不接受一个会改变结果的上界。
|
||||
|
||||
**看结果:一条命令,然后打开前端 —— 不需要第二条命令**
|
||||
|
||||
这条命令自己会打印 `run_id`,并给出前端位置。结果全部落库,页面里直接下钻:
|
||||
|
||||
| 页面位置 | 能看到什么 |
|
||||
|---|---|
|
||||
| 回测记录 → 该条(模式 daily) | 绩效 KPI(总收益 / CAGR / 最大回撤 / Sharpe)、**净值曲线与基准**、资金对账、回测条件、可复现性 |
|
||||
| ↳ 持仓明细 | 任意交易日的持仓(可翻上/下一交易日) |
|
||||
| ↳ 逐笔成交与理由 | **全部成交** + 每笔的触发理由 |
|
||||
| ↳ **成交个股的实时画像** | 每一笔成交当天的画像:股息率、股息率分位、PE、PB、5 年 ROE、连续分红年数、支付率、FCF 覆盖,并标注该股**当日是否仍在池内** |
|
||||
| ↳ 未成交信号与原因 | 想买没买到 / 画像未通过 / 掉出池子 / 现金不足 |
|
||||
| ↳ **每日动态股票池** | 逐日选股留痕:决策日下拉、池内成员与入选时因子 |
|
||||
| ↳ 点任一成交个股 | 趋势与买卖点(N 联图)+ 该股**逐笔决策的实时画像**(可展开全部指标) |
|
||||
|
||||
「成交个股的实时画像」与个股页的「决策时点实时画像」是 `--mode daily` 的核心新增:
|
||||
它们把**每个买卖决策当天、只用当时可见数据**算出的画像列出来,
|
||||
回答的是「当时凭什么买/卖」,而不是「今天回头看它长什么样」。
|
||||
数据来自成交流水的 `reason_json.profile`,可用 SQL 逐条复核。
|
||||
|
||||
**同一批数据也可以用 SQL 直接查**(页面上的每个数字都可追溯到 SQL):
|
||||
|
||||
```sql
|
||||
-- 某一天的动态股票池(dividend_yield 是**筛选口径**的股息率)
|
||||
SELECT symbol, name, industry, dividend_yield, total_mv, roe_avg
|
||||
FROM hd_daily_universe
|
||||
WHERE run_id = '<run_id>' AND trade_date = '2024-03-29'
|
||||
ORDER BY dividend_yield DESC;
|
||||
|
||||
-- 池子的规模变化。注意两个「候选」含义不同:
|
||||
-- listed_count = 当日市场候选数(未预剪枝)
|
||||
-- candidate_count = 当日**实际参与筛选**的候选数(已预剪枝)
|
||||
SELECT trade_date, MAX(listed_count) AS 市场候选, MAX(candidate_count) AS 实际筛选,
|
||||
COUNT(*) AS 入选
|
||||
FROM hd_daily_universe WHERE run_id = '<run_id>'
|
||||
GROUP BY trade_date ORDER BY trade_date;
|
||||
|
||||
-- 「想加仓但已掉出池子」的记录(pool_exit_action=hold 的直接证据)
|
||||
SELECT symbol, signal_date, JSON_UNQUOTE(JSON_EXTRACT(reason_json,'$.rule')) AS why
|
||||
FROM hd_backtest_signal
|
||||
WHERE run_id = '<run_id>' AND skip_reason = 'OUT_OF_UNIVERSE';
|
||||
|
||||
-- 买卖决策时的个股画像留痕
|
||||
SELECT symbol, signal_date, signal_type,
|
||||
JSON_EXTRACT(reason_json, '$.profile.values.roe_avg') AS roe_avg,
|
||||
JSON_EXTRACT(reason_json, '$.profile.percentiles.dv_yield') AS dv_pct
|
||||
FROM hd_backtest_signal
|
||||
WHERE run_id = '<run_id>' AND JSON_EXTRACT(reason_json,'$.profile') IS NOT NULL LIMIT 20;
|
||||
```
|
||||
|
||||
**相关接口**(前端已经在用,供二次开发参考):
|
||||
|
||||
| 接口 | 内容 |
|
||||
|---|---|
|
||||
| `GET /api/backtests/<run_id>` | 运行头(mode、区间、资金、未建模声明) |
|
||||
| `GET /api/backtests/<run_id>/metrics` | 绩效指标(含逐年、基准对比) |
|
||||
| `GET /api/backtests/<run_id>/equity` | 逐日净值 / 回撤 / 基准净值 |
|
||||
| `GET /api/backtests/<run_id>/trades` | 全部成交 + `reason`(含决策时点画像) |
|
||||
| `GET /api/backtests/<run_id>/signals` | 未成交信号与原因 |
|
||||
| `GET /api/backtests/<run_id>/portfolio?date=` | 任意日持仓 |
|
||||
| `GET /api/backtests/<run_id>/stocks/<symbol>` | 个股买卖点 + 曲线 + 该股全部成交(含画像) |
|
||||
| `GET /api/backtests/<run_id>/daily-universe[?date=]` | 每日股票池时间线 / 某日成员 |
|
||||
|
||||
**已知限制**:
|
||||
|
||||
- **不支持 `--universe-run`**(会报错说明原因)。固定股票池与「每日动态」在定义上
|
||||
互斥,且冻结名单自带未来信息。
|
||||
- **daily 没有「训练段」**,因此也没有冻结分布。它**不检验**参数稳定性 ——
|
||||
要检验参数稳定性请用 `--mode walkforward` 与 `sensitivity`。
|
||||
- **候选集预剪枝会少留逐股淘汰原因**:daily 模式只落库每日**入选**成员;
|
||||
被剪掉的股票不会出现在 `hd_universe_member` 里(它本来也不落库 daily 的候选)。
|
||||
需要「某只股票为什么没选上」时,用 `hdiv universe --asof <日期>` 单独跑一天。
|
||||
- **区间起点即起点**:daily 是单条路径。起点不同,路径就不同(早年的股票池、
|
||||
估值水平都不一样)。不要拿两个不同起点的 daily 结果直接比较策略优劣。
|
||||
|
||||
## 5.8 `sensitivity` — 参数敏感性
|
||||
|
||||
```bash
|
||||
@@ -1050,7 +1360,7 @@ hdiv report validate # 校验全部报告的离线合规性与 JS 语法
|
||||
```bash
|
||||
export PYTHONPATH=src
|
||||
.venv/bin/python -m hdiv ddl apply
|
||||
.venv/bin/python -m hdiv ddl verify # 应输出「OK:30 张 hd_* 表结构全部符合 schema 定义」
|
||||
.venv/bin/python -m hdiv ddl verify # 应输出「OK:31 张 hd_* 表结构全部符合 schema 定义」
|
||||
```
|
||||
|
||||
## 6.2 数据同步(首次约 3~4 小时)
|
||||
@@ -1408,7 +1718,7 @@ http://<host>:8080/ggx/assets/echarts.min.js ← 图表库(约 1MB)
|
||||
|
||||
# 8. 数据库
|
||||
|
||||
## 8.1 表清单(30 张,全部 `hd_` 前缀)
|
||||
## 8.1 表清单(31 张,全部 `hd_` 前缀)
|
||||
|
||||
### 数据同步层
|
||||
|
||||
@@ -1427,6 +1737,7 @@ http://<host>:8080/ggx/assets/echarts.min.js ← 图表库(约 1MB)
|
||||
| 表 | 用途 |
|
||||
|---|---|
|
||||
| `hd_universe_run` / `hd_universe_member` | 股票池运行头 / 成员与逐滤网留痕 |
|
||||
| `hd_daily_universe` | **每日动态股票池**成员(`--mode daily` 的逐日选股留痕) |
|
||||
| `hd_factor_snapshot` | 决策时点因子值 |
|
||||
| `hd_profile_run` / `hd_profile_stat` / `hd_profile_series` / `hd_profile_score` | 画像头 / 分布统计 / 时间序列 / 安全边际得分 |
|
||||
| `hd_strategy` / `hd_strategy_param` | 策略版本 / 参数扁平表 |
|
||||
@@ -1615,15 +1926,22 @@ Tushare 各接口单位不统一,且从列名看不出来。系统在 `data/un
|
||||
| 整手 | 买入按 100 股取整 |
|
||||
| 涨跌停 | 开盘即封板则该信号**当日跳过**,记录 `skip_reason`(`defer` 未实现) |
|
||||
| 停牌 | **当日跳过**(⚠️ `fill.suspended_rule: defer` **未实现**,不会顺延;见 §0.3) |
|
||||
| 送转股 | 已实现:股数按 `stk_div` 增加、成本不变 |
|
||||
| 配股 | **未实现**(`handle_rights_issue` 不生效) |
|
||||
| 送转股 | 已实现:股数按 `stk_div` 增加、**总成本不变**(每股成本随之下降);纯送转(如 10 送 10,无现金分红)同样处理 |
|
||||
| 配股 | **未实现**(`handle_rights_issue` 不生效;且库里没有配股价/配股比例数据) |
|
||||
| 部分成交 / 成交量占比 | **未实现**(按信号全额成交,受资金与权重上限约束) |
|
||||
|
||||
**分红处理**:持仓市值用**不复权价**,现金分红在除权日**单独入账**(按持股期限扣红利税),
|
||||
**留存为现金**,在下次调仓时按目标权重重新配置
|
||||
(⚠️ `cash_mode: reinvest` / `reinvest_rule` **未实现**)。
|
||||
**分红处理**:持仓市值用**不复权价**,现金分红在除权日**单独入账**(按持股期限扣红利税)。
|
||||
入账的税后现金进的是**与初始资金同一个现金池**(`cash_mode: reinvest` +
|
||||
`reinvest_rule: portfolio_rebalance`,已实现)—— 它不是被隔离的「不可投资资金」,
|
||||
下次调仓时按目标权重再配置;`hold`(永久留存)与 `cash_out`(移出组合)才是未实现分支。
|
||||
用不复权价 + 独立现金流,从根上避免了「复权收益 + 分红」的重复计算。
|
||||
|
||||
> **两处已知口径简化**(不产生 `unimplemented` 声明,因为这是模型假设而非漏实现):
|
||||
> ① 红利税在**除权日**一次性按「买入→除权日」的持有期扣除,而 A 股实际是**卖出时**
|
||||
> 按「买入→卖出」的实际持有期补缴,且按分笔 FIFO;② **送股**(`stk_bo_rate`)
|
||||
> 按面值 1 元计入红利所得的个税未建模(转增 `stk_co_rate` 本就不征,故对以转增为主的
|
||||
> 样本无影响;持股 > 1 年者该税为 0)。
|
||||
|
||||
> 以上每一项未实现都会逐条写入 `hd_backtest_run.unimplemented_json` —— 可直接查库核对。
|
||||
|
||||
**资金对账**(每次回测都会校验):
|
||||
@@ -1985,6 +2303,39 @@ PYTHONPATH=src .venv/bin/python -c \
|
||||
**记住这条规则:改 `src/` 或 `config/` 之后,一律 `restart` 一次。**
|
||||
只改 `web/`、`templates/` 这类静态产物不需要——它们由 nginx 每次请求重新读盘。
|
||||
|
||||
### 11.2.2 页面一直停在「加载中…」,或页面没有样式(裸 HTML)
|
||||
|
||||
这两个症状都是**前端产物**的问题,后端其实是好的。分别定位:
|
||||
|
||||
**① 一直停在「加载中…」(概览页打不开,其他页正常)**
|
||||
|
||||
`index.html` 里预置了一句 `<div class="loading">加载中…</div>`,由前端路由
|
||||
`render()` 用真实内容替换掉。前端有一个「已渲染路径」缓存来避免重复渲染,
|
||||
而**首页的路径恰好是空串 `''`**:一旦这个缓存的初值也用 `''`,`render()`
|
||||
在首页一进门就命中 `path === currentPath` 提前返回 —— 占位符永远不被替换。
|
||||
哨兵值必须用 `null`(`web/app.js` 里 `let currentPath = null`),这种不一致
|
||||
才是病根,不是接口慢。
|
||||
|
||||
**② 页面裸奔(HTML 打得开,CSS/JS 403)**
|
||||
|
||||
nginx 的 master 是 root、**worker 是 nobody**,所以站点文件必须 world-readable:
|
||||
|
||||
```bash
|
||||
# 从 nginx 的视角看某个文件是否读得到(403 = 权限,404 = 路径/发布没做)
|
||||
curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/ggx/app/app.css
|
||||
grep 'Permission denied' /usr/local/var/log/nginx/ggx.error.log | tail -3
|
||||
find output -type f ! -perm -o=r # 列出 nobody 读不到的文件
|
||||
```
|
||||
|
||||
根因通常是 `shutil.copy2` **连权限一起复制**:`web/app.css` 若被编辑器以
|
||||
umask 077 存成 `600`,发布后 `output/app/app.css` 也是 `600` → nginx 403。
|
||||
`hdiv site build` 现在会在发布末尾统一把站点树收敛为「目录 755 / 文件 644」,
|
||||
所以正确做法是改完前端后重跑一次发布,而不是手工 chmod:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m hdiv site build # 同步 web/ → output/ 并修正权限
|
||||
```
|
||||
|
||||
## 11.3 股票池为空或很少
|
||||
|
||||
**按顺序排查**:
|
||||
@@ -2074,7 +2425,7 @@ market.min_market_capp
|
||||
| 4 | 未实现部分成交 | 按信号全额成交,受资金与权重上限约束 |
|
||||
| 5 | 大股东质押、重大诉讼过滤**无数据源** | 配置项存在但恒不生效 |
|
||||
| 6 | AI Agent 层(P8)未实现 | 属 `plan.md` 第四版扩展 |
|
||||
| 7 | 策略/回测配置里下列字段**尚未实现** | 改了它们**回测结果不会变**:<br>`position.max_holdings`、`position.sector_max_position`、`position.weight_scheme`、`risk.max_portfolio_drawdown`、`risk.max_single_drawdown`、`risk.liquidity_limit_pct_adv`、`exit.stop_loss_pct`、`exit.max_holding_days`、`fill.max_volume_pct`、`fill.partial_fill`、<br>**`fill.suspended_rule` / `fill.limit_up_down_rule` 的 `defer`**(未成交信号当日即被丢弃,不会顺延)、**`dividend.cash_mode=reinvest` / `dividend.reinvest_rule`**(分红留存为现金,在下次调仓再配置)、**`dividend.handle_rights_issue`**(配股不入账)、**`execution.signal_to_execution`**(固定次日开盘成交)。<br>**这些都会逐条写入 `hd_backtest_run.unimplemented_json`**,可直接从库里查 |
|
||||
| 7 | 策略/回测配置里下列字段**尚未实现** | 改了它们**回测结果不会变**:<br>`position.max_holdings`、`position.sector_max_position`、`position.weight_scheme`、`risk.max_portfolio_drawdown`、`risk.max_single_drawdown`、`risk.liquidity_limit_pct_adv`、`exit.stop_loss_pct`、`exit.max_holding_days`、`fill.max_volume_pct`、`fill.partial_fill`、<br>**`fill.suspended_rule` / `fill.limit_up_down_rule` 的 `defer`**(未成交信号当日即被丢弃,不会顺延)、**`dividend.cash_mode` 的 `hold`/`cash_out`** 与 **`dividend.reinvest_rule=same_stock_next_open`**(实际行为一律是「分红现金回落到可投资现金池,下次调仓按目标权重再配置」,即 `reinvest` + `portfolio_rebalance`)、**`dividend.handle_rights_issue`**(配股不入账)、**`execution.signal_to_execution`**(固定次日开盘成交)。<br>**这些都会逐条写入 `hd_backtest_run.unimplemented_json`**,可直接从库里查 |
|
||||
| 8 | `stock_daily` 2015–2019 的存量行仍是 Tushare 原始单位 | 读取层已兜底换算(结果正确),审计 `UNIT-OHLCV` 报 WARN;重刷数据可消除 |
|
||||
| 9 | ~~行情/每日指标只到 2015-01-05~~ → **已修复(2026-10-04 回补到 2005-01-04)** | 曾使「过去 5 年画像」在 2019 年前只有 0.2~4 年数据(覆盖率 20%~67%);回补后 5 年窗口覆盖率为 **98.7%~100%**,残差经逐日核实为真实停牌。另:**修好数据后全期收益从 +117.36% 降到 +92.73%**,因为 2015 年(牛市顶 + 股灾)从「被数据缺口挡住」变成被真实交易。见 §6.6 与 implementation-status §9.3c |
|
||||
| 10 | `config/profile.yml: sufficiency` 三个阈值**尚未被任何代码使用** | `min_history_years_dividend` / `min_history_years_price` / `min_dividend_records` 目前是死配置。真正的充分性判定由 `profile_gate.min_window_coverage` + `on_unverifiable` 承担 |
|
||||
|
||||
@@ -257,6 +257,27 @@ CREATE TABLE IF NOT EXISTS `hd_universe_member` (
|
||||
KEY `ix_hd_uni_mem_sym` (`symbol`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='股票池成员留痕';
|
||||
|
||||
-- 每日动态股票池成员(daily 模式「每日选股」的留痕)
|
||||
CREATE TABLE IF NOT EXISTS `hd_daily_universe` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`run_id` VARCHAR(32) NOT NULL COMMENT '所属回测 run_id',
|
||||
`trade_date` DATE NOT NULL COMMENT '该交易日的选股结果',
|
||||
`symbol` VARCHAR(12) NOT NULL,
|
||||
`name` VARCHAR(64) NULL,
|
||||
`industry` VARCHAR(64) NULL,
|
||||
`dividend_yield` DECIMAL(18,8) NULL COMMENT '入选当日股息率(池内排序口径)',
|
||||
`total_mv` DECIMAL(24,4) NULL,
|
||||
`roe_avg` DECIMAL(18,8) NULL,
|
||||
`listed_count` INT NULL COMMENT '当日市场候选数(未预剪枝)',
|
||||
`candidate_count` INT NULL COMMENT '当日实际参与筛选的候选数(已预剪枝)',
|
||||
`values_json` TEXT NULL COMMENT '入选时的关键因子快照',
|
||||
`created_at` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_hd_daily_uni` (`run_id`,`trade_date`,`symbol`),
|
||||
KEY `ix_hd_daily_uni_date` (`trade_date`),
|
||||
KEY `ix_hd_daily_uni_sym` (`symbol`,`trade_date`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='每日动态股票池成员';
|
||||
|
||||
-- 决策时点因子值(长表;避免 1.9 亿行日频面板,见 plan §3.2)
|
||||
CREATE TABLE IF NOT EXISTS `hd_factor_snapshot` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- 每日动态股票池成员(daily 模式「每日选股」的留痕)
|
||||
-- 由 src/hdiv/data/schema.py 生成,请勿手工修改
|
||||
CREATE TABLE IF NOT EXISTS `hd_daily_universe` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`run_id` VARCHAR(32) NOT NULL COMMENT '所属回测 run_id',
|
||||
`trade_date` DATE NOT NULL COMMENT '该交易日的选股结果',
|
||||
`symbol` VARCHAR(12) NOT NULL,
|
||||
`name` VARCHAR(64) NULL,
|
||||
`industry` VARCHAR(64) NULL,
|
||||
`dividend_yield` DECIMAL(18,8) NULL COMMENT '入选当日股息率(池内排序口径)',
|
||||
`total_mv` DECIMAL(24,4) NULL,
|
||||
`roe_avg` DECIMAL(18,8) NULL,
|
||||
`listed_count` INT NULL COMMENT '当日市场候选数(未预剪枝)',
|
||||
`candidate_count` INT NULL COMMENT '当日实际参与筛选的候选数(已预剪枝)',
|
||||
`values_json` TEXT NULL COMMENT '入选时的关键因子快照',
|
||||
`created_at` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_hd_daily_uni` (`run_id`,`trade_date`,`symbol`),
|
||||
KEY `ix_hd_daily_uni_date` (`trade_date`),
|
||||
KEY `ix_hd_daily_uni_sym` (`symbol`,`trade_date`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='每日动态股票池成员';
|
||||
@@ -0,0 +1,557 @@
|
||||
"""每日动态股票池回测(``backtest --mode daily``)。
|
||||
|
||||
**它回答的问题**:给定一个起点(如 2020-01-05),如果从那天起按这套规则
|
||||
**每个交易日**重新选股、每个交易日判断买卖点,实际会发生什么。
|
||||
|
||||
**与 walk-forward 的区别(不是同一件事,也不互相替代)**:
|
||||
|
||||
============================ ========================================== ==========================================
|
||||
``--mode walkforward`` ``--mode daily``
|
||||
============================ ========================================== ==========================================
|
||||
回答的问题 参数在样本外能否复现(过拟合检验) 从某天起连续实盘推演会怎样
|
||||
时间结构 切多个 (train, test) 窗口 一条连续的 [start, latest]
|
||||
阈值口径 测试段**冻结**训练段分布 一律 rolling(PIT 滚动窗口)
|
||||
股票池 每个窗口/调仓日按 asof 重筛 **每个交易日**按 asof 重筛
|
||||
持仓掉出股票池 ——(每个窗口独立重来) pool_exit_action:只减不加 / 清仓
|
||||
产出 多窗口样本外统计 一条净值 + 逐日选股 + 逐笔信号(含画像留痕)
|
||||
============================ ========================================== ==========================================
|
||||
|
||||
daily 模式**没有训练段**,因此不存在「冻结分布」;它检验的不是参数稳定性,
|
||||
而是「动态股票池下这套规则的连续表现」。两者应当**都看**,不要只采信一个。
|
||||
|
||||
**实现要点**:筛选与模拟分成两趟,共用一个 :class:`~hdiv.universe.pit.PitRepo`:
|
||||
|
||||
1. **选股趟**:行情/每日指标按区块(默认一年)批量预载,逐交易日在内存里
|
||||
跑**原有的** ``UniverseSelector`` 与四个 ``Filter``(口径一行未改),
|
||||
记录每天的入选成员后释放区块 —— 内存峰值是一个区块,不随区间长度增长;
|
||||
2. **模拟趟**:把「交易日 → 股票池」交给原有的
|
||||
:class:`~hdiv.backtest.engine.BacktestEngine`,由它完成撮合、成本、分红、
|
||||
公司行为、盯市与绩效。引擎在 daily 模式下只改了四件事:
|
||||
股票池按交易日切换、信号按交易日评估、池外持仓只减不加、买卖决策附画像留痕。
|
||||
|
||||
**没有未来函数**:每个决策日的股票池只用 ``<= 该日`` 的行情、财报(
|
||||
``ann_date <= 该日``)与已实施分红(``imp_ann_date <= 该日`` 且 ``ex_date <= 该日``)。
|
||||
这一点由既有 PIT 纪律与滤网实现保证,daily 模式只是把它的**调用频率**提到每日。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from hdiv.core.config import BacktestConfig, DailyConfig, load_config
|
||||
from hdiv.core.errors import DataGapError, HdivError
|
||||
from hdiv.data import db
|
||||
from hdiv.data.repo import data_version
|
||||
from hdiv.data.sync.base import stable_id
|
||||
from hdiv.strategy.registry import StrategyRegistry
|
||||
from hdiv.universe.daily import DailyUniverseScreener, ScreenDay
|
||||
from hdiv.universe.pit import PitRepo
|
||||
|
||||
__all__ = ["DailyRunner", "DailyProgress"]
|
||||
|
||||
#: 实测速率(本机、本数据集),仅用于「预计耗时」提示,不参与任何业务判定。
|
||||
#:
|
||||
#: 2026-10-05 用**同一区间**实测两次(2026-08-01 起 43 个交易日,
|
||||
#: 池内 30~40 只、面板 41 只):
|
||||
#:
|
||||
#: 选股每 1 日 / 信号每 1 日 → 选股 114 秒 + 模拟 131 秒
|
||||
#: 选股每 5 日 / 信号每 5 日 → 选股 43 秒 + 模拟 约 28 秒
|
||||
#:
|
||||
#: 由此把模拟拆成两项 —— **只有第二项随信号频率下降**:
|
||||
#: ``BASE`` 每个交易日都要做的盯市 / 分红 / 持仓记账(很小)
|
||||
#: ``SIGNAL`` **每次「评估买卖」**的开销(收益率序列 + 逐笔决策画像),
|
||||
#: 与信号频率成反比
|
||||
#: 早先把整段模拟都写成「随交易日数增长」,会把粗粒度方案的耗时**高估**约 1.6 倍。
|
||||
#: 注意这组常数来自 2026 年窗口;全区间面板是 123 只(此处 41 只),实际会更慢。
|
||||
_SEC_PER_SCREEN_DAY = 2.6
|
||||
_SEC_PER_SIM_DAY_BASE = 0.05
|
||||
_SEC_PER_SIM_SIGNAL = 3.0
|
||||
_SEC_PER_CHUNK = 25.0
|
||||
|
||||
|
||||
class DailyProgress:
|
||||
"""逐日选股的进度打印(默认每 N 个交易日一行)。
|
||||
|
||||
为什么必须打印:每日全市场筛选是分钟级到小时级的操作,静默运行会让人
|
||||
无法区分「在算」和「卡死」;而不打印时长的估计,用户也无从判断该不该
|
||||
用更粗的 ``universe_refresh_days``。
|
||||
"""
|
||||
|
||||
def __init__(self, total: int, every: int, *, enabled: bool = True) -> None:
|
||||
self.total = total
|
||||
self.every = max(1, int(every))
|
||||
self.enabled = enabled
|
||||
self.t0 = time.time()
|
||||
self.done = 0
|
||||
|
||||
def tick(self, label: str = "") -> None:
|
||||
self.done += 1
|
||||
if not self.enabled:
|
||||
return
|
||||
if self.done % self.every and self.done != self.total:
|
||||
return
|
||||
el = time.time() - self.t0
|
||||
rate = el / max(self.done, 1)
|
||||
eta = rate * max(self.total - self.done, 0)
|
||||
print(
|
||||
f" 选股 {self.done}/{self.total} {el:,.0f}s"
|
||||
f"({rate:.2f}s/日,预计剩余 {eta / 60:,.1f} 分钟){label}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
class DailyRunner:
|
||||
"""每日动态股票池回测的执行器。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
strategy_path: str | Path = "config/strategy/high_dividend_v1.yml",
|
||||
) -> None:
|
||||
db.load_dotenv_once()
|
||||
self.registry = StrategyRegistry()
|
||||
self.strategy = self.registry.load(strategy_path)
|
||||
self.bt: BacktestConfig = load_config("backtest")
|
||||
self.daily: DailyConfig = self.bt.daily
|
||||
|
||||
@classmethod
|
||||
def from_strategy(cls, path: str | Path) -> DailyRunner:
|
||||
return cls(path)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 主流程
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run(
|
||||
self,
|
||||
*,
|
||||
start: date | None = None,
|
||||
end: date | None = None,
|
||||
persist: bool = True,
|
||||
verbose: bool = True,
|
||||
refresh_pools: bool = False,
|
||||
every_n_days: int | None = None,
|
||||
signal_every_n_days: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""执行每日(或每 N 日)动态股票池回测。
|
||||
|
||||
两个**频率**参数是仅有的速度旋钮,语义不同、代价不同:
|
||||
|
||||
- ``every_n_days``:股票池**重建**频率(交易日)。1 = 每个交易日重筛。
|
||||
只影响「选股」那一趟的耗时(近似线性下降);两次重建之间池子不变。
|
||||
- ``signal_every_n_days``:买卖**判断**频率(交易日)。1 = 每个交易日判断。
|
||||
影响「模拟」那一趟(信号评估 + 逐笔决策画像),也直接影响交易机会数量。
|
||||
|
||||
两者都**不改变判定规则**,只改变「多久看一次」。所以调大它们得到的是
|
||||
「粗粒度版本」,不是同一策略的加速版 —— 交易机会与换手都会下降,
|
||||
结果不可与逐日口径直接比较。详见用户手册 §5.7b 的对照表。
|
||||
"""
|
||||
from hdiv.backtest.engine import BacktestEngine
|
||||
|
||||
repo = PitRepo()
|
||||
days_all = self._trading_days(repo, start, end)
|
||||
start, end = days_all[0], days_all[-1]
|
||||
if len(days_all) < 2:
|
||||
raise DataGapError(f"{start} ~ {end} 交易日不足,无法回测")
|
||||
|
||||
step = max(1, int(every_n_days or self.daily.universe_refresh_days))
|
||||
sig_step = max(
|
||||
1, int(signal_every_n_days or self.daily.signal_frequency_days)
|
||||
)
|
||||
screen_days = days_all[::step]
|
||||
|
||||
if verbose:
|
||||
print(
|
||||
f"每日动态股票池回测 {self.strategy.strategy.id} "
|
||||
f"v{self.strategy.strategy.version}:"
|
||||
f"{start} ~ {end}({len(days_all)} 个交易日)",
|
||||
flush=True,
|
||||
)
|
||||
print(
|
||||
f" 选股频率:每 {step} 个交易日"
|
||||
f"(共 {len(screen_days)} 次筛选);"
|
||||
f"信号频率:每 {sig_step} 个交易日;"
|
||||
f"池外持仓:{self.daily.pool_exit_action}",
|
||||
flush=True,
|
||||
)
|
||||
if step > 1 or sig_step > 1:
|
||||
print(
|
||||
f" 注意:粗粒度口径 —— 股票池每 {step} 个交易日才重建、"
|
||||
f"买卖每 {sig_step} 个交易日才判断。\n"
|
||||
f" 判定规则未变,但机会数量与换手低于逐日口径,"
|
||||
f"结果不可与逐日口径直接比较。",
|
||||
flush=True,
|
||||
)
|
||||
if len(screen_days) > 60:
|
||||
# 成本要**先说清楚**:全市场筛选是分钟级到小时级操作,
|
||||
# 让人先看到预计时长,再决定是否继续(或改用更粗的频率/更短区间)。
|
||||
n_chunks = max(
|
||||
1,
|
||||
len({d.year for d in screen_days})
|
||||
// max(1, int(self.daily.chunk_years)),
|
||||
)
|
||||
est = (
|
||||
len(screen_days) * _SEC_PER_SCREEN_DAY
|
||||
+ len(days_all) * _SEC_PER_SIM_DAY_BASE
|
||||
+ len(days_all) * _SEC_PER_SIM_SIGNAL / sig_step
|
||||
+ n_chunks * _SEC_PER_CHUNK
|
||||
+ 20.0
|
||||
)
|
||||
print(
|
||||
f" 预计耗时约 {est / 60:,.1f} 分钟"
|
||||
f"(按实测 选股 {_SEC_PER_SCREEN_DAY:.1f}s/次、"
|
||||
f"模拟 {_SEC_PER_SIM_DAY_BASE:.1f}s/交易日 + "
|
||||
f"{_SEC_PER_SIM_SIGNAL:.1f}s/次信号评估 估算;"
|
||||
f"机器与数据量不同会有出入)",
|
||||
flush=True,
|
||||
)
|
||||
print(
|
||||
" ⚠ 区间较长。可按需选择速度方案(详见手册 §5.7b):\n"
|
||||
" --every-n-days 5 股票池每 5 个交易日重建一次(选股耗时 ~÷5)\n"
|
||||
" --signal-every-n-days 5 买卖每 5 个交易日判断一次(模拟耗时大幅下降)\n"
|
||||
" 两者可叠加;也可缩短 --start/--end,或改 config/backtest.yml 的 daily 段。",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# --- 参照数据(财务/分红/日历)一次载入,全程常驻 ---
|
||||
t0 = time.time()
|
||||
repo.load_reference(end=end)
|
||||
if verbose:
|
||||
print(f" 已载入参照数据(财报/分红/交易日历):{time.time() - t0:,.1f}s",
|
||||
flush=True)
|
||||
|
||||
# --- 第一趟:逐区块选股(可按可复现的指纹复用缓存)---
|
||||
#
|
||||
# **为什么必须有缓存**:选股这一趟在 6.7 年区间上要 1 小时以上,而它完全
|
||||
# 由(策略 + 区间 + 筛选配置 + 刷新频率)唯一决定。第二趟模拟若因任何原因
|
||||
# 失败或需要重跑,重新筛一遍纯属浪费 —— 实测一次失败就白烧掉一小时。
|
||||
# 缓存键只含**输入**(不含时间戳),所以「同样的输入 ⇒ 同样的选股结果」,
|
||||
# 复用它不引入任何未来信息。
|
||||
cache_key = self._pools_cache_key(start, end, step)
|
||||
cached = None if refresh_pools else self._read_cache(cache_key)
|
||||
if cached is not None:
|
||||
pools, member_rows, prune_stats, screen_dates = cached
|
||||
screens = []
|
||||
if verbose:
|
||||
print(
|
||||
f" 复用已缓存的选股结果:{len(pools)} 个决策时点"
|
||||
f"(省去逐日筛选;加 --refresh-pools 可强制重筛)",
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
# --- 保守预剪枝 ---
|
||||
screener = DailyUniverseScreener.from_strategy(
|
||||
self.registry, self.strategy, repo, verbose=False
|
||||
)
|
||||
allowed = screener.build_prune_set(start, end)
|
||||
if verbose:
|
||||
p = screener.prune.as_dict()
|
||||
print(
|
||||
f" 候选集预剪枝:{p['total']} → {p['kept']} 只"
|
||||
f"(剔除 交易所 {p['pruned_exchange']}、板块 {p['pruned_board']}、"
|
||||
f"上市年限 {p['pruned_listing']}、市值 {p['pruned_market_cap']})"
|
||||
f";被剔除者在本区间内不可能通过市场滤网,不影响最终入选",
|
||||
flush=True,
|
||||
)
|
||||
screens = self._screen_all(repo, screener, screen_days, verbose=verbose)
|
||||
pools = {s.trade_date: set(s.symbols) for s in screens}
|
||||
prune_stats = screener.prune.as_dict()
|
||||
screen_dates = sorted(pools)
|
||||
member_rows = DailyUniverseScreener.member_rows(
|
||||
"", screens, created_at=datetime.now()
|
||||
)
|
||||
self._write_cache(cache_key, pools, member_rows, prune_stats, verbose=verbose)
|
||||
|
||||
nonempty = sum(1 for v in pools.values() if v)
|
||||
if verbose:
|
||||
sizes = [len(v) for v in pools.values()]
|
||||
print(
|
||||
f" 选股完成:{nonempty}/{len(pools)} 个决策日选出非空股票池,"
|
||||
f"成员数 {min(sizes) if sizes else 0}~{max(sizes) if sizes else 0},"
|
||||
f"累计出现过的股票 {len(set().union(*pools.values())) if pools else 0} 只",
|
||||
flush=True,
|
||||
)
|
||||
if not nonempty:
|
||||
raise DataGapError(
|
||||
"每日选股未产出任何非空股票池:请检查筛选条件与数据覆盖。"
|
||||
)
|
||||
|
||||
# --- 第二趟:模拟(复用既有引擎)---
|
||||
engine = BacktestEngine(
|
||||
self.strategy,
|
||||
backtest=self.bt,
|
||||
repo=repo,
|
||||
universe_by_refresh=pools,
|
||||
signal_frequency_days=sig_step,
|
||||
pool_exit_action=self.daily.pool_exit_action,
|
||||
profile_on_trade=bool(self.daily.profile_on_trade),
|
||||
)
|
||||
result = engine.run(
|
||||
start=start, end=end, persist=persist, mode="daily", verbose=verbose
|
||||
)
|
||||
|
||||
# --- 每日选股留痕(行内容来自选股趟,run_id 来自本次模拟)---
|
||||
result["daily_screening"] = {
|
||||
"screen_count": len(pools),
|
||||
"screen_days": [str(d) for d in screen_dates],
|
||||
"pool_size_min": min(len(v) for v in pools.values()),
|
||||
"pool_size_max": max(len(v) for v in pools.values()),
|
||||
"pool_size_mean": sum(len(v) for v in pools.values()) / len(pools),
|
||||
"distinct_symbols": len(set().union(*pools.values())),
|
||||
"prune": prune_stats,
|
||||
"refresh_days": step,
|
||||
"signal_every_days": sig_step,
|
||||
"pool_exit_action": self.daily.pool_exit_action,
|
||||
"profile_on_trade": bool(self.daily.profile_on_trade),
|
||||
"repo": repo.stats(),
|
||||
"pools_from_cache": cached is not None,
|
||||
}
|
||||
if persist and self.daily.persist_daily_universe:
|
||||
result["written_daily_universe"] = self._persist_members(
|
||||
result["run_id"], member_rows, verbose=verbose
|
||||
)
|
||||
if verbose:
|
||||
self._print_changes(pools)
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 选股
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _screen_all(
|
||||
self,
|
||||
repo: PitRepo,
|
||||
screener: DailyUniverseScreener,
|
||||
screen_days: list[date],
|
||||
*,
|
||||
verbose: bool,
|
||||
) -> list[ScreenDay]:
|
||||
"""按年分块预载行情 → 区内逐日筛选 → 释放区块。"""
|
||||
chunks = _chunks(screen_days, int(self.daily.chunk_years))
|
||||
progress = DailyProgress(
|
||||
len(screen_days), int(self.daily.progress_every_days), enabled=verbose
|
||||
)
|
||||
out: list[ScreenDay] = []
|
||||
for ci, chunk in enumerate(chunks, start=1):
|
||||
t0 = time.time()
|
||||
repo.load_range(chunk[0], chunk[-1])
|
||||
if verbose:
|
||||
print(
|
||||
f" 区块 {ci}/{len(chunks)} {chunk[0]} ~ {chunk[-1]}"
|
||||
f"({len(chunk)} 个交易日,已载入行情 "
|
||||
f"{repo.stats()['market_rows']:,} 行 / "
|
||||
f"{time.time() - t0:,.1f}s)",
|
||||
flush=True,
|
||||
)
|
||||
try:
|
||||
for d in chunk:
|
||||
sd = screener.screen_day(d)
|
||||
out.append(sd)
|
||||
progress.tick(f"{d} 池内 {sd.member_count} 只")
|
||||
finally:
|
||||
repo.release_range()
|
||||
return out
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 选股结果缓存(让「选股一小时的成果」不会因模拟失败而丢失)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _cache_path(key: str) -> Path:
|
||||
from hdiv.core.paths import output_dir
|
||||
|
||||
return output_dir("output") / "cache" / f"daily_pools_{key}.json"
|
||||
|
||||
def _pools_cache_key(self, start: date, end: date, step: int) -> str:
|
||||
"""选股缓存的指纹:**只由输入决定**,不含时间戳。
|
||||
|
||||
``step``(股票池重建频率)必须在键里:用 1 日/5 日筛出的池子是不同的输入,
|
||||
共用一份缓存会静默给出错的股票池。信号频率(``--signal-every-n-days``)
|
||||
**不在**键里 —— 它只影响模拟,不影响选股结果。
|
||||
"""
|
||||
return stable_id(
|
||||
"dailypools",
|
||||
self.strategy.strategy.id,
|
||||
self.strategy.strategy.version,
|
||||
self.registry.hash_of(self.strategy),
|
||||
str(start), str(end), str(int(step)),
|
||||
)
|
||||
|
||||
def _read_cache(self, key: str) -> tuple[Any, list[dict], dict, list[date]] | None:
|
||||
p = self._cache_path(key)
|
||||
if not p.is_file():
|
||||
return None
|
||||
try:
|
||||
blob = json.loads(p.read_text(encoding="utf-8"))
|
||||
pools = {
|
||||
date.fromisoformat(d): set(v)
|
||||
for d, v in (blob.get("pools") or {}).items()
|
||||
}
|
||||
if not pools:
|
||||
return None
|
||||
return (
|
||||
pools,
|
||||
list(blob.get("member_rows") or []),
|
||||
dict(blob.get("prune") or {}),
|
||||
[date.fromisoformat(x) for x in (blob.get("screen_days") or [])],
|
||||
)
|
||||
except Exception:
|
||||
# 缓存损坏一律视为「没有缓存」并重新筛选 —— 绝不因为一个坏文件而
|
||||
# 用错的股票池回测(那会静默改变结果)。
|
||||
return None
|
||||
|
||||
def _write_cache(
|
||||
self, key: str, pools: dict[date, set[str]], member_rows: list[dict],
|
||||
prune: dict, *, verbose: bool,
|
||||
) -> None:
|
||||
p = self._cache_path(key)
|
||||
try:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
rows = []
|
||||
for r in member_rows:
|
||||
r = dict(r)
|
||||
r.pop("created_at", None) # 由落库时统一填充
|
||||
r["trade_date"] = str(r["trade_date"])
|
||||
rows.append(r)
|
||||
p.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"pools": {str(d): sorted(v) for d, v in pools.items()},
|
||||
"member_rows": rows,
|
||||
"prune": prune,
|
||||
"screen_days": [str(d) for d in sorted(pools)],
|
||||
},
|
||||
ensure_ascii=False, default=str,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
if verbose:
|
||||
mb = p.stat().st_size / 1e6
|
||||
print(f" 选股结果已缓存:{p}({mb:,.1f} MB,供重跑复用)", flush=True)
|
||||
except Exception as exc: # pragma: no cover - 缓存失败不该影响回测
|
||||
if verbose:
|
||||
print(f" ⚠ 选股结果缓存写入失败(不影响本次运行):{exc}", flush=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 落库
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _persist_members(
|
||||
self, run_id: str, member_rows: list[dict], *, verbose: bool
|
||||
) -> int:
|
||||
cfg = load_config("datasource")
|
||||
if not db.table_exists("hd_daily_universe", cfg):
|
||||
if verbose:
|
||||
print(
|
||||
" ⚠ hd_daily_universe 不存在,跳过每日选股留痕。"
|
||||
"请先执行 `python -m hdiv ddl apply`。",
|
||||
flush=True,
|
||||
)
|
||||
return 0
|
||||
if not member_rows:
|
||||
return 0
|
||||
now = datetime.now()
|
||||
rows = []
|
||||
for r in member_rows:
|
||||
r = dict(r)
|
||||
r["run_id"] = run_id
|
||||
r["created_at"] = now
|
||||
td = r.get("trade_date")
|
||||
r["trade_date"] = (
|
||||
td if isinstance(td, date) else date.fromisoformat(str(td))
|
||||
)
|
||||
rows.append(r)
|
||||
n = 0
|
||||
for i in range(0, len(rows), 2000):
|
||||
n += db.upsert_dataframe(
|
||||
"hd_daily_universe",
|
||||
pd.DataFrame(rows[i : i + 2000]),
|
||||
cfg=cfg,
|
||||
update_columns=[
|
||||
"name", "industry", "dividend_yield", "total_mv", "roe_avg",
|
||||
"listed_count", "candidate_count", "values_json",
|
||||
],
|
||||
)
|
||||
if verbose:
|
||||
print(f" 每日选股留痕:hd_daily_universe {n} 行"
|
||||
f"({len({r['trade_date'] for r in rows})} 个时点)", flush=True)
|
||||
return n
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 辅助
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _trading_days(
|
||||
self, repo: PitRepo, start: date | None, end: date | None
|
||||
) -> list[date]:
|
||||
"""确定回测区间(含 ``end=latest`` 与「起点对齐到交易日」)。"""
|
||||
from hdiv.data.repo import Repo
|
||||
|
||||
plain = Repo()
|
||||
s = start or self.bt.period.start
|
||||
raw_end = end or (
|
||||
plain.trading_day(None) if self.bt.period.end == "latest"
|
||||
else self.bt.period.end
|
||||
)
|
||||
# 用直连 Repo 定位端点(PitRepo 此时尚未载入日历)
|
||||
s = plain.trading_day(s)
|
||||
e = plain.trading_day(raw_end)
|
||||
if e <= s:
|
||||
raise HdivError(f"回测区间非法:{s} ~ {e}")
|
||||
days = plain.trading_days(s, e)
|
||||
if len(days) < 2:
|
||||
raise DataGapError(f"{s} ~ {e} 交易日不足,无法回测")
|
||||
return days
|
||||
|
||||
def _print_changes(self, pools: dict[date, set[str]]) -> None:
|
||||
"""打印股票池的进出(动态池最值得看的东西)。"""
|
||||
prev: set[str] | None = None
|
||||
enter = exit_ = 0
|
||||
events: list[str] = []
|
||||
for day in sorted(pools):
|
||||
cur = pools[day]
|
||||
if prev is not None:
|
||||
add, drop = cur - prev, prev - cur
|
||||
enter += len(add)
|
||||
exit_ += len(drop)
|
||||
if (add or drop) and len(events) < 8:
|
||||
events.append(
|
||||
f" {day}: +{len(add)} -{len(drop)}"
|
||||
+ (f" 进 {'、'.join(sorted(add)[:4])}" if add else "")
|
||||
+ (f" 出 {'、'.join(sorted(drop)[:4])}" if drop else "")
|
||||
)
|
||||
prev = cur
|
||||
if not pools:
|
||||
return
|
||||
n = max(len(pools) - 1, 1)
|
||||
print(
|
||||
f" 股票池变动:累计进入 {enter} 次 / 移出 {exit_} 次"
|
||||
f"(平均每日 {enter / n:.1f} 进 {exit_ / n:.1f} 出)",
|
||||
flush=True,
|
||||
)
|
||||
for line in events:
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
def _chunks(days: list[date], chunk_years: int) -> list[list[date]]:
|
||||
"""按自然年(或 chunk_years 年)切块。"""
|
||||
if not days:
|
||||
return []
|
||||
n = max(1, int(chunk_years))
|
||||
out: list[list[date]] = []
|
||||
cur: list[date] = []
|
||||
bucket = days[0].year
|
||||
for d in days:
|
||||
if d.year >= bucket + n:
|
||||
out.append(cur)
|
||||
cur = []
|
||||
bucket = d.year
|
||||
cur.append(d)
|
||||
if cur:
|
||||
out.append(cur)
|
||||
return out
|
||||
+349
-61
@@ -146,6 +146,50 @@ class CostModel:
|
||||
return float(dt.rates.get("gt1y", 0.0))
|
||||
|
||||
|
||||
def dividend_handling_notes(bt_cfg: BacktestConfig) -> list[str]:
|
||||
"""声明 ``backtest.yml`` 里「写了但引擎没实现」的分红/公司行为配置。
|
||||
|
||||
口径必须与 :meth:`BacktestEngine._apply_dividends` 与 :meth:`_execute`
|
||||
的实际行为逐条对应 —— 配置承诺与实际行为不一致是本项目反复记录的一类缺陷:
|
||||
run 记录看起来「一切正常」,使用者却以为某项规则生效了。
|
||||
|
||||
当前**已实现**的组合(不产生声明):
|
||||
|
||||
- ``cash_mode: reinvest`` + ``reinvest_rule: portfolio_rebalance``
|
||||
—— 分红现金回落到**可投资现金池**,下次调仓按目标权重再配置;
|
||||
这笔钱与初始资金同一个 ``cash`` 变量,可以直接用于买入(不会被隔离)。
|
||||
- ``apply_dividend_tax``(总闸)+ ``cost.yml`` 的分档税率。
|
||||
- ``handle_stock_dividend`` —— 送转股按 ``stk_div`` 调整股数、总成本不变。
|
||||
|
||||
其余取值都会逐条写入 ``hd_backtest_run.unimplemented_json``。
|
||||
"""
|
||||
d = bt_cfg.dividend
|
||||
notes: list[str] = []
|
||||
mode = str(d.cash_mode)
|
||||
rule = str(d.reinvest_rule)
|
||||
if mode != "reinvest":
|
||||
notes.append(
|
||||
f"未实现 cash_mode={mode}:引擎一律把分红现金回落到**可投资现金池**,"
|
||||
f"在下次调仓按目标权重再配置(行为等价 reinvest + portfolio_rebalance)"
|
||||
)
|
||||
elif rule != "portfolio_rebalance":
|
||||
notes.append(
|
||||
f"未实现 reinvest_rule={rule}(按同一只股票再投资):实际行为等价 "
|
||||
f"portfolio_rebalance —— 分红现金回落到可投资现金池,"
|
||||
f"在下次调仓按目标权重再配置"
|
||||
)
|
||||
if not d.handle_stock_dividend:
|
||||
notes.append(
|
||||
"未处理送转股(handle_stock_dividend=false):股数不调整,"
|
||||
"而价格是不复权价、除权日照常下跌 → 送转被记成虚假亏损"
|
||||
)
|
||||
if d.handle_rights_issue:
|
||||
notes.append(
|
||||
"未实现配股处理(handle_rights_issue):配股缴款/股数变动不入账"
|
||||
)
|
||||
return notes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 引擎
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -158,16 +202,26 @@ class BacktestEngine:
|
||||
*,
|
||||
cost: CostConfig | None = None,
|
||||
backtest: BacktestConfig | None = None,
|
||||
repo: Repo | None = None,
|
||||
frozen_reference: tuple[date, date] | None = None,
|
||||
universe_run_id: str | None = None,
|
||||
allow_lookahead_universe: bool = False,
|
||||
universe_by_refresh: dict[date, set[str]] | None = None,
|
||||
universe_refresh_days: int | None = None,
|
||||
signal_frequency_days: int | None = None,
|
||||
pool_exit_action: str = "hold",
|
||||
profile_on_trade: bool = False,
|
||||
profile_window_years: int | None = None,
|
||||
) -> None:
|
||||
db.load_dotenv_once()
|
||||
self.strategy = strategy
|
||||
self.cost_cfg = cost or load_config("cost")
|
||||
self.bt_cfg = backtest or load_config("backtest")
|
||||
self.cost = CostModel(self.cost_cfg)
|
||||
self.repo = Repo()
|
||||
#: 取数出口。默认直连数据库;daily 模式注入已批量预载的 PitRepo,
|
||||
#: 这样实时画像的逐 asof 财报面板来自内存而不是每天重查 4 张财务表
|
||||
#: (后者约 5~6 秒/时点,几千个决策时点就废掉了)。
|
||||
self.repo = repo if repo is not None else Repo()
|
||||
self.registry = StrategyRegistry()
|
||||
# frozen_reference 非空时,分位分布冻结在该区间(walk-forward 测试段必须)
|
||||
self.frozen_reference = frozen_reference
|
||||
@@ -183,6 +237,22 @@ class BacktestEngine:
|
||||
self.gate_cfg = strategy.entry.profile_gate
|
||||
self.pit: Any = None
|
||||
|
||||
# --- 每日动态股票池模式(--mode daily)的注入点,默认全部关闭 ---
|
||||
#: 外部预先算好的「交易日 → 股票池」映射。给定时引擎**不再自行筛选**:
|
||||
#: 每日选股由 DailyRunner 独立完成(它需要按区块预载行情才能跑得动),
|
||||
#: 引擎只负责模拟。默认 None,行为与改造前逐字一致。
|
||||
self.universe_by_refresh = universe_by_refresh
|
||||
#: 股票池重建频率改以**交易日**计(1 = 每个交易日)。
|
||||
#: 默认 None → 沿用 backtest.yml 的 universe_refresh_months(月)。
|
||||
self.universe_refresh_days = universe_refresh_days
|
||||
#: 信号评估频率改以**交易日**计(1 = 每个交易日)。
|
||||
self.signal_frequency_days = signal_frequency_days
|
||||
#: 持仓掉出当日股票池后的处置:hold = 只减不加(默认);sell = 清仓
|
||||
self.pool_exit_action = pool_exit_action
|
||||
#: 买卖决策发生时计算并留痕个股画像(不区分是否在当日池内)
|
||||
self.profile_on_trade = profile_on_trade
|
||||
self.profile_window_years = profile_window_years
|
||||
|
||||
@classmethod
|
||||
def from_strategy(cls, path: str | Path, **kw: Any) -> BacktestEngine:
|
||||
reg = StrategyRegistry()
|
||||
@@ -293,8 +363,10 @@ class BacktestEngine:
|
||||
rc = result["reconciliation"]
|
||||
print(
|
||||
f" 期初 {result['initial_capital']:,.0f} → 期末 {result['final_capital']:,.0f}"
|
||||
f" | 总收益 {result['total_return']:.2%} | CAGR {result['cagr']:.2%}"
|
||||
f" | 最大回撤 {result['max_drawdown']:.2%} | Sharpe {result['sharpe']:.2f}"
|
||||
f" | 总收益 {_pct(result['total_return'])}"
|
||||
f" | CAGR {_pct(result['cagr'])}"
|
||||
f" | 最大回撤 {_pct(result['max_drawdown'])}"
|
||||
f" | Sharpe {_num(result['sharpe'])}"
|
||||
f" | 成交 {result['trade_count']} 笔",
|
||||
flush=True,
|
||||
)
|
||||
@@ -373,17 +445,40 @@ class BacktestEngine:
|
||||
if self.frozen_reference
|
||||
else bt.percentile_reference.lookback_years
|
||||
)
|
||||
data_start = date(max(days[0].year - max_years - 1, 2000), 1, 1)
|
||||
# 取数起点见下面 P6 处:它要与实时画像的最长窗口取并集,
|
||||
# 因此这里不再单独定义 data_start(避免出现两个"起点"口径)。
|
||||
|
||||
# --- 股票池:按 universe_refresh_months 周期重建(PIT)---
|
||||
refresh_dates: list[date] = []
|
||||
universe_by_refresh: dict[date, set[str]] = {}
|
||||
if self.universe_by_refresh is not None:
|
||||
# 每日动态股票池:名单已由 DailyRunner 逐交易日算好(它必须先在
|
||||
# 区块内批量预载行情才跑得动,因此不能在这里临时筛)。引擎不再筛选,
|
||||
# 只按这些「决策时点」切换池子 —— 每个时点的名单仍是 PIT 的。
|
||||
universe_by_refresh = {
|
||||
d: set(v) for d, v in self.universe_by_refresh.items() if v
|
||||
}
|
||||
refresh_dates = sorted(universe_by_refresh)
|
||||
if not refresh_dates:
|
||||
raise DataGapError("每日选股未产出任何非空股票池,无法回测")
|
||||
if verbose:
|
||||
print(
|
||||
f" 每日动态股票池:{len(refresh_dates)} 个决策时点,"
|
||||
f"成员数 {min(len(v) for v in universe_by_refresh.values())}"
|
||||
f"~{max(len(v) for v in universe_by_refresh.values())} 只",
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
step_days = self.universe_refresh_days
|
||||
step = bt.schedule.universe_refresh_months
|
||||
cur = days[0]
|
||||
if step_days:
|
||||
# 按交易日步进(daily 模式):每 N 个交易日重建一次
|
||||
refresh_dates = days[:: max(1, int(step_days))]
|
||||
else:
|
||||
refresh_dates = []
|
||||
for d in days:
|
||||
if not refresh_dates or _months_between(refresh_dates[-1], d) >= step:
|
||||
refresh_dates.append(d)
|
||||
del cur
|
||||
universe_by_refresh: dict[date, set[str]] = {}
|
||||
if self.universe_run_id:
|
||||
# 未来函数守卫:股票池自带 asof。若它晚于回测起点,名单里就含有
|
||||
# 「当时不可能知道」的信息(哪些公司此后仍满足分红/质量条件),
|
||||
@@ -420,8 +515,21 @@ class BacktestEngine:
|
||||
|
||||
all_syms = sorted(set().union(*universe_by_refresh.values())) if universe_by_refresh else []
|
||||
|
||||
# --- 价格(不复权)与股息率序列 ---
|
||||
price = self.repo.price_history(all_syms, data_start, days[-1], adjust="none")
|
||||
# --- P6:价格只取一次,覆盖「引擎需要的分位窗口」与「画像需要的最长窗口」的并集 ---
|
||||
#
|
||||
# 两处窗口本来是分开取的:引擎按 backtest.yml 的 lookback_years(如 5 年),
|
||||
# 实时画像按 profile.yml 的 windows_years 最大值(如 10 年)。于是同一批股票
|
||||
# 的价格会被查两遍,而且画像那遍起点更早。这里先把两遍并起来算起点,
|
||||
# 取一次,再把同一份帧交给画像服务复用(见下面的 `price=` 参数)。
|
||||
need_pit = self.gate_cfg.enabled or self.profile_on_trade
|
||||
pit_years = 0
|
||||
if need_pit:
|
||||
from hdiv.profile.builder import ProfileBuilder
|
||||
|
||||
pit_years = max(ProfileBuilder.from_config().config.windows_years)
|
||||
price_start = date(max(days[0].year - max(max_years, pit_years) - 1, 2000), 1, 1)
|
||||
|
||||
price = self.repo.price_history(all_syms, price_start, days[-1], adjust="none")
|
||||
dividends = self.repo.dividend_records(days[-1], years_back=max_years + 3)
|
||||
dividends = dividends[dividends["symbol"].isin(set(all_syms))]
|
||||
events = build_dps_events(dividends)
|
||||
@@ -434,6 +542,22 @@ class BacktestEngine:
|
||||
g["trade_date"] = pd.to_datetime(g["trade_date"])
|
||||
px_by_sym[sym] = g.set_index("trade_date")[["open", "close"]]
|
||||
|
||||
# --- P2:股息率序列**每只股票只算一次** ---
|
||||
#
|
||||
# 原实现每个评估日都对每只股票从零重算一遍 TTM 股息率序列
|
||||
# (`ttm_dps_series(idx, events)`,idx 是从取数起点到当天的**前缀**),
|
||||
# 于是同一段历史被反复计算 —— 典型 O(交易日数²):1600 天 × 池内几十只,
|
||||
# 越到后期窗口越长、越慢。
|
||||
#
|
||||
# `ttm_dps_series` 的每一天取值只依赖「该日期 + 分红事件」,与传入的日期
|
||||
# 序列里还有哪些其它日期无关(`ttm_dps_at` 与序列右端点一致就是这条性质
|
||||
# 的现成证据,见 tests/test_dividend_smoothing.py)。因此可以先按股票算
|
||||
# 整段序列,之后每天只是切片 —— 结果逐值相同。
|
||||
_w, _g, _sm = ttm_params()
|
||||
yield_by_sym: dict[str, pd.Series] = build_yield_series(
|
||||
px_by_sym, events, ttm_days=_w, grace_days=_g, smooth_spikes=_sm
|
||||
)
|
||||
|
||||
# --- 分红事件(含送转),用于持仓期间的现金与股数调整 ---
|
||||
div_events = self.repo.dividend_events(days[0], days[-1])
|
||||
div_events = div_events[div_events["symbol"].isin(set(all_syms))]
|
||||
@@ -447,19 +571,29 @@ class BacktestEngine:
|
||||
limits = self._load_limits(all_syms, days[0], days[-1])
|
||||
|
||||
# --- 实时画像闸门:预载跨决策日共享的面板(仅在启用时)---
|
||||
if self.gate_cfg.enabled:
|
||||
# profile_on_trade(daily 模式)也会用到画像:语义是「买卖决策发生时
|
||||
# 计算并留痕」,不区分是否在当日池内,也不要求闸门开启。
|
||||
if need_pit:
|
||||
from hdiv.profile.pit import PitProfileService
|
||||
|
||||
self.pit = PitProfileService(window_years=self.gate_cfg.window_years)
|
||||
# 画像取数起点必须覆盖最长窗口(profile.yml 的 windows_years),
|
||||
# 与分位参照窗口(backtest.yml 的 lookback_years)是两个独立的量。
|
||||
pit_start = date(max(days[0].year - self.pit.max_years - 1, 2000), 1, 1)
|
||||
self.pit.prepare(all_syms, pit_start, days[-1])
|
||||
window_years = (
|
||||
self.profile_window_years
|
||||
if self.profile_window_years is not None
|
||||
else self.gate_cfg.window_years
|
||||
)
|
||||
self.pit = PitProfileService(window_years=window_years, repo=self.repo)
|
||||
# 画像取数起点必须覆盖最长窗口(profile.yml 的 windows_years);
|
||||
# price_start 已经把这个窗口考虑进来了,这里只需把**同一份价格帧**
|
||||
# 交给它复用(P6),避免同一批股票被查两遍。
|
||||
pit_start = date(max(days[0].year - pit_years - 1, 2000), 1, 1)
|
||||
self.pit.prepare(all_syms, pit_start, days[-1], price=price)
|
||||
if self.gate_cfg.enabled:
|
||||
# 成本控制:闸门规则用不到的指标不必载入财报
|
||||
self.pit.configure({r.metric for r in self.gate_cfg.rules})
|
||||
if verbose:
|
||||
print(
|
||||
f" 实时画像闸门已启用:窗口 {self.gate_cfg.window_years} 年,"
|
||||
f"{len(self.gate_cfg.rules)} 条规则,"
|
||||
f" 实时画像:窗口 {window_years} 年,"
|
||||
f"{'闸门 %d 条规则' % len(self.gate_cfg.rules) if self.gate_cfg.enabled else '仅留痕(闸门关闭)'},"
|
||||
f"面板自 {pit_start} 起载入({len(all_syms)} 只)",
|
||||
flush=True,
|
||||
)
|
||||
@@ -474,7 +608,8 @@ class BacktestEngine:
|
||||
"div_by_date": div_by_date,
|
||||
"suspend": suspend,
|
||||
"limits": limits,
|
||||
"data_start": data_start,
|
||||
"data_start": price_start,
|
||||
"yield_by_sym": yield_by_sym,
|
||||
}
|
||||
|
||||
def _load_suspend(self, syms: list[str], start: date, end: date) -> set[tuple[str, date]]:
|
||||
@@ -553,12 +688,14 @@ class BacktestEngine:
|
||||
dividend_ledger: list[dict[str, Any]] = []
|
||||
|
||||
freq = bt.schedule.signal_frequency_months
|
||||
# daily 模式:信号频率改以**交易日**计(1 = 每个交易日)
|
||||
freq_days = self.signal_frequency_days
|
||||
last_signal_month: tuple[int, int] | None = None
|
||||
bench = self._benchmark(days)
|
||||
current_universe: set[str] = set()
|
||||
last_refresh: date | None = None
|
||||
|
||||
for day in days:
|
||||
for day_index, day in enumerate(days):
|
||||
# --- (0) 股票池切换 ---
|
||||
if last_refresh is None or day in ctx["universe_by_refresh"]:
|
||||
current_universe = ctx["universe_by_refresh"].get(
|
||||
@@ -586,10 +723,18 @@ class BacktestEngine:
|
||||
cash = self._apply_dividends(day, positions, ctx, cash, dividend_ledger)
|
||||
|
||||
# --- (3) 收盘:评估信号 ---
|
||||
if (day.month, day.year) != last_signal_month:
|
||||
if last_signal_month is None or _months_between(
|
||||
if freq_days:
|
||||
# 按交易日步进(daily 模式)。原「按月」判定的语义完全保留在
|
||||
# else 分支里,两者互斥,freq_days=None 时行为与改造前一致。
|
||||
do_signal = day_index % max(1, int(freq_days)) == 0
|
||||
else:
|
||||
do_signal = (day.month, day.year) != last_signal_month and (
|
||||
last_signal_month is None
|
||||
or _months_between(
|
||||
date(last_signal_month[1], last_signal_month[0], 1), day
|
||||
) >= freq:
|
||||
) >= freq
|
||||
)
|
||||
if do_signal:
|
||||
new_signals = self._evaluate(day, cash, positions, current_universe, ctx)
|
||||
pending = [x for x in new_signals if x.kind in {"BUY", "ADD", "SELL", "TRIM"}]
|
||||
signals.extend([x for x in new_signals if x.kind not in {"BUY", "ADD", "SELL", "TRIM"}])
|
||||
@@ -643,7 +788,7 @@ class BacktestEngine:
|
||||
unimplemented.add("涨跌停约束未生效(hd_limit 在回测区间内无数据,成交按可达价格近似)")
|
||||
if not self._has_constraint_rows("hd_suspend", days[0], days[-1]):
|
||||
unimplemented.add("停牌约束未生效(hd_suspend 在回测区间内无数据)")
|
||||
# 以下三项**配置写了但引擎没实现**,必须如实声明 —— 否则 run 记录看起来
|
||||
# 以下各项**配置写了但引擎没实现**,必须如实声明 —— 否则 run 记录看起来
|
||||
# 「一切正常」,而使用者以为 backtest.yml 的 defer / reinvest 生效了。
|
||||
# (配置承诺与实际行为不一致,是本项目反复记录的一类缺陷。)
|
||||
# 注意字段归属:fill/dividend 在 backtest.yml;execution/risk 在策略 yml。
|
||||
@@ -654,21 +799,15 @@ class BacktestEngine:
|
||||
"未实现停牌/涨跌停顺延(suspended_rule / limit_up_down_rule 的 "
|
||||
"defer 分支):未成交信号在当日被**丢弃**,不会顺延到下一个可成交日"
|
||||
)
|
||||
if str(bt.dividend.cash_mode) != "hold" or bt.dividend.reinvest_rule:
|
||||
unimplemented.add(
|
||||
"未实现分红再投资规则(cash_mode=reinvest / reinvest_rule):"
|
||||
"现金分红按除权日入账后**留存为现金**,在下次调仓时按目标权重重新配置"
|
||||
)
|
||||
# 分红模式/送转/配股的声明口径集中在 dividend_handling_notes,
|
||||
# 与 _apply_dividends 的实际行为一一对应(unit test 覆盖每一档取值)。
|
||||
unimplemented.update(dividend_handling_notes(bt))
|
||||
if s.execution.signal_to_execution != "next_open" or str(bt.fill.price) != "next_open":
|
||||
unimplemented.add(
|
||||
f"未实现 signal_to_execution/fill.price 的 "
|
||||
f"{s.execution.signal_to_execution}/{bt.fill.price} 分支:"
|
||||
f"成交固定按信号次日开盘价"
|
||||
)
|
||||
if bt.dividend.handle_rights_issue:
|
||||
unimplemented.add(
|
||||
"未实现配股处理(handle_rights_issue):配股缴款/股数变动不入账"
|
||||
)
|
||||
if not bt.fill.partial_fill:
|
||||
unimplemented.add("未启用部分成交(按信号全额成交,但受资金与权重上限约束)")
|
||||
if bt.fill.max_volume_pct is not None:
|
||||
@@ -719,21 +858,14 @@ class BacktestEngine:
|
||||
px_hist = ctx["px_by_sym"].get(sym)
|
||||
if px_hist is None or px_hist.empty:
|
||||
continue
|
||||
close_hist = px_hist["close"].loc[: pd.Timestamp(day)]
|
||||
if close_hist.empty:
|
||||
# P2:整段股息率序列在 _prepare 里已按股票算好,这里只做切片。
|
||||
# 每一天的取值只依赖「该日期 + 分红事件」,与前缀里还有哪些日期无关,
|
||||
# 所以「先算整段再切前缀」与「每次按前缀重算」逐值相同 ——
|
||||
# 早先的实现是后者,代价是 O(交易日数²)。
|
||||
ser_all = ctx["yield_by_sym"].get(sym)
|
||||
if ser_all is None or ser_all.empty:
|
||||
continue
|
||||
idx = pd.DatetimeIndex(close_hist.index)
|
||||
# 参数从因子层的统一来源取,不再硬编码 ——
|
||||
# 否则改了 profile.yml 的回测也不会变(曾如此)。
|
||||
_w, _g, _sm = ttm_params()
|
||||
dps = ttm_dps_series(
|
||||
idx, ctx["events"].get(sym, pd.DataFrame()),
|
||||
ttm_days=_w, grace_days=_g, smooth_spikes=_sm,
|
||||
)
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
y = np.where(close_hist.to_numpy(dtype="float64") > 0,
|
||||
dps / close_hist.to_numpy(dtype="float64"), np.nan)
|
||||
ser = pd.Series(y, index=idx).dropna()
|
||||
ser = ser_all.loc[: pd.Timestamp(day)]
|
||||
if ser.empty:
|
||||
continue
|
||||
current = float(ser.iloc[-1])
|
||||
@@ -752,7 +884,17 @@ class BacktestEngine:
|
||||
pct = float((ref_ser <= current).sum() / ref_ser.size * 100.0)
|
||||
|
||||
held = sym in positions
|
||||
# 成交/信号记的是**当日该股最后一个可得收盘价**(停牌时就是最近一次收盘)。
|
||||
# 这与股息率序列的最后一点不是一回事:序列里只保留收益率非缺失的日期,
|
||||
# 价格则必须有值才能下单,所以这里单独取一次(O(log n) 切片)。
|
||||
close_hist = px_hist["close"].loc[: pd.Timestamp(day)]
|
||||
if close_hist.empty:
|
||||
continue
|
||||
price = float(close_hist.iloc[-1])
|
||||
# 动态股票池的语义核心:池子回答「今天能**买**什么」,
|
||||
# 不自动回答「必须卖什么」。掉出池子的持仓在 pool_exit_action=hold
|
||||
# 时只被禁止加仓,仍按股息率分位规则决定减仓/卖出。
|
||||
in_pool = sym in universe
|
||||
common = {
|
||||
"dividend_yield": round(current, 6),
|
||||
"yield_percentile": round(pct, 2),
|
||||
@@ -764,6 +906,8 @@ class BacktestEngine:
|
||||
"min_observations": int(
|
||||
self.bt_cfg.percentile_reference.min_observations),
|
||||
"close": price,
|
||||
"in_universe": bool(in_pool),
|
||||
"universe_size": len(universe),
|
||||
}
|
||||
|
||||
# 统一阶梯:先算目标仓位,再决定动作。
|
||||
@@ -779,48 +923,74 @@ class BacktestEngine:
|
||||
sym, day, current, pct, price, common, gate,
|
||||
))
|
||||
continue
|
||||
out.append(Signal(
|
||||
out.append(self._trade_signal(
|
||||
Signal(
|
||||
sym, day, "BUY", target, current, pct, price,
|
||||
{**common,
|
||||
"rule": f"股息率历史分位 {pct:.1f}% >= P{s.entry.yield_percentile:g},"
|
||||
f"目标仓位 {target:.0%}",
|
||||
"reason_cn": "股息率进入历史高位区间,达到买入阈值",
|
||||
**({"profile_gate": gate} if gate else {})},
|
||||
), day,
|
||||
))
|
||||
else:
|
||||
# --- 持仓掉出当日股票池 ---
|
||||
if not in_pool and self.pool_exit_action == "sell":
|
||||
out.append(self._trade_signal(Signal(
|
||||
sym, day, "SELL", 0.0, current, pct, price,
|
||||
{**common,
|
||||
"rule": f"掉出当日股票池(池内 {len(universe)} 只),"
|
||||
f"pool_exit_action=sell → 清仓",
|
||||
"reason_cn": "动态股票池移出,按配置清仓"},
|
||||
), day))
|
||||
continue
|
||||
if target is None:
|
||||
continue # 死区:保持仓位
|
||||
if abs(target) <= 1e-9:
|
||||
out.append(Signal(
|
||||
out.append(self._trade_signal(Signal(
|
||||
sym, day, "SELL", 0.0, current, pct, price,
|
||||
{**common,
|
||||
"rule": f"股息率历史分位 {pct:.1f}% <= P{s.exit.yield_percentile:g}",
|
||||
"reason_cn": "股息率回落至历史低位区间,达到卖出阈值,清仓"},
|
||||
))
|
||||
), day))
|
||||
elif target < 1.0:
|
||||
out.append(Signal(
|
||||
out.append(self._trade_signal(Signal(
|
||||
sym, day, "TRIM", target, current, pct, price,
|
||||
{**common,
|
||||
"rule": f"分位 {pct:.1f}% 对应目标仓位 {target:.0%}",
|
||||
"reason_cn": "股息率分位变动,按阶梯规则调整仓位"},
|
||||
))
|
||||
), day))
|
||||
else:
|
||||
# ADD 也是买入 —— 同样要过实时画像闸门。
|
||||
# 被拒时**不动已有仓位**(REJECT 不进入待成交队列),
|
||||
# 因为闸门的语义是「不值得买」,不是「该卖」。
|
||||
if not in_pool and self.pool_exit_action == "hold":
|
||||
# 只减不加:留下一条 HOLD 记录,说明「想加但被池子挡住」。
|
||||
# 不进入待成交队列(kind 不在 BUY/ADD/SELL/TRIM 里)。
|
||||
out.append(self._trade_signal(Signal(
|
||||
sym, day, "HOLD", 0.0, current, pct, price,
|
||||
{**common,
|
||||
"rule": f"分位 {pct:.1f}% 本应加仓至 {target:.0%},"
|
||||
f"但该股已掉出当日股票池(pool_exit_action=hold)",
|
||||
"reason_cn": "已掉出动态股票池,停止加仓(不清仓)",
|
||||
"skip_reason": "OUT_OF_UNIVERSE",
|
||||
"executed": False,
|
||||
"rule_hit": "pool_exit_hold"},
|
||||
), day))
|
||||
continue
|
||||
gate = self._gate(sym, day)
|
||||
if gate is not None and gate["verdict"] != "PASS":
|
||||
out.append(self._reject_signal(
|
||||
sym, day, current, pct, price, common, gate,
|
||||
))
|
||||
continue
|
||||
out.append(Signal(
|
||||
out.append(self._trade_signal(Signal(
|
||||
sym, day, "ADD", target, current, pct, price,
|
||||
{**common,
|
||||
"rule": f"分位 {pct:.1f}% 对应目标仓位 {target:.0%}",
|
||||
"reason_cn": "股息率分位变动,按阶梯规则调整仓位",
|
||||
**({"profile_gate": gate} if gate else {})},
|
||||
))
|
||||
), day))
|
||||
return out
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -855,6 +1025,40 @@ class BacktestEngine:
|
||||
min_window_coverage=self.gate_cfg.min_window_coverage,
|
||||
)
|
||||
|
||||
def _trade_signal(self, sig: Signal, day: date) -> Signal:
|
||||
"""买卖信号的统一出口:在这里附加**个股画像留痕**。
|
||||
|
||||
``profile_on_trade``(daily 模式的默认)语义是「买卖决策发生时计算并留痕」:
|
||||
买入/加仓、卖出/减仓都会附上该股在决策日的实时画像,**不区分它是否在
|
||||
当日股票池内** —— 掉出池子的持仓被卖出时,更需要「当时它长什么样」的证据。
|
||||
|
||||
闸门(``profile_gate``)与此是两件事:闸门决定「买不买得到」,
|
||||
留痕只记录「当时看到了什么」。闸门关闭时留痕照常工作。
|
||||
"""
|
||||
note = self._profile_note(sig.symbol, day)
|
||||
if note is not None:
|
||||
sig.reason["profile"] = note
|
||||
return sig
|
||||
|
||||
def _profile_note(self, sym: str, day: date) -> dict[str, Any] | None:
|
||||
"""该股在 ``day`` 的实时画像快照(供 reason_json 留痕)。"""
|
||||
if not self.profile_on_trade or self.pit is None:
|
||||
return None
|
||||
snap = self.pit.snapshot(sym, day)
|
||||
if snap is None:
|
||||
return {"asof": str(day), "status": "NO_SNAPSHOT"}
|
||||
return {
|
||||
"asof": str(snap.asof),
|
||||
"window_years": snap.window_years,
|
||||
"values": {k: round(v, 8) for k, v in snap.values.items()
|
||||
if isinstance(v, (int, float))},
|
||||
"percentiles": {k: round(v, 4) for k, v in snap.percentiles.items()},
|
||||
"status": dict(snap.status),
|
||||
"n_obs": dict(snap.n_obs),
|
||||
"coverage": {k: round(v, 4) for k, v in snap.coverage.items()},
|
||||
"scores": {k: round(v, 4) for k, v in snap.scores.items()},
|
||||
}
|
||||
|
||||
def _reject_signal(
|
||||
self, sym: str, day: date, current: float, pct: float, price: float,
|
||||
common: dict[str, Any], gate: dict[str, Any],
|
||||
@@ -1068,38 +1272,62 @@ class BacktestEngine:
|
||||
这是「不复权价 + 独立分红现金流」的关键实现 ——
|
||||
与复权价配合会造成重复计算,因此价格一律用不复权。
|
||||
|
||||
**现金与送转必须各自独立判断**。纯送转(如 10 送 10:股价腰斩、
|
||||
股数翻倍)的 ``cash_div_tax`` 是 NULL/0 而 ``stk_div`` > 0;早期实现
|
||||
在算送股**之前**就按 ``per_share <= 0`` 整行 ``continue``,等于把纯送转
|
||||
丢掉 —— 而价格是不复权价、除权日仍会下跌,于是凭空记出一笔亏损。
|
||||
实测本库 2015-2026 区间内高股息池成员有 824 笔纯送转(0.3~1.2 股/股)。
|
||||
|
||||
分红**不计入成交流水**(``hd_backtest_trade`` 只记录买卖),
|
||||
它直接改变现金与持股数量,并通过净值曲线体现。
|
||||
"""
|
||||
stock_div_on = bool(self.bt_cfg.dividend.handle_stock_dividend)
|
||||
# 红利税总闸:backtest.yml 的 apply_dividend_tax 与 cost.yml 的
|
||||
# dividend_tax.enabled 必须同时为真(前者曾是死字段,改了不起作用)。
|
||||
tax_on = bool(self.bt_cfg.dividend.apply_dividend_tax)
|
||||
for r in ctx["div_by_date"].get(day, []):
|
||||
sym = r["symbol"]
|
||||
pos = positions.get(sym)
|
||||
if pos is None or pos.quantity <= 0:
|
||||
continue
|
||||
holding = pos.holding_days(day)
|
||||
rate = self.cost.dividend_tax_rate(holding)
|
||||
# 必须用 NaN 感知的转换:`float(x or 0.0)` 在 x=NaN 时会返回 NaN
|
||||
# (NaN 是真值),导致后续 gross/tax/net 全为 NaN,
|
||||
# 对账时 `NaN <= x` 为 False,表现为「税后大于税前」的假象。
|
||||
per_share = _fnum(r.get("cash_div_tax"))
|
||||
# 只处理**正的**现金分红:跳过 NULL / 0 / 负数(纯送转或数据异常)。
|
||||
if per_share is None or per_share <= 0:
|
||||
stk = _fnum(r.get("stk_div")) or 0.0
|
||||
has_cash = per_share is not None and per_share > 0
|
||||
has_stock = stk > 0
|
||||
# 既无现金也无送转:NULL / 0 / 负数(数据异常)—— 这才是该跳过的行
|
||||
if not has_cash and not has_stock:
|
||||
continue
|
||||
holding = pos.holding_days(day)
|
||||
rate = self.cost.dividend_tax_rate(holding) if tax_on else 0.0
|
||||
gross = tax = net = 0.0
|
||||
if has_cash:
|
||||
gross = pos.quantity * per_share
|
||||
tax = gross * rate
|
||||
net = gross - tax
|
||||
if net > 0:
|
||||
cash += net
|
||||
# 送转股:股数增加,成本不变(成本不变更符合税务口径)
|
||||
stk = _fnum(r.get("stk_div")) or 0.0
|
||||
# 送转股:股数增加,**总成本不变** → 每股成本随之下降,与「不复权价
|
||||
# 在除权日下跌、股数补上」严格相抵,市值不变。
|
||||
# 不重算 avg_cost 会让后续卖出的 realized_pnl 按除权前的旧每股成本
|
||||
# 多扣成本(1000 股 @10 送 0.3 后全卖 @10 会记成 0 而非 +3000)。
|
||||
shares_added = 0.0
|
||||
if stk > 0 and self.bt_cfg.dividend.handle_stock_dividend:
|
||||
if has_stock and stock_div_on:
|
||||
shares_added = pos.quantity * stk
|
||||
pos.quantity += shares_added
|
||||
if pos.quantity > 0:
|
||||
pos.avg_cost = pos.cost_basis / pos.quantity
|
||||
ledger.append({
|
||||
"ex_date": day, "symbol": sym, "quantity": pos.quantity,
|
||||
"cash_div_tax": per_share, "gross": gross, "tax": tax, "net": net,
|
||||
"cash_div_tax": per_share if has_cash else 0.0,
|
||||
"stk_div": stk,
|
||||
"stk_bo_rate": _fnum(r.get("stk_bo_rate")) or 0.0,
|
||||
"stk_co_rate": _fnum(r.get("stk_co_rate")) or 0.0,
|
||||
"gross": gross, "tax": tax, "net": net,
|
||||
"holding_days": holding, "tax_rate": rate, "shares_added": shares_added,
|
||||
"stock_div_applied": bool(has_stock and stock_div_on),
|
||||
"cash_mode": self.bt_cfg.dividend.cash_mode,
|
||||
})
|
||||
return cash
|
||||
@@ -1350,6 +1578,47 @@ def _write(table: str, df: pd.DataFrame, cfg: Any, updates: list[str]) -> int:
|
||||
return n
|
||||
|
||||
|
||||
def build_yield_series(
|
||||
px_by_sym: dict[str, pd.DataFrame],
|
||||
events: dict[str, pd.DataFrame],
|
||||
*,
|
||||
ttm_days: int | None = None,
|
||||
grace_days: int | None = None,
|
||||
smooth_spikes: bool | None = None,
|
||||
) -> dict[str, pd.Series]:
|
||||
"""按股票预计算**整段**股息率序列(P2)。
|
||||
|
||||
引擎原先在每个评估日对每只股票从零重算一遍「到当天为止」的前缀序列,
|
||||
代价是 O(交易日数²)。``ttm_dps_series`` 的每一天取值只依赖「该日期 + 分红事件」,
|
||||
与传入的日期序列里还有哪些其它日期无关,所以「整段算一次再切片」与
|
||||
「每天按前缀重算」逐值相同(由
|
||||
``tests/test_daily.py::test_ttm_dps_series_prefix_equals_full`` 锁定)。
|
||||
|
||||
返回 ``{symbol: Series[close>0 的日期 -> 股息率]}``,索引为 ``DatetimeIndex``。
|
||||
"""
|
||||
w, g, sm = ttm_params()
|
||||
ttm_days = w if ttm_days is None else ttm_days
|
||||
grace_days = g if grace_days is None else grace_days
|
||||
smooth_spikes = sm if smooth_spikes is None else smooth_spikes
|
||||
|
||||
out: dict[str, pd.Series] = {}
|
||||
for sym, frame in px_by_sym.items():
|
||||
close = frame["close"]
|
||||
if close.empty:
|
||||
continue
|
||||
dps = ttm_dps_series(
|
||||
pd.DatetimeIndex(close.index), events.get(sym, pd.DataFrame()),
|
||||
ttm_days=ttm_days, grace_days=grace_days, smooth_spikes=smooth_spikes,
|
||||
)
|
||||
c = close.to_numpy(dtype="float64")
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
y = np.where(c > 0, dps / c, np.nan)
|
||||
s = pd.Series(y, index=close.index).dropna()
|
||||
if not s.empty:
|
||||
out[sym] = s
|
||||
return out
|
||||
|
||||
|
||||
def _fnum(v: Any) -> float | None:
|
||||
"""把值转成 float,None / NaN / 非数 一律返回 None。
|
||||
|
||||
@@ -1365,6 +1634,25 @@ def _fnum(v: Any) -> float | None:
|
||||
return None if (f != f or np.isinf(f)) else f
|
||||
|
||||
|
||||
def _pct(v: Any) -> str:
|
||||
"""百分比格式化;不可计算时显示「—」。
|
||||
|
||||
**短区间下 Sharpe / CAGR 会不可计算**(样本不足,见
|
||||
``analysis/performance.py``),而它们此前被直接 ``:.2%`` 格式化 ——
|
||||
实测:15 个交易日的回测会抛 ``TypeError: unsupported format string
|
||||
passed to NoneType`` 并以完整 traceback 结束。那不是「用户可理解的错误」,
|
||||
而是把「指标不可计算」这一个正常状态说成了程序缺陷。
|
||||
"""
|
||||
f = _fnum(v)
|
||||
return "—" if f is None else f"{f:.2%}"
|
||||
|
||||
|
||||
def _num(v: Any, digits: int = 2) -> str:
|
||||
"""数值格式化;不可计算时显示「—」。见 :func:`_pct`。"""
|
||||
f = _fnum(v)
|
||||
return "—" if f is None else f"{f:,.{digits}f}"
|
||||
|
||||
|
||||
def _round_lot(qty: float, lot: int = 100) -> float:
|
||||
"""A 股按手(100 股)取整。"""
|
||||
if qty <= 0:
|
||||
|
||||
@@ -163,16 +163,20 @@ class WalkForwardRunner:
|
||||
)
|
||||
|
||||
if verbose:
|
||||
# 用容忍 None 的格式化:短窗口下 CAGR / Sharpe 会不可计算,
|
||||
# 直接 :.2% 会抛 TypeError 并以 traceback 结束(见 engine._pct)。
|
||||
from hdiv.backtest.engine import _pct
|
||||
|
||||
print(
|
||||
f" 训练: 收益 {train_res['total_return']:>8.2%} "
|
||||
f"回撤 {train_res['max_drawdown']:>8.2%} "
|
||||
f"CAGR {train_res['cagr']:>7.2%} 成交 {train_res['trade_count']:>3d}",
|
||||
f" 训练: 收益 {_pct(train_res['total_return']):>8} "
|
||||
f"回撤 {_pct(train_res['max_drawdown']):>8} "
|
||||
f"CAGR {_pct(train_res['cagr']):>7} 成交 {train_res['trade_count']:>3d}",
|
||||
flush=True,
|
||||
)
|
||||
print(
|
||||
f" 测试: 收益 {test_res['total_return']:>8.2%} "
|
||||
f"回撤 {test_res['max_drawdown']:>8.2%} "
|
||||
f"CAGR {test_res['cagr']:>7.2%} 成交 {test_res['trade_count']:>3d}",
|
||||
f" 测试: 收益 {_pct(test_res['total_return']):>8} "
|
||||
f"回撤 {_pct(test_res['max_drawdown']):>8} "
|
||||
f"CAGR {_pct(test_res['cagr']):>7} 成交 {test_res['trade_count']:>3d}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
+154
-8
@@ -15,13 +15,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from hdiv.core.errors import HdivError
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from datetime import date, datetime
|
||||
from datetime import date
|
||||
|
||||
from hdiv import __version__
|
||||
from hdiv.core.errors import HdivError
|
||||
|
||||
|
||||
def _setup_logging(level: str) -> None:
|
||||
@@ -63,6 +63,21 @@ def cmd_ddl(args: argparse.Namespace) -> int:
|
||||
|
||||
def cmd_sync(args: argparse.Namespace) -> int:
|
||||
target = args.target
|
||||
if target == "daily":
|
||||
from hdiv.data.sync import daily
|
||||
|
||||
summary = daily.run(
|
||||
asof=date.fromisoformat(args.asof) if args.asof else None,
|
||||
lookback_days=args.lookback_days,
|
||||
only=args.only,
|
||||
include_financial=not args.no_financial,
|
||||
financial_limit=args.financial_limit,
|
||||
dry_run=args.dry_run,
|
||||
verbose=not args.json,
|
||||
)
|
||||
if args.json:
|
||||
print(json.dumps(summary, ensure_ascii=False, default=str, indent=2))
|
||||
return 0 if summary.get("ok", True) else 1
|
||||
if target == "dividend":
|
||||
from hdiv.data.sync import dividend
|
||||
|
||||
@@ -289,6 +304,64 @@ def cmd_backtest(args: argparse.Namespace) -> int:
|
||||
from hdiv.backtest.engine import BacktestEngine
|
||||
from hdiv.backtest.walk_forward import WalkForwardRunner
|
||||
|
||||
if args.mode == "daily":
|
||||
# 每日动态股票池:与 walk-forward 是两种不同的检验(见
|
||||
# hdiv/backtest/daily.py 的模块文档)。同样拒绝 --universe-run ——
|
||||
# 固定股票池与「每日动态」在定义上互斥,而且冻结名单自带未来信息。
|
||||
if args.universe_run:
|
||||
raise HdivError(
|
||||
"daily 模式不支持 --universe-run。\n"
|
||||
" 原因:daily 的定义就是「每个交易日按当时可见的数据重新选股」,\n"
|
||||
" 而固定股票池自带一个 asof(例如 2025-01-21),把它套到\n"
|
||||
" 更早的年份就是用未来信息选股(名单里含有回测起点时不可能\n"
|
||||
" 知道的信息)。\n"
|
||||
" 正确做法:去掉 --universe-run,让引擎每日重新筛选:\n"
|
||||
" python -m hdiv backtest --mode daily --start 2020-01-05\n"
|
||||
" 若确实要检验「固定股票池 + 月频调仓」,请用普通回测:\n"
|
||||
" python -m hdiv backtest --universe-run <run_id>"
|
||||
)
|
||||
_reject_no_persist_with_html(args, "hdiv backtest --mode daily")
|
||||
from hdiv.backtest.daily import DailyRunner
|
||||
|
||||
runner = DailyRunner.from_strategy(args.strategy)
|
||||
res = runner.run(
|
||||
start=date.fromisoformat(args.start) if args.start else None,
|
||||
end=date.fromisoformat(args.end) if args.end else None,
|
||||
persist=not args.no_persist,
|
||||
refresh_pools=bool(args.refresh_pools),
|
||||
every_n_days=args.every_n_days,
|
||||
signal_every_n_days=args.signal_every_n_days,
|
||||
)
|
||||
if args.no_persist:
|
||||
print()
|
||||
print("注意:--no-persist 已启用,本次回测与每日选股**未写入数据库**,"
|
||||
"不会出现在 Web 前端。")
|
||||
from hdiv.backtest.engine import _num as _fnum, _pct as _fpct
|
||||
|
||||
ds = res.get("daily_screening") or {}
|
||||
cadence = ""
|
||||
if int(ds.get("refresh_days") or 1) > 1 or int(ds.get("signal_every_days") or 1) > 1:
|
||||
cadence = (f"(粗粒度:选股每 {ds.get('refresh_days')} 日、"
|
||||
f"信号每 {ds.get('signal_every_days')} 日,"
|
||||
f"不可与逐日口径比较)")
|
||||
print(
|
||||
f"每日动态股票池回测 {res['run_id']} "
|
||||
f"{res['start_date']} ~ {res['end_date']}\n"
|
||||
f" 选股 {ds.get('screen_count')} 次(每 {ds.get('refresh_days')} 个交易日),"
|
||||
f"池内 {ds.get('pool_size_min')}~{ds.get('pool_size_max')} 只,"
|
||||
f"累计个股 {ds.get('distinct_symbols')} 只\n"
|
||||
f" 期初 {res['initial_capital']:,.0f} → 期末 {res['final_capital']:,.0f}\n"
|
||||
f" 总收益 {_fpct(res['total_return'])} CAGR {_fpct(res['cagr'])} "
|
||||
f"最大回撤 {_fpct(res['max_drawdown'])} Sharpe {_fnum(res['sharpe'])}\n"
|
||||
f" 成交 {res['trade_count']} 笔{cadence}"
|
||||
)
|
||||
_print_frontend_hint(res["run_id"])
|
||||
if args.html:
|
||||
from hdiv.report.build import build_backtest_report
|
||||
|
||||
print("HTML:", build_backtest_report(res["run_id"]))
|
||||
return 0
|
||||
|
||||
if args.mode == "walkforward":
|
||||
# 冻结股票池与 walk-forward 在时序上不兼容:
|
||||
# 股票池有其自身的 asof(如 2025-01-21),而 walk-forward 的窗口从
|
||||
@@ -326,13 +399,16 @@ def cmd_backtest(args: argparse.Namespace) -> int:
|
||||
if args.no_persist:
|
||||
print()
|
||||
print("注意:--no-persist 已启用,本次回测**未写入数据库**,不会出现在 Web 前端。")
|
||||
from hdiv.backtest.engine import _num as _fnum, _pct as _fpct
|
||||
|
||||
print(
|
||||
f"回测 {res['run_id']} {res['start_date']} ~ {res['end_date']}\n"
|
||||
f" 期初 {res['initial_capital']:,.0f} → 期末 {res['final_capital']:,.0f}\n"
|
||||
f" 总收益 {res['total_return']:.2%} CAGR {res['cagr']:.2%} "
|
||||
f"最大回撤 {res['max_drawdown']:.2%} Sharpe {res['sharpe']:.2f}\n"
|
||||
f" 总收益 {_fpct(res['total_return'])} CAGR {_fpct(res['cagr'])} "
|
||||
f"最大回撤 {_fpct(res['max_drawdown'])} Sharpe {_fnum(res['sharpe'])}\n"
|
||||
f" 成交 {res['trade_count']} 笔"
|
||||
)
|
||||
_print_frontend_hint(res["run_id"])
|
||||
if args.html:
|
||||
from hdiv.report.build import build_backtest_report
|
||||
|
||||
@@ -340,6 +416,25 @@ def cmd_backtest(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _print_frontend_hint(run_id: str) -> None:
|
||||
"""告诉用户「去哪里看明细」——结果已经入库,不需要再跑第二条命令。
|
||||
|
||||
这一步是刻意的:``--mode daily`` 一次运行会把完整明细写进
|
||||
``hd_backtest_run/_equity/_position/_trade/_signal/_metric`` 与
|
||||
``hd_daily_universe``,前端「回测记录」页能直接下钻。曾把「导出结果」
|
||||
写成需要另跑一个脚本并手工传 run_id,那等于让用户猜 run_id 从哪来 ——
|
||||
而它本来就在上面这一行里。
|
||||
"""
|
||||
print(
|
||||
f"\n明细已入库,直接在前端查看(无需再执行其它命令):\n"
|
||||
f" 打开 Web 前端 → 「回测记录」→ 选中 run_id "
|
||||
f"{run_id[:12]}…(模式 daily)\n"
|
||||
f" 页面内可看:净值曲线与基准、持仓明细、逐笔成交与理由、\n"
|
||||
f" 成交个股的实时画像、每日动态股票池(逐日选股留痕)、\n"
|
||||
f" 点任一成交个股 → 趋势与买卖点 + 决策时点实时画像"
|
||||
)
|
||||
|
||||
|
||||
def cmd_sensitivity(args: argparse.Namespace) -> int:
|
||||
from hdiv.analysis.sensitivity import SensitivityRunner
|
||||
|
||||
@@ -384,7 +479,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
)
|
||||
s.add_argument(
|
||||
"target",
|
||||
choices=["dividend", "financial", "index", "price", "trading", "backfill"],
|
||||
choices=["daily", "dividend", "financial", "index", "price", "trading", "backfill"],
|
||||
)
|
||||
s.add_argument("--symbols", nargs="*")
|
||||
s.add_argument("--only-missing", action="store_true")
|
||||
@@ -405,6 +500,33 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
s.add_argument("--basic-end", default="2019-12-31", help="daily_basic 回补终点")
|
||||
s.add_argument("--no-resume", action="store_true")
|
||||
s.add_argument("--no-weight", action="store_true")
|
||||
# ---- sync daily:按缺口增量补齐(缺几天抓几天)----
|
||||
s.add_argument(
|
||||
"--asof", default=None,
|
||||
help="sync daily:以哪天为「今天」(YYYY-MM-DD,默认系统当天)",
|
||||
)
|
||||
s.add_argument(
|
||||
"--lookback-days", type=int, default=45,
|
||||
help="sync daily:向前回溯多少自然日找缺口(默认 45;更早的空洞属于回补)",
|
||||
)
|
||||
s.add_argument(
|
||||
"--only", nargs="*", default=None,
|
||||
help="sync daily:只同步指定目标(price/trading/index/dividend/financial,"
|
||||
"或单个目标 daily/adj_factor/daily_basic/suspend/limit)",
|
||||
)
|
||||
s.add_argument(
|
||||
"--no-financial", action="store_true",
|
||||
help="sync daily:跳过财报四表(它们是按股票拉取,最耗时)",
|
||||
)
|
||||
s.add_argument(
|
||||
"--financial-limit", type=int, default=500,
|
||||
help="sync daily:单次最多重拉多少只股票的财报(按市值降序,默认 500)",
|
||||
)
|
||||
s.add_argument(
|
||||
"--dry-run", action="store_true",
|
||||
help="sync daily:只打印待抓清单,不调用接口、不写库",
|
||||
)
|
||||
s.add_argument("--json", action="store_true", help="sync daily:以 JSON 输出结果")
|
||||
s.set_defaults(func=cmd_sync)
|
||||
|
||||
# audit
|
||||
@@ -505,10 +627,18 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
# backtest
|
||||
b = sub.add_parser(
|
||||
"backtest", help="回测与 Walk-forward",
|
||||
description="执行历史回测;--mode walkforward 执行滚动样本外验证。",
|
||||
description=(
|
||||
"执行历史回测。--mode walkforward 执行滚动样本外验证(过拟合检验);"
|
||||
"--mode daily 执行「每日动态股票池」连续推演:从 --start 起每个交易日"
|
||||
"重新选股、每个交易日判断买卖点。"
|
||||
),
|
||||
)
|
||||
b.add_argument("-s", "--strategy", default="config/strategy/high_dividend_v1.yml")
|
||||
b.add_argument("--mode", choices=["single", "walkforward"], default="single")
|
||||
b.add_argument(
|
||||
"--mode", choices=["single", "walkforward", "daily"], default="single",
|
||||
help="single=单条路径回测;walkforward=滚动样本外;"
|
||||
"daily=每日动态股票池(每个交易日重新选股)",
|
||||
)
|
||||
b.add_argument("--start", default=None)
|
||||
b.add_argument("--end", default=None)
|
||||
b.add_argument(
|
||||
@@ -522,6 +652,22 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
"会如实写入 hd_backtest_run.unimplemented_json)",
|
||||
)
|
||||
b.add_argument("--no-persist", action="store_true")
|
||||
b.add_argument(
|
||||
"--refresh-pools", action="store_true",
|
||||
help="daily 模式:忽略已缓存的每日选股结果,强制重新逐日筛选(默认复用缓存)",
|
||||
)
|
||||
b.add_argument(
|
||||
"--every-n-days", type=int, default=None, metavar="N",
|
||||
help="daily 模式:股票池每 N 个交易日重建一次(默认 1 = 每个交易日)。"
|
||||
"N 越大选股耗时越少(近似 ÷N),代价是池子更新变粗;"
|
||||
"判定规则不变,但结果不可与逐日口径直接比较",
|
||||
)
|
||||
b.add_argument(
|
||||
"--signal-every-n-days", type=int, default=None, metavar="N",
|
||||
help="daily 模式:买卖每 N 个交易日判断一次(默认 1 = 每个交易日)。"
|
||||
"这是**模拟阶段**的主要速度旋钮(信号评估 + 逐笔决策画像都按 N 摊薄),"
|
||||
"但交易机会与换手会随之下降",
|
||||
)
|
||||
# HTML 报告已降级为「导出件」:默认不生成,需要时显式 --html。
|
||||
# --no-html 保留为空操作,避免历史命令与脚本报错。
|
||||
b.add_argument("--no-html", action="store_true",
|
||||
|
||||
@@ -515,6 +515,51 @@ class PercentileReferenceConfig(StrictModel):
|
||||
return self
|
||||
|
||||
|
||||
class DailyConfig(StrictModel):
|
||||
"""每日动态股票池模式(``backtest --mode daily``)。
|
||||
|
||||
与 walk-forward 的区别在**时间结构与目的**:walk-forward 切多个
|
||||
(train, test) 窗口来检验过拟合;daily 只跑**一条连续路径**
|
||||
``[start, latest]``,目的回答「从某天起按这套规则每天重新选股、每天判断
|
||||
买卖,实际会怎样」。其中「股票池每日变化」是本模式的核心,因此:
|
||||
|
||||
- ``universe_refresh_days=1``:**每个交易日**按当日可见数据重建股票池
|
||||
(PIT),而不是 walk-forward 的「每个窗口一次」;
|
||||
- ``signal_frequency_days=1``:每个交易日评估买卖点;
|
||||
- ``pool_exit_action``:持仓掉出当日股票池后的处置 ——
|
||||
``hold`` = **只减不加**(默认,符合「池子决定能买什么,不决定必须卖」),
|
||||
``sell`` = 掉出即清仓。
|
||||
|
||||
**刻意没有 ``enabled`` 开关**:模式由命令行 ``--mode daily`` 显式选择。
|
||||
加一个「配置写了但没有任何代码路径会读」的字段,正是本项目反复记录的
|
||||
「配置承诺与实际行为不一致」那一类缺陷。
|
||||
"""
|
||||
|
||||
#: 股票池重建频率(交易日)。1 = 每个交易日重新筛选
|
||||
universe_refresh_days: int = 1
|
||||
#: 信号评估频率(交易日)。1 = 每个交易日评估
|
||||
signal_frequency_days: int = 1
|
||||
#: 持仓掉出当日股票池后的处置:hold = 只减不加;sell = 清仓
|
||||
pool_exit_action: Literal["hold", "sell"] = "hold"
|
||||
#: 买卖决策发生时计算并留痕个股画像(无论该股是否在当日池内)
|
||||
profile_on_trade: bool = True
|
||||
#: 是否把每日入选成员写入 hd_daily_universe
|
||||
persist_daily_universe: bool = True
|
||||
#: 批量预载的分块年数(内存控制)
|
||||
chunk_years: int = 1
|
||||
#: 每日股票池重建时打印进度的间隔(交易日);1 = 每天都打印
|
||||
progress_every_days: int = 20
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check(self) -> DailyConfig:
|
||||
for name in ("universe_refresh_days", "signal_frequency_days", "chunk_years"):
|
||||
if getattr(self, name) <= 0:
|
||||
raise SchemaValidationError(f"daily.{name} 必须为正")
|
||||
if self.progress_every_days <= 0:
|
||||
raise SchemaValidationError("daily.progress_every_days 必须为正")
|
||||
return self
|
||||
|
||||
|
||||
class BacktestConfig(StrictModel):
|
||||
version: int = 1
|
||||
capital: CapitalConfig = Field(default_factory=CapitalConfig)
|
||||
@@ -524,6 +569,7 @@ class BacktestConfig(StrictModel):
|
||||
default_factory=PercentileReferenceConfig
|
||||
)
|
||||
walk_forward: WalkForwardConfig = Field(default_factory=WalkForwardConfig)
|
||||
daily: DailyConfig = Field(default_factory=DailyConfig)
|
||||
dividend: DividendHandlingConfig = Field(default_factory=DividendHandlingConfig)
|
||||
benchmark: list[BenchmarkConfig] = Field(default_factory=list)
|
||||
risk_free_rate: float = 0.02
|
||||
|
||||
@@ -497,6 +497,37 @@ CREATE TABLE IF NOT EXISTS `hd_universe_member` (
|
||||
""",
|
||||
)
|
||||
|
||||
T_DAILY_UNIVERSE = Table(
|
||||
name="hd_daily_universe",
|
||||
comment="每日动态股票池成员(daily 模式「每日选股」的留痕)",
|
||||
ddl=f"""
|
||||
CREATE TABLE IF NOT EXISTS `hd_daily_universe` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
||||
`run_id` VARCHAR(32) NOT NULL COMMENT '所属回测 run_id',
|
||||
`trade_date` DATE NOT NULL COMMENT '该交易日的选股结果',
|
||||
`symbol` VARCHAR(12) NOT NULL,
|
||||
`name` VARCHAR(64) NULL,
|
||||
`industry` VARCHAR(64) NULL,
|
||||
`dividend_yield` DECIMAL(18,8) NULL COMMENT '入选当日股息率(池内排序口径)',
|
||||
`total_mv` DECIMAL(24,4) NULL,
|
||||
`roe_avg` DECIMAL(18,8) NULL,
|
||||
`listed_count` INT NULL COMMENT '当日市场候选数(未预剪枝)',
|
||||
`candidate_count` INT NULL COMMENT '当日实际参与筛选的候选数(已预剪枝)',
|
||||
`values_json` TEXT NULL COMMENT '入选时的关键因子快照',
|
||||
`created_at` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_hd_daily_uni` (`run_id`,`trade_date`,`symbol`),
|
||||
KEY `ix_hd_daily_uni_date` (`trade_date`),
|
||||
KEY `ix_hd_daily_uni_sym` (`symbol`,`trade_date`)
|
||||
) {CHARSET} COMMENT='每日动态股票池成员'
|
||||
""",
|
||||
# 建表时已含上述列;这里的 added_columns 是为了让**先建表、后加列**的库
|
||||
# 也能幂等补上(CREATE TABLE IF NOT EXISTS 不会修改已存在的表)。
|
||||
added_columns={
|
||||
"listed_count": "INT NULL COMMENT '当日市场候选数(未预剪枝)'",
|
||||
},
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C. 因子与画像层
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1034,6 +1065,7 @@ ALL_TABLES: tuple[Table, ...] = (
|
||||
# B. 股票池层
|
||||
T_UNIVERSE_RUN,
|
||||
T_UNIVERSE_MEMBER,
|
||||
T_DAILY_UNIVERSE,
|
||||
# C. 因子与画像层
|
||||
T_FACTOR_SNAPSHOT,
|
||||
T_PROFILE_RUN,
|
||||
|
||||
@@ -0,0 +1,729 @@
|
||||
"""每日增量同步 —— 「缺几天就抓几天」。
|
||||
|
||||
本模块是 ``hdiv sync daily`` 的实现,用途只有一个:
|
||||
**在每天收盘后(默认 17:00)把外部数据源里「本地还没有的那几天」补下来**,
|
||||
不重拉已经完整的历史,也不碰已经存在的行。
|
||||
|
||||
外部数据源与本地表的对应关系(只有这些表是「抓来的」)::
|
||||
|
||||
本地表 数据源接口 分区方式 缺口判定
|
||||
------------------ --------------- -------------------- ------------------------------
|
||||
stock_daily daily trade_date 当日股票数 ≥ 当年规模阈值
|
||||
adjust_factor adj_factor trade_date 同上
|
||||
daily_basic daily_basic trade_date 同上
|
||||
hd_suspend suspend_d trade_date 有行即视为已同步
|
||||
hd_limit stk_limit trade_date 当日股票数 ≥ 当年规模阈值
|
||||
hd_index_daily index_daily (指数, trade_date) 每个指数各自的最后一天
|
||||
hd_dividend dividend ann/imp_ann/ex/record 四个日期列都查过才算同步
|
||||
hd_fina_indicator fina_indicator ts_code(接口强制) 缺股票 / 报告期滞后
|
||||
hd_cashflow cashflow ts_code(接口强制) 同上
|
||||
hd_balancesheet balancesheet ts_code(接口强制) 同上
|
||||
hd_income income ts_code(接口强制) 同上
|
||||
index_weight index_weight 月度区间 最后一个权重日之后
|
||||
|
||||
**为什么财报四表不能按天**:实测 ``fina_indicator`` / ``income`` / ``balancesheet`` /
|
||||
``cashflow`` 传 ``period`` / ``ann_date`` / ``start_date`` 而不传 ``ts_code`` 一律返回
|
||||
``50101 必填参数, ts_code`` —— 服务端强制按股票拉取。所以这四张表只能按
|
||||
「股票 × 报告期」补:先补完全没数据的股票,再按报告期水位补滞后的股票,
|
||||
每次运行有上限(``--financial-limit``),积压会在随后的每天里自动排空。
|
||||
|
||||
**为什么分红可以按天**:``dividend`` 接口支持 ``ann_date`` / ``imp_ann_date`` /
|
||||
``ex_date`` / ``record_date`` 四种日期参数(实测可用),因此不必像早期实现那样
|
||||
逐只股票重拉全历史(5,900 次调用)。这里改为「缺哪天查哪天」——
|
||||
每天最多 4 次调用,且四个日期列都查,避免漏掉「预案日已过、除权日未到」的记录。
|
||||
|
||||
**写入门槛**:``stock_daily`` / ``adjust_factor`` / ``daily_basic`` 是 qlib 的
|
||||
**只读表**,写入需要 ``HDIV_ALLOW_BACKFILL=1``(StatementGuard 的安全开关)。
|
||||
:func:`run` 会在进程内自动打开它 —— 这是本项目**唯一**被授权写入这些表的日常通道,
|
||||
且一律使用 ``INSERT IGNORE``,冲突行完全不改动(见 ``price.sync_days``)。
|
||||
``index_weight`` 已在 ``allow_write_tables`` 中,不受该开关限制。
|
||||
|
||||
**重复查询的取舍**:分红表里「一条记录都没有」的股票(实测 23 只,全是刚上市、
|
||||
尚未分红的次新股)每天都会被重查一次 —— 这正是想要的:它们第一次分红公告要能
|
||||
当天入库。财报四表则相反,已退市多年的标的必须排除(见 :data:`DELIST_GRACE_DAYS`),
|
||||
否则每天白拉 800 次调用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from hdiv.core.config import DataSourceConfig, load_config
|
||||
from hdiv.data import db
|
||||
from hdiv.data.sync import dividend as dividend_sync
|
||||
from hdiv.data.sync import financial as financial_sync
|
||||
from hdiv.data.sync import index as index_sync
|
||||
from hdiv.data.sync import price as price_sync
|
||||
from hdiv.data.sync import trading as trading_sync
|
||||
from hdiv.data.sync.base import symbols_by_priority, sync_job, to_date, upsert
|
||||
from hdiv.data.tushare_client import TushareClient
|
||||
|
||||
#: 默认回溯窗口(自然日)。日频表只在这个窗口内找缺口 ——
|
||||
#: 更早的历史空洞属于「回补」而不是「每日增量」,由 ``hdiv audit`` 报告、
|
||||
#: 由 ``hdiv sync backfill`` 处理。窗口存在是为了「昨夜失败今晨自愈」。
|
||||
DEFAULT_LOOKBACK_DAYS = 45
|
||||
|
||||
#: 单次运行最多重拉多少只股票的财报(按市值降序)。
|
||||
DEFAULT_FINANCIAL_LIMIT = 500
|
||||
|
||||
#: 分红按日期补时向前多查几天:公告日与除权日之间常有几天差,
|
||||
#: 单日查询会漏掉「昨天公告、今天才入库」的记录。
|
||||
DIVIDEND_OVERLAP_DAYS = 7
|
||||
|
||||
#: 已退市超过这么多天的标的,财报不会再更新,不再进入每日队列。
|
||||
#:
|
||||
#: 为什么必须有这条规则:实测「报告期滞后」的股票里 **200+ 只已退市**
|
||||
#: (最后一份财报停在退市前,如 1996-12-31)。不退市过滤的话,它们每天都会被
|
||||
#: 重拉一次(4 个接口 × 200 只 = 800 次调用/天),而结果永远是同一批旧数据 ——
|
||||
#: 实测一次全量重拉耗时约 2 分钟、返回 6.5 万行,全是无功而返。
|
||||
#: 留下 90 天宽限期,是为了兜住「刚退市、最终财报还没入库」的情况。
|
||||
#: 更早的历史缺口属于**一次性回补**:hdiv sync financial --only-missing
|
||||
DELIST_GRACE_DAYS = 90
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 目标描述
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DayTarget:
|
||||
"""按交易日分区的同步目标。"""
|
||||
|
||||
key: str
|
||||
table: str
|
||||
api: str
|
||||
label: str
|
||||
which: str | None = None # price.SPECS 的键;仅行情三表有
|
||||
min_symbols: int | None = None # 显式覆盖完整性阈值(停牌表用 1)
|
||||
group: str = "price"
|
||||
|
||||
|
||||
DAY_TARGETS: tuple[DayTarget, ...] = (
|
||||
DayTarget("daily", "stock_daily", "daily", "日线行情", which="daily"),
|
||||
DayTarget("adj_factor", "adjust_factor", "adj_factor", "复权因子", which="adj_factor"),
|
||||
DayTarget("daily_basic", "daily_basic", "daily_basic", "每日指标", which="daily_basic"),
|
||||
DayTarget("suspend", "hd_suspend", "suspend_d", "停牌记录", min_symbols=1, group="trading"),
|
||||
DayTarget("limit", "hd_limit", "stk_limit", "涨跌停价", group="trading"),
|
||||
)
|
||||
|
||||
DAY_TARGET_BY_KEY = {t.key: t for t in DAY_TARGETS}
|
||||
|
||||
#: 财报四表(接口强制按 ts_code 拉取)
|
||||
FINANCIAL_TABLES = ("hd_fina_indicator", "hd_cashflow", "hd_balancesheet", "hd_income")
|
||||
|
||||
#: 分红可用的日期参数:四个都查,避免漏记录
|
||||
DIVIDEND_DATE_PARAMS = ("ann_date", "imp_ann_date", "ex_date", "record_date")
|
||||
|
||||
#: ``--only`` 允许的组名
|
||||
GROUPS = ("price", "trading", "index", "dividend", "financial")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 计划(纯读,不写库)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class DailyPlan:
|
||||
"""一次每日增量同步的「待抓清单」。"""
|
||||
|
||||
asof: date
|
||||
window_start: date
|
||||
day_gaps: dict[str, list[date]] = field(default_factory=dict)
|
||||
index_days: dict[str, list[date]] = field(default_factory=dict)
|
||||
dividend_days: list[date] = field(default_factory=list)
|
||||
dividend_symbols: list[str] = field(default_factory=list)
|
||||
financial_symbols: list[str] = field(default_factory=list)
|
||||
financial_missing: dict[str, list[str]] = field(default_factory=dict)
|
||||
financial_stale: dict[str, list[str]] = field(default_factory=dict)
|
||||
financial_watermark: date | None = None
|
||||
index_weight_window: tuple[date, date] | None = None
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def total_days(self) -> int:
|
||||
"""所有按日分区的缺口天数合计(不含分红,分红按「四个日期列」计)。"""
|
||||
return sum(len(v) for v in self.day_gaps.values()) + sum(
|
||||
len(v) for v in self.index_days.values()
|
||||
)
|
||||
|
||||
@property
|
||||
def empty(self) -> bool:
|
||||
return not (
|
||||
any(self.day_gaps.values())
|
||||
or any(self.index_days.values())
|
||||
or self.dividend_days
|
||||
or self.dividend_symbols
|
||||
or self.financial_symbols
|
||||
or self.index_weight_window
|
||||
)
|
||||
|
||||
|
||||
def _trading_days(start: date, end: date, cfg: DataSourceConfig) -> list[date]:
|
||||
"""交易日列表;区间内没有交易日(长假)时返回空而不是抛错。"""
|
||||
if start > end:
|
||||
return []
|
||||
try:
|
||||
return list(price_sync.open_days(start, end, cfg))
|
||||
except RuntimeError:
|
||||
return []
|
||||
|
||||
|
||||
def missing_days(
|
||||
table: str,
|
||||
start: date,
|
||||
end: date,
|
||||
cfg: DataSourceConfig,
|
||||
*,
|
||||
min_symbols: int | None = None,
|
||||
) -> list[date]:
|
||||
"""``[start, end]`` 内**数据不完整**的交易日。
|
||||
|
||||
「不完整」的口径与断点续传完全一致(``price.fetched_days``):
|
||||
当日股票数 ≥ ``max(下限, 比例 × 当年应有上市股票数)``。
|
||||
这样半成品日期(只填了几百只)也会被重抓,而不是被当成已完成跳过。
|
||||
"""
|
||||
days = _trading_days(start, end, cfg)
|
||||
if not days:
|
||||
return []
|
||||
done = price_sync.fetched_days(table, start, end, cfg, min_symbols=min_symbols)
|
||||
return [d for d in days if d not in done]
|
||||
|
||||
|
||||
def index_daily_gaps(
|
||||
start: date, end: date, cfg: DataSourceConfig
|
||||
) -> dict[str, list[date]]:
|
||||
"""每个指数各自缺失的交易日(指数接口一次调用返回整段区间)。"""
|
||||
days = _trading_days(start, end, cfg)
|
||||
if not days:
|
||||
return {}
|
||||
df = db.read_sql(
|
||||
"SELECT index_code AS c, MAX(trade_date) AS m FROM hd_index_daily GROUP BY index_code",
|
||||
cfg=cfg,
|
||||
)
|
||||
have = {str(r.c): to_date(r.m) for r in df.itertuples()}
|
||||
out: dict[str, list[date]] = {}
|
||||
for item in index_sync.default_indices(cfg):
|
||||
code = item["code"]
|
||||
last = have.get(code)
|
||||
if last is None:
|
||||
# 从未同步过的指数:只补窗口内的(全历史回补请用 hdiv sync index --start)
|
||||
out[code] = list(days)
|
||||
continue
|
||||
gap = [d for d in days if d > last]
|
||||
if gap:
|
||||
out[code] = gap
|
||||
return out
|
||||
|
||||
|
||||
def dividend_gaps(
|
||||
start: date, end: date, asof: date, cfg: DataSourceConfig
|
||||
) -> tuple[list[date], date]:
|
||||
"""分红表缺失的交易日。
|
||||
|
||||
分红没有「每天应有 N 条」的规模口径,因此退化为与 ``hd_suspend`` 相同的
|
||||
「查过即算」:四个日期列里任何一列在该交易日有行,就认为那天已经查过。
|
||||
代价是**真正没有分红记录的交易日会被重复查询**(每天 4 次调用、返回空),
|
||||
收益是绝不漏记录 —— 与 ``trading.py`` 对停牌表的取舍一致。
|
||||
|
||||
返回 ``(待查交易日, 锚点日期)``;锚点用于把窗口收窄到「上次数据附近」,
|
||||
避免每天把整个回溯窗口重查一遍。
|
||||
"""
|
||||
df = db.read_sql(
|
||||
"SELECT MAX(d) AS m FROM ("
|
||||
" SELECT MAX(ann_date) AS d FROM hd_dividend"
|
||||
" UNION ALL SELECT MAX(imp_ann_date) FROM hd_dividend"
|
||||
" UNION ALL SELECT MAX(ex_date) FROM hd_dividend"
|
||||
" UNION ALL SELECT MAX(record_date) FROM hd_dividend"
|
||||
") t",
|
||||
cfg=cfg,
|
||||
)
|
||||
anchor = to_date(df["m"].iloc[0]) if not df.empty else None
|
||||
# 未来日期(已公告但尚未除权)不能当锚点,否则窗口会落在未来
|
||||
if anchor is None or anchor > asof:
|
||||
anchor = asof
|
||||
since = max(start, anchor - timedelta(days=DIVIDEND_OVERLAP_DAYS))
|
||||
|
||||
days = _trading_days(since, end, cfg)
|
||||
if not days:
|
||||
return [], anchor
|
||||
have = db.read_sql(
|
||||
"SELECT DISTINCT d FROM ("
|
||||
" SELECT ann_date AS d FROM hd_dividend WHERE ann_date BETWEEN :s AND :e"
|
||||
" UNION SELECT imp_ann_date FROM hd_dividend WHERE imp_ann_date BETWEEN :s AND :e"
|
||||
" UNION SELECT ex_date FROM hd_dividend WHERE ex_date BETWEEN :s AND :e"
|
||||
" UNION SELECT record_date FROM hd_dividend WHERE record_date BETWEEN :s AND :e"
|
||||
") t",
|
||||
{"s": since, "e": end},
|
||||
cfg=cfg,
|
||||
)
|
||||
seen = {to_date(x) for x in have["d"].tolist()} if not have.empty else set()
|
||||
return [d for d in days if d not in seen], anchor
|
||||
|
||||
|
||||
def _active_since(asof: date) -> date:
|
||||
"""退市宽限期的截止日:早于它退市的标的不再进入每日队列。"""
|
||||
return asof - timedelta(days=DELIST_GRACE_DAYS)
|
||||
|
||||
|
||||
def _symbols_without_rows(
|
||||
table: str, cfg: DataSourceConfig, *, asof: date | None = None
|
||||
) -> list[str]:
|
||||
"""``stock`` 里有、目标表里一条记录都没有、且**未长期退市**的股票。"""
|
||||
cutoff = _active_since(asof or datetime.now().date())
|
||||
df = db.read_sql(
|
||||
f"SELECT s.symbol AS symbol FROM stock s "
|
||||
f"WHERE (s.delist_date IS NULL OR s.delist_date > :cutoff) "
|
||||
f"AND NOT EXISTS (SELECT 1 FROM `{table}` x WHERE x.symbol = s.symbol)",
|
||||
{"cutoff": cutoff},
|
||||
cfg=cfg,
|
||||
)
|
||||
return [str(x) for x in df["symbol"].tolist()] if not df.empty else []
|
||||
|
||||
|
||||
def _symbols_lagging(
|
||||
table: str, watermark: date, cfg: DataSourceConfig, *, asof: date | None = None
|
||||
) -> list[str]:
|
||||
"""有数据但最新报告期早于水位、且仍可能出新财报的股票(按市值降序)。"""
|
||||
cutoff = _active_since(asof or datetime.now().date())
|
||||
df = db.read_sql(
|
||||
f"SELECT s.symbol AS symbol, t.mx AS mx FROM stock s "
|
||||
f"LEFT JOIN (SELECT symbol, MAX(end_date) AS mx FROM `{table}` GROUP BY symbol) t "
|
||||
f"ON t.symbol = s.symbol "
|
||||
f"WHERE s.delist_date IS NULL OR s.delist_date > :cutoff",
|
||||
{"cutoff": cutoff},
|
||||
cfg=cfg,
|
||||
)
|
||||
out: list[str] = []
|
||||
for r in df.itertuples():
|
||||
mx = to_date(r.mx)
|
||||
if mx is None or mx < watermark:
|
||||
out.append(str(r.symbol))
|
||||
return symbols_by_priority(cfg, out)
|
||||
|
||||
|
||||
def financial_watermark(asof: date) -> date:
|
||||
"""按 A 股披露截止日推算「此刻理应已披露的最新报告期」。
|
||||
|
||||
年报 4/30、一季报 4/30、半年报 8/31、三季报 10/31。取**已过截止日**的
|
||||
最近一期作为水位:最新报告期晚于水位的股票才算「滞后」,
|
||||
这样财报季里刚披露的公司会立刻退出待抓队列,不必重拉全市场。
|
||||
"""
|
||||
y = asof.year
|
||||
if asof >= date(y, 11, 1):
|
||||
return date(y, 9, 30)
|
||||
if asof >= date(y, 9, 1):
|
||||
return date(y, 6, 30)
|
||||
if asof >= date(y, 5, 1):
|
||||
return date(y, 3, 31)
|
||||
# 1~4 月是年报季(上一年 12-31 的年报 4/30 前披露)
|
||||
return date(y - 1, 12, 31)
|
||||
|
||||
|
||||
def _financial_candidates(
|
||||
watermark: date, asof: date, cfg: DataSourceConfig
|
||||
) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
|
||||
missing = {t: _symbols_without_rows(t, cfg, asof=asof) for t in FINANCIAL_TABLES}
|
||||
stale = {t: _symbols_lagging(t, watermark, cfg, asof=asof) for t in FINANCIAL_TABLES}
|
||||
return missing, stale
|
||||
|
||||
|
||||
def plan(
|
||||
cfg: DataSourceConfig | None = None,
|
||||
*,
|
||||
asof: date | None = None,
|
||||
lookback_days: int = DEFAULT_LOOKBACK_DAYS,
|
||||
only: Sequence[str] | None = None,
|
||||
include_financial: bool = True,
|
||||
financial_limit: int = DEFAULT_FINANCIAL_LIMIT,
|
||||
) -> DailyPlan:
|
||||
"""算出待抓清单(只读,不调用 Tushare、不写库)。"""
|
||||
if cfg is None:
|
||||
db.load_dotenv_once()
|
||||
cfg = load_config("datasource")
|
||||
asof = asof or datetime.now().date()
|
||||
if isinstance(asof, datetime):
|
||||
asof = asof.date()
|
||||
window_start = asof - timedelta(days=max(1, lookback_days))
|
||||
targets = _resolve_only(only)
|
||||
|
||||
out = DailyPlan(asof=asof, window_start=window_start)
|
||||
|
||||
if "price" in targets or "trading" in targets:
|
||||
for t in DAY_TARGETS:
|
||||
if t.group not in targets:
|
||||
continue
|
||||
out.day_gaps[t.key] = missing_days(
|
||||
t.table, window_start, asof, cfg, min_symbols=t.min_symbols
|
||||
)
|
||||
|
||||
if "index" in targets:
|
||||
out.index_days = index_daily_gaps(window_start, asof, cfg)
|
||||
out.index_weight_window = _index_weight_window(window_start, asof, cfg)
|
||||
if out.index_weight_window and db.row_count("index_weight", cfg) == 0:
|
||||
out.notes.append(
|
||||
"index_weight 为空:本次只补最近窗口。全历史成分股权重是一次性回补,"
|
||||
"请执行 hdiv sync index --start 20150101"
|
||||
)
|
||||
|
||||
if "dividend" in targets:
|
||||
out.dividend_days, _ = dividend_gaps(window_start, asof, asof, cfg)
|
||||
out.dividend_symbols = _symbols_without_rows("hd_dividend", cfg, asof=asof)
|
||||
if out.dividend_symbols:
|
||||
out.notes.append(
|
||||
f"{len(out.dividend_symbols)} 只股票在分红表里仍无任何记录"
|
||||
"(多为新上市公司,尚未分红;首次公告后会自动入库)"
|
||||
)
|
||||
|
||||
if "financial" in targets and include_financial:
|
||||
wm = financial_watermark(asof)
|
||||
out.financial_watermark = wm
|
||||
missing, stale = _financial_candidates(wm, asof, cfg)
|
||||
out.financial_missing = {k: v for k, v in missing.items() if v}
|
||||
out.financial_stale = {k: v for k, v in stale.items() if v}
|
||||
# 完全缺数据的股票优先(它们连一行都没有),再按市值补滞后股票
|
||||
priority: list[str] = []
|
||||
for table in FINANCIAL_TABLES:
|
||||
for s in missing[table]:
|
||||
if s not in priority:
|
||||
priority.append(s)
|
||||
rest: list[str] = []
|
||||
for table in FINANCIAL_TABLES:
|
||||
for s in stale[table]:
|
||||
if s not in priority and s not in rest:
|
||||
rest.append(s)
|
||||
rest = symbols_by_priority(cfg, rest)
|
||||
out.financial_symbols = (priority + rest)[: max(0, financial_limit)]
|
||||
if len(priority) + len(rest) > len(out.financial_symbols):
|
||||
out.notes.append(
|
||||
f"财报待补 {len(priority) + len(rest)} 只,本次按上限只处理 "
|
||||
f"{len(out.financial_symbols)} 只,其余在后续运行中自动排空"
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _index_weight_window(
|
||||
start: date, end: date, cfg: DataSourceConfig
|
||||
) -> tuple[date, date] | None:
|
||||
"""成分股权重的待补区间(月度接口,按最后一个权重日之后起算)。
|
||||
|
||||
右端点收到**最近一个交易日**:否则长假期间(如国庆 10-01 ~ 10-08)
|
||||
每天都会重查一段没有任何数据的「未来」区间,白白消耗调用次数。
|
||||
"""
|
||||
days = _trading_days(start, end, cfg)
|
||||
if not days:
|
||||
return None
|
||||
end = days[-1]
|
||||
df = db.read_sql("SELECT MAX(trade_date) AS m FROM index_weight", cfg=cfg)
|
||||
last = to_date(df["m"].iloc[0]) if not df.empty else None
|
||||
if last is not None and last >= end:
|
||||
return None
|
||||
since = start if last is None else max(start, last + timedelta(days=1))
|
||||
if since > end:
|
||||
return None
|
||||
return since, end
|
||||
|
||||
|
||||
def _resolve_only(only: Sequence[str] | None) -> set[str]:
|
||||
if not only:
|
||||
return set(GROUPS)
|
||||
out: set[str] = set()
|
||||
for item in only:
|
||||
key = str(item).strip()
|
||||
if not key:
|
||||
continue
|
||||
if key in GROUPS:
|
||||
out.add(key)
|
||||
elif key in DAY_TARGET_BY_KEY:
|
||||
out.add(DAY_TARGET_BY_KEY[key].group)
|
||||
elif key == "index_weight":
|
||||
out.add("index")
|
||||
else:
|
||||
raise ValueError(
|
||||
f"未知的同步目标:{key}(可用:{'/'.join(GROUPS)}"
|
||||
f" 或单个目标 {'/'.join(DAY_TARGET_BY_KEY)})"
|
||||
)
|
||||
return out or set(GROUPS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 执行
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def authorize_qlib_writes() -> bool:
|
||||
"""打开「写入 qlib 既有表」的进程内开关。
|
||||
|
||||
返回 True 表示本次由本函数打开(调用方应据此在结束时不回滚 —— 进程退出即失效)。
|
||||
StatementGuard 在**引擎创建时**读取该开关,所以这里必须同时丢弃已缓存的引擎,
|
||||
否则在一个已经建过引擎的进程里调用会拿到旧 guard。
|
||||
"""
|
||||
already = os.environ.get("HDIV_ALLOW_BACKFILL", "").strip().lower() in {"1", "true", "yes"}
|
||||
if already:
|
||||
return False
|
||||
os.environ["HDIV_ALLOW_BACKFILL"] = "1"
|
||||
db.reset_engine_cache()
|
||||
return True
|
||||
|
||||
|
||||
def sync_dividend_days(days: Sequence[date], cfg: DataSourceConfig) -> int:
|
||||
"""按日期补分红:每个交易日查四个日期参数,幂等写入 ``hd_dividend``。"""
|
||||
if not days:
|
||||
return 0
|
||||
written = 0
|
||||
with TushareClient(cfg.tushare) as client, sync_job(
|
||||
"dividend:dates",
|
||||
api="dividend",
|
||||
params={"days": [str(d) for d in days]},
|
||||
table="hd_dividend",
|
||||
cfg=cfg,
|
||||
) as ctx:
|
||||
ctx["data_start"], ctx["data_end"] = days[0], days[-1]
|
||||
for i, d in enumerate(days, 1):
|
||||
stamp = d.strftime("%Y%m%d")
|
||||
for param in DIVIDEND_DATE_PARAMS:
|
||||
rows = client.query("dividend", {param: stamp}, dividend_sync.FIELDS)
|
||||
if not rows:
|
||||
continue
|
||||
df = dividend_sync.rows_to_frame(rows)
|
||||
if df.empty:
|
||||
continue
|
||||
upsert("hd_dividend", df, cfg=cfg, ctx=ctx)
|
||||
if i % 10 == 0:
|
||||
print(f" [dividend] [{i}/{len(days)}] 累计入库 {ctx['rows_written']} 行", flush=True)
|
||||
written = ctx["rows_written"]
|
||||
print(
|
||||
f"[dividend] 完成:{len(days)} 个交易日(每校 {len(DIVIDEND_DATE_PARAMS)} 个日期列),"
|
||||
f"写入 {written} 行",
|
||||
flush=True,
|
||||
)
|
||||
return written
|
||||
|
||||
|
||||
def _print_plan(p: DailyPlan, targets: set[str]) -> None:
|
||||
print(f"=== 每日增量同步计划(asof={p.asof},窗口自 {p.window_start})===", flush=True)
|
||||
for t in DAY_TARGETS:
|
||||
if t.group not in targets:
|
||||
continue
|
||||
days = p.day_gaps.get(t.key, [])
|
||||
head = ", ".join(str(d) for d in days[:6])
|
||||
tail = " …" if len(days) > 6 else ""
|
||||
print(f" [{t.label:8s}] {t.table:16s} 缺 {len(days):>3d} 个交易日 {head}{tail}", flush=True)
|
||||
if "index" in targets:
|
||||
total = sum(len(v) for v in p.index_days.values())
|
||||
print(f" [指数行情 ] hd_index_daily 缺 {total:>3d} 个交易日·指数", flush=True)
|
||||
for code, days in p.index_days.items():
|
||||
print(f" {code} 缺 {len(days)} 天({days[0]} ~ {days[-1]})", flush=True)
|
||||
if p.index_weight_window:
|
||||
print(
|
||||
f" [成分权重 ] index_weight 待补 {p.index_weight_window[0]} ~ "
|
||||
f"{p.index_weight_window[1]}",
|
||||
flush=True,
|
||||
)
|
||||
if "dividend" in targets:
|
||||
print(
|
||||
f" [分红明细 ] hd_dividend 缺 {len(p.dividend_days)} 个交易日"
|
||||
f"({len(DIVIDEND_DATE_PARAMS)} 个日期列/天);"
|
||||
f"完全无记录的股票 {len(p.dividend_symbols)} 只",
|
||||
flush=True,
|
||||
)
|
||||
if "financial" in targets:
|
||||
if p.financial_watermark is None:
|
||||
print(" [财报四表 ] 已跳过", flush=True)
|
||||
else:
|
||||
print(
|
||||
f" [财报四表 ] 报告期水位 {p.financial_watermark},"
|
||||
f"本次待补 {len(p.financial_symbols)} 只("
|
||||
f"完全缺失 {sum(len(v) for v in p.financial_missing.values())} 只·表)",
|
||||
flush=True,
|
||||
)
|
||||
for n in p.notes:
|
||||
print(f" 注意:{n}", flush=True)
|
||||
|
||||
|
||||
def run(
|
||||
cfg: DataSourceConfig | None = None,
|
||||
*,
|
||||
asof: date | None = None,
|
||||
lookback_days: int = DEFAULT_LOOKBACK_DAYS,
|
||||
only: Sequence[str] | None = None,
|
||||
include_financial: bool = True,
|
||||
financial_limit: int = DEFAULT_FINANCIAL_LIMIT,
|
||||
dry_run: bool = False,
|
||||
verbose: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""执行一次每日增量同步。
|
||||
|
||||
``dry_run=True`` 只打印计划、不调用 Tushare、不写库。
|
||||
``verbose=False`` 不打任何进度文字,只返回结果(供 ``--json`` 使用)——
|
||||
否则 stdout 会混进人类可读文本,机器解析不了。
|
||||
"""
|
||||
if cfg is None:
|
||||
db.load_dotenv_once()
|
||||
cfg = load_config("datasource")
|
||||
targets = _resolve_only(only)
|
||||
p = plan(
|
||||
cfg,
|
||||
asof=asof,
|
||||
lookback_days=lookback_days,
|
||||
only=only,
|
||||
include_financial=include_financial,
|
||||
financial_limit=financial_limit,
|
||||
)
|
||||
if verbose:
|
||||
_print_plan(p, targets)
|
||||
|
||||
summary: dict[str, Any] = {
|
||||
"asof": str(p.asof),
|
||||
"window_start": str(p.window_start),
|
||||
"dry_run": dry_run,
|
||||
"targets": {},
|
||||
"errors": [],
|
||||
"ok": True,
|
||||
}
|
||||
|
||||
if dry_run:
|
||||
summary["planned"] = {
|
||||
"day_gaps": {k: len(v) for k, v in p.day_gaps.items()},
|
||||
"index_days": {k: len(v) for k, v in p.index_days.items()},
|
||||
"dividend_days": len(p.dividend_days),
|
||||
"dividend_symbols": len(p.dividend_symbols),
|
||||
"financial_symbols": len(p.financial_symbols),
|
||||
}
|
||||
if verbose:
|
||||
print("(--dry-run:未调用任何接口,也未写库)", flush=True)
|
||||
return summary
|
||||
|
||||
if p.empty:
|
||||
if verbose:
|
||||
print("全部目标均已是最新,无需抓取。", flush=True)
|
||||
return summary
|
||||
|
||||
# 行情三表落在 qlib 既有表上,写入需要显式授权(进程内生效)
|
||||
needs_backfill = any(t.which for t in DAY_TARGETS if p.day_gaps.get(t.key))
|
||||
if needs_backfill or p.index_weight_window:
|
||||
authorize_qlib_writes()
|
||||
|
||||
def _record(key: str, label: str, fn) -> None: # type: ignore[no-untyped-def]
|
||||
try:
|
||||
result = fn()
|
||||
summary["targets"][key] = {"label": label, "status": "ok", "result": result}
|
||||
except Exception as exc: # 单个目标失败不应中断其余目标
|
||||
summary["ok"] = False
|
||||
summary["errors"].append(f"{key}: {type(exc).__name__}: {exc}")
|
||||
summary["targets"][key] = {
|
||||
"label": label,
|
||||
"status": "fail",
|
||||
"error": f"{type(exc).__name__}: {exc}",
|
||||
}
|
||||
if verbose:
|
||||
print(f" [失败] {label}:{type(exc).__name__}: {exc}", flush=True)
|
||||
|
||||
# 1) 行情三表(内部按 fetched_days 再筛一次,真正抓的就是缺口那几天)
|
||||
for t in DAY_TARGETS:
|
||||
days = p.day_gaps.get(t.key)
|
||||
if not days or not t.which:
|
||||
continue
|
||||
_record(
|
||||
t.key,
|
||||
t.label,
|
||||
lambda t=t, days=days: price_sync.sync_days(
|
||||
t.which, days[0], days[-1], resume=True, insert_ignore=True, cfg=cfg
|
||||
),
|
||||
)
|
||||
|
||||
# 2) 停牌 / 涨跌停(两者共用一次窗口计算,各自按自己的阈值判缺口)
|
||||
trading_days = (p.day_gaps.get("suspend") or []) + (p.day_gaps.get("limit") or [])
|
||||
if trading_days:
|
||||
_record(
|
||||
"trading",
|
||||
"停牌/涨跌停",
|
||||
lambda: trading_sync.sync_trading_constraints(
|
||||
min(trading_days), max(trading_days), resume=True, cfg=cfg
|
||||
),
|
||||
)
|
||||
|
||||
# 3) 指数行情:逐个指数按自己的缺口起点拉,已最新的指数一次调用都不发
|
||||
if "index" in targets:
|
||||
for code, days in p.index_days.items():
|
||||
item = next(
|
||||
(i for i in index_sync.default_indices(cfg) if i["code"] == code),
|
||||
{"code": code, "name": None},
|
||||
)
|
||||
_record(
|
||||
f"index_daily:{code}",
|
||||
f"指数行情 {code}",
|
||||
lambda item=item, days=days: index_sync.sync_index_daily(
|
||||
indices=[item],
|
||||
start_date=days[0].strftime("%Y%m%d"),
|
||||
end_date=days[-1].strftime("%Y%m%d"),
|
||||
cfg=cfg,
|
||||
),
|
||||
)
|
||||
if p.index_weight_window:
|
||||
s, e = p.index_weight_window
|
||||
_record(
|
||||
"index_weight",
|
||||
"指数成分权重",
|
||||
lambda s=s, e=e: index_sync.sync_index_weight(
|
||||
start_date=s.strftime("%Y%m%d"), end_date=e.strftime("%Y%m%d"), cfg=cfg
|
||||
),
|
||||
)
|
||||
|
||||
# 4) 分红:按日期补(新记录)+ 补齐完全无记录的股票
|
||||
if "dividend" in targets:
|
||||
if p.dividend_days:
|
||||
_record(
|
||||
"dividend:dates",
|
||||
"分红明细(按日)",
|
||||
lambda: {"written": sync_dividend_days(p.dividend_days, cfg)},
|
||||
)
|
||||
if p.dividend_symbols:
|
||||
_record(
|
||||
"dividend:symbols",
|
||||
"分红明细(缺股票)",
|
||||
lambda: dividend_sync.run(symbols=p.dividend_symbols, cfg=cfg),
|
||||
)
|
||||
|
||||
# 5) 财报四表:按股票交错拉,一次把四张表补齐
|
||||
if p.financial_symbols:
|
||||
_record(
|
||||
"financial",
|
||||
"财报四表",
|
||||
lambda: financial_sync.run_interleaved(
|
||||
apis=list(financial_sync.SPECS),
|
||||
symbols=p.financial_symbols,
|
||||
only_missing=False,
|
||||
cfg=cfg,
|
||||
),
|
||||
)
|
||||
|
||||
if verbose:
|
||||
print("\n=== 本次增量同步结果 ===", flush=True)
|
||||
for info in summary["targets"].values():
|
||||
print(f" [{info['status']:4s}] {info['label']}", flush=True)
|
||||
if summary["errors"]:
|
||||
print(f" 失败 {len(summary['errors'])} 项:", flush=True)
|
||||
for e in summary["errors"]:
|
||||
print(f" - {e}", flush=True)
|
||||
else:
|
||||
print(" 全部成功。", flush=True)
|
||||
return summary
|
||||
|
||||
|
||||
def format_summary(summary: dict[str, Any]) -> str:
|
||||
"""把 :func:`run` 的结果压成一行摘要(给 launchd 日志 / 告警用)。"""
|
||||
if summary.get("dry_run"):
|
||||
planned = summary.get("planned", {})
|
||||
days = sum(planned.get("day_gaps", {}).values()) + sum(
|
||||
planned.get("index_days", {}).values()
|
||||
)
|
||||
return f"计划:{days} 个交易日·指数缺口,分红 {planned.get('dividend_days', 0)} 天"
|
||||
if summary.get("errors"):
|
||||
return f"失败 {len(summary['errors'])} 项:" + ";".join(summary["errors"])
|
||||
return "全部目标同步成功"
|
||||
@@ -32,9 +32,14 @@ def ttm_params() -> tuple[int, int, bool]:
|
||||
walk-forward 与 Web 用函数默认值 —— 同一个「股息率」在不同环节定义不同,
|
||||
改了配置只有画像会变。现在统一从这里取。
|
||||
"""
|
||||
from hdiv.core.config import load_config
|
||||
# 必须走**带缓存**的 get_config:本函数在筛选器里是**逐股**调用的,
|
||||
# 而 load_config 每次都重新读盘 + YAML 解析 + pydantic 校验(实测约 16ms)。
|
||||
# 逐日全市场筛选时这会变成每天约 2.8 秒的纯开销 —— cProfile 实测:
|
||||
# 6 个交易日里 load_config 被调用 1008 次、共 16.6 秒,占整个筛选时间的 22%,
|
||||
# 而它每次返回的都是完全相同的一份配置。
|
||||
from hdiv.core.config import get_config
|
||||
|
||||
c = load_config("profile").ttm_dividend
|
||||
c = get_config("profile").ttm_dividend
|
||||
return int(c.window_days), int(c.grace_days), bool(
|
||||
getattr(c, "smooth_spikes", True)
|
||||
)
|
||||
|
||||
+134
-21
@@ -25,6 +25,7 @@ from dataclasses import dataclass, field
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from hdiv.core.errors import HdivError
|
||||
@@ -55,6 +56,21 @@ __all__ = [
|
||||
# 配置校验(core.config)与画像实现(profile.pit)不允许出现两个清单。
|
||||
|
||||
|
||||
def _with_dt(df: pd.DataFrame, col: str) -> pd.DataFrame:
|
||||
"""把日期列一次性转成 ``datetime64``(P1)。
|
||||
|
||||
内部一律用 datetime64 比较、排序、切片;``date`` 对象只在对外返回时出现。
|
||||
原因:object 的 ``datetime.date`` 列在 pandas 里做比较/排序会退化到 Python
|
||||
逐元素循环 —— 实测这些转换与比较占模拟阶段约 28%(``DatetimeArray.__iter__``
|
||||
一百万次调用、``pd.to_datetime`` 累计 7.4 秒)。
|
||||
"""
|
||||
if df.empty or col not in df.columns:
|
||||
return df
|
||||
if not pd.api.types.is_datetime64_any_dtype(df[col]):
|
||||
df[col] = pd.to_datetime(df[col])
|
||||
return df
|
||||
|
||||
|
||||
def metrics_needing_financials(metrics: set[str]) -> bool:
|
||||
"""是否需要财报面板。
|
||||
|
||||
@@ -162,6 +178,21 @@ class _AsOfContext:
|
||||
expected_obs: dict[int, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
#: 时点面板缓存上限(``_ctx`` 的条目数)。
|
||||
#:
|
||||
#: **为什么必须设上限**:引擎逐日推进,**旧时点不会再被查询**;而每个时点面板
|
||||
#: 持有该 asof 可见的**分红超集**(约 12 年 × 全市场,实测约 4 MB)、财务历史与
|
||||
#: 财年表。不设上限时,一次 6.7 年(约 1600 个决策日)的每日回测会把 1600 份
|
||||
#: 面板全部留在内存里 —— 约 6 GB,**必然 OOM**。
|
||||
#: 保留 2 个(当前 + 上一个)是为了容忍调用方偶尔回看一天,代价可以忽略。
|
||||
_MAX_ASOF_CONTEXTS = 2
|
||||
|
||||
#: ``(symbol, asof)`` 画像快照的缓存上限(``_snapshots`` 的条目数)。
|
||||
#: 画像快照的复用只发生在**同一个 asof 内**(闸门算过一次、信号留痕再取一次),
|
||||
#: 跨日必然 miss。按 asof 清空即可保住这份收益,同时把内存限制在「一天」的量级。
|
||||
_SNAPSHOTS_PER_ASOF = 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 服务
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -207,6 +238,10 @@ class PitProfileService:
|
||||
self._all_dividends = pd.DataFrame()
|
||||
self._ctx: dict[date, _AsOfContext] = {}
|
||||
self._snapshots: dict[tuple[str, date], ProfileSnapshot] = {}
|
||||
#: 当前快照缓存所属的 asof(换日即清空,见 _SNAPSHOTS_PER_ASOF)
|
||||
self._snapshot_asof: date | None = None
|
||||
#: 累计构建过的时点面板数(与「当前缓存了几个」是两件事)
|
||||
self._asof_built = 0
|
||||
#: 闸门规则用到的指标集合;None = 未知,按「全都可能需要」处理(保守)
|
||||
self._needed: set[str] | None = None
|
||||
self._financial_required: bool | None = None
|
||||
@@ -244,24 +279,51 @@ class PitProfileService:
|
||||
# 批量预载(跨越整个回测区间、与 asof 无关的部分)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def prepare(self, symbols: list[str], start: date, end: date) -> None:
|
||||
def prepare(
|
||||
self,
|
||||
symbols: list[str],
|
||||
start: date,
|
||||
end: date,
|
||||
*,
|
||||
price: pd.DataFrame | None = None,
|
||||
) -> None:
|
||||
"""载入跨决策日共享的面板。
|
||||
|
||||
只有 ``trade_date`` 范围过滤,没有 PIT 语义 —— 真正的 PIT 剪裁发生在
|
||||
:meth:`snapshot` 里逐 asof 进行(与 ``ProfileBuilder.run`` 的取数起点
|
||||
规则完全一致:``asof.year - max_years - 1`` 的 1 月 1 日)。
|
||||
|
||||
``price`` 允许调用方传入**已经取好的同一批不复权行情**(引擎就是这么做的)——
|
||||
回测引擎本来就要取这段价格来算分位,画像再取一遍是纯粹的重复查询。
|
||||
传入时必须已经覆盖 ``[start, end]``(调用方把两者的起点取并集)。
|
||||
"""
|
||||
self._symbols = sorted(set(symbols))
|
||||
if not self._symbols:
|
||||
self._prepared = True
|
||||
return
|
||||
self._price = self.repo.price_history(self._symbols, start, end, adjust="none")
|
||||
if price is not None and not price.empty:
|
||||
# 只保留面板里的股票,并**统一为 datetime64**(P1)
|
||||
self._price = _with_dt(
|
||||
price[price["symbol"].isin(set(self._symbols))].copy(), "trade_date"
|
||||
)
|
||||
else:
|
||||
self._price = _with_dt(
|
||||
self.repo.price_history(self._symbols, start, end, adjust="none"),
|
||||
"trade_date",
|
||||
)
|
||||
self._counters["price_loaded"] += 1
|
||||
if self.load_basics:
|
||||
self._basics = self.builder._load_daily_basic(self._symbols, start, end)
|
||||
# P1:日期列一次性转成 datetime64。原先每个 asof、每只股票都要
|
||||
# 把 object 的 datetime.date 列再 `pd.to_datetime` / `.dt.date` 一遍,
|
||||
# 实测这一族转换占模拟阶段约 28%(DatetimeArray.__iter__ 100 万次调用)。
|
||||
self._basics = _with_dt(
|
||||
self.builder._load_daily_basic(self._symbols, start, end), "trade_date"
|
||||
)
|
||||
self._counters["basics_loaded"] += 1
|
||||
if self.load_index:
|
||||
self._index = self.repo.index_history("000300.SH", start, end)
|
||||
self._index = _with_dt(
|
||||
self.repo.index_history("000300.SH", start, end), "trade_date"
|
||||
)
|
||||
# 分红:为**整个回测区间**取一次超集,逐 asof 再用与 repo.dividend_records
|
||||
# 完全相同的三重 PIT 条件(imp_ann_date / ex_date / 回看窗口)在 pandas 里剪裁。
|
||||
#
|
||||
@@ -274,7 +336,16 @@ class PitProfileService:
|
||||
if not self._all_dividends.empty:
|
||||
self._all_dividends = self._all_dividends[
|
||||
self._all_dividends["symbol"].isin(set(self._symbols))
|
||||
]
|
||||
].copy()
|
||||
# P1:预存 datetime64 的公告日/除权日,逐 asof 的 PIT 剪裁直接用它们比较
|
||||
# (原来每次都把 2 万行×2 列 `pd.to_datetime(...).dt.date` 转成 object
|
||||
# 再逐元素比较 —— object 比较会退化到 Python 循环)。
|
||||
self._all_dividends["_imp_dt"] = pd.to_datetime(
|
||||
self._all_dividends["imp_ann_date"]
|
||||
).to_numpy(dtype="datetime64[ns]")
|
||||
self._all_dividends["_ex_dt"] = pd.to_datetime(
|
||||
self._all_dividends["ex_date"]
|
||||
).to_numpy(dtype="datetime64[ns]")
|
||||
self._prepared = True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -285,6 +356,11 @@ class PitProfileService:
|
||||
"""计算(或取缓存)``symbol`` 在 ``asof`` 的实时画像。"""
|
||||
if not self._prepared:
|
||||
raise HdivError("PitProfileService 必须先 prepare(symbols, start, end)")
|
||||
if self._snapshot_asof is not None and asof != self._snapshot_asof:
|
||||
# 换日:跨日的 (symbol, asof) 键不可能再命中,直接释放。
|
||||
# 见 _SNAPSHOTS_PER_ASOF 对「为什么这样不改变任何结果」的说明。
|
||||
self._snapshots.clear()
|
||||
self._snapshot_asof = asof
|
||||
key = (symbol, asof)
|
||||
hit = self._snapshots.get(key)
|
||||
if hit is not None:
|
||||
@@ -299,7 +375,9 @@ class PitProfileService:
|
||||
|
||||
def stats(self) -> dict[str, int]:
|
||||
out = dict(self._counters)
|
||||
out["distinct_asof"] = len(self._ctx)
|
||||
# distinct_asof = **累计**构建过的时点数(缓存里当前只剩最近几个,
|
||||
# 用 len(self._ctx) 会把「涉及多少决策时点」报成 1~2,直接误导)。
|
||||
out["distinct_asof"] = self._asof_built
|
||||
out["cached_symbols"] = len(self._snapshots)
|
||||
return out
|
||||
|
||||
@@ -317,9 +395,17 @@ class PitProfileService:
|
||||
div = d
|
||||
else:
|
||||
since = asof - timedelta(days=int((self.max_years + 2) * 365.25))
|
||||
imp = pd.to_datetime(d["imp_ann_date"]).dt.date
|
||||
ex = pd.to_datetime(d["ex_date"]).dt.date
|
||||
div = d[(imp <= asof) & (ex <= asof) & (ex >= since)]
|
||||
# P1:用 prepare 里预存的 datetime64 列直接比较(原来每次都对 2 万行
|
||||
# ×2 列做 `pd.to_datetime(...).dt.date`,再用 object 比较 —— 后者会
|
||||
# 退化到 Python 逐元素循环)。日期都在零点,闭区间端点与 object 版一致。
|
||||
a = np.datetime64(asof, "ns")
|
||||
sn = np.datetime64(since, "ns")
|
||||
if "_imp_dt" in d.columns:
|
||||
imp, ex = d["_imp_dt"], d["_ex_dt"]
|
||||
else: # 兼容:未走 prepare 的调用路径
|
||||
imp = pd.to_datetime(d["imp_ann_date"]).to_numpy(dtype="datetime64[ns]")
|
||||
ex = pd.to_datetime(d["ex_date"]).to_numpy(dtype="datetime64[ns]")
|
||||
div = d[(imp <= a) & (ex <= a) & (ex >= sn)]
|
||||
div_by_symbol: dict[str, list[dict[str, Any]]] = {}
|
||||
if not div.empty:
|
||||
for rec in div.to_dict("records"):
|
||||
@@ -351,6 +437,14 @@ class PitProfileService:
|
||||
}
|
||||
self._counters["financial_loads"] += 1
|
||||
self._ctx[asof] = ctx
|
||||
self._asof_built += 1
|
||||
# 只保留最近 _MAX_ASOF_CONTEXTS 个时点(按插入顺序淘汰最旧)。
|
||||
# 见该常量的说明:不设上限会让长区间每日回测 OOM。
|
||||
while len(self._ctx) > _MAX_ASOF_CONTEXTS:
|
||||
oldest = next(iter(self._ctx))
|
||||
if oldest == asof:
|
||||
break
|
||||
self._ctx.pop(oldest, None)
|
||||
self._counters["asof_contexts"] += 1
|
||||
return ctx
|
||||
|
||||
@@ -360,21 +454,40 @@ class PitProfileService:
|
||||
"""调用 ``ProfileBuilder._profile_one`` —— **指标定义的单一口径来源**。"""
|
||||
start = date(asof.year - self.max_years - 1, 1, 1)
|
||||
|
||||
def _slice(df: pd.DataFrame) -> pd.DataFrame:
|
||||
if df.empty:
|
||||
return df
|
||||
td = pd.to_datetime(df["trade_date"]).dt.date
|
||||
return df[(td >= start) & (td <= asof)]
|
||||
def _slice(d: pd.DataFrame) -> pd.DataFrame:
|
||||
"""按 ``[start, asof]`` 剪裁(**必须已先按 symbol 过滤**)。
|
||||
|
||||
price = _slice(self._price) if not self._price.empty else self._price
|
||||
price = price[price["symbol"] == symbol] if not price.empty else price
|
||||
用 ``datetime64`` 比较而不是 ``.dt.date`` 的 object 比较:后者在
|
||||
pandas 里逐元素装箱,实测 50 万行约 150 毫秒,前者毫秒级。
|
||||
两者语义相同(日期都在零点,闭区间端点一致)。
|
||||
"""
|
||||
if d.empty:
|
||||
return d
|
||||
td = d["trade_date"]
|
||||
if not pd.api.types.is_datetime64_any_dtype(td):
|
||||
td = pd.to_datetime(td)
|
||||
return d[(td >= pd.Timestamp(start)) & (td <= pd.Timestamp(asof))]
|
||||
|
||||
def _of_symbol(d: pd.DataFrame) -> pd.DataFrame:
|
||||
"""先取该股票的行,再剪裁日期区间。
|
||||
|
||||
**顺序不能反**:``_price`` / ``_basics`` 是**全部股票**的面板
|
||||
(每日回测里 123 只 × 17 年 ≈ 50 万行)。原先「先剪裁整表、再筛 symbol」
|
||||
让每次画像快照都要处理 50 万行 —— 实测 174 毫秒,而「先筛 symbol
|
||||
再剪裁」只要 11 毫秒(**16 倍**)。两个过滤条件互相独立,交换顺序
|
||||
不改变任何结果(``ProfileBuilder._profile_one`` 内部还会
|
||||
``sort_values("trade_date")``,所以行序也不敏感)。
|
||||
"""
|
||||
if d.empty:
|
||||
return d
|
||||
hit = d[d["symbol"] == symbol]
|
||||
return _slice(hit)
|
||||
|
||||
price = _of_symbol(self._price)
|
||||
if price.empty:
|
||||
return None
|
||||
basics = _slice(self._basics) if not self._basics.empty else self._basics
|
||||
if not basics.empty:
|
||||
basics = basics[basics["symbol"] == symbol]
|
||||
basics = _with_columns(basics, _EMPTY_BASICS)
|
||||
index = _slice(self._index) if not self._index.empty else self._index
|
||||
basics = _with_columns(_of_symbol(self._basics), _EMPTY_BASICS)
|
||||
index = _slice(self._index)
|
||||
sym_div = ctx.div_by_symbol.get(symbol, [])
|
||||
# ProfileBuilder 期望的 events 是 {symbol: DataFrame}
|
||||
events = build_dps_events(pd.DataFrame(sym_div)) if sym_div else {}
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
"""每日动态股票池筛选器(``backtest --mode daily`` 的选股环节)。
|
||||
|
||||
**它做什么**:从 ``start`` 起,对**每一个交易日**按当日可见数据重建股票池。
|
||||
判定逻辑一行不改 —— 仍然调用 :class:`~hdiv.universe.selector.UniverseSelector`
|
||||
与四个 ``Filter``;本模块只负责两件工程上的事:
|
||||
|
||||
1. **取数**:用 :class:`~hdiv.universe.pit.PitRepo` 按区块批量预载,
|
||||
逐日切片在内存完成(单次筛选从 10~18 秒降到秒级);
|
||||
2. **候选集预剪枝**:见下。
|
||||
|
||||
------------------------------------------------------------
|
||||
候选集预剪枝:为什么是「精确」的,而不是「近似」
|
||||
------------------------------------------------------------
|
||||
|
||||
市场滤网要逐行判断 5000 余只股票的交易所/板块/上市年限/市值/流动性,
|
||||
这一段的 Python 开销与**候选数**成正比,是每日循环里最大的一项。
|
||||
|
||||
预剪枝只剔除「在整个回测区间内**不可能**通过市场滤网」的股票,
|
||||
判据都是**上界**:
|
||||
|
||||
- **交易所 / 板块**:与日期无关,不在配置名单里的股票永远不可能通过;
|
||||
- **上市年限**:当 ``list_date + min_listing_years`` 晚于区间**最后一天**时,
|
||||
该股在区间内任何一天都不满足 ``listed_years >= min_listing_years``;
|
||||
- **市值**:当该股在区间内的 ``MAX(total_mv)``(换算为元)仍低于
|
||||
``min_market_cap`` 时,任何一天都不满足市值下限。取不到市值(NULL)时
|
||||
**保留**,不剪。
|
||||
|
||||
被剪掉的股票在原流程里**必然**在第一个滤网(market)就被淘汰,因此:
|
||||
最终入选集合逐只相同,`hd_daily_universe` 的内容也相同。
|
||||
差别只在于「被剪掉的股票没有留下逐滤网的原因」—— 而每日选股模式
|
||||
**不落库逐股淘汰原因**(只落库每日入选成员),所以这个差别不可观测。
|
||||
|
||||
正确性由 ``tests/test_daily.py::test_prune_does_not_change_selection`` 锁定:
|
||||
同一批交易日,开/关剪枝必须选出**完全相同**的成员。
|
||||
|
||||
**流动性没有做预剪枝**:``stock_daily`` 的量价单位在 2015-2019 是「手/千元」、
|
||||
2020 起是「股/元」,用 ``MAX(amount)`` 做上界会在早年低估 1000 倍,
|
||||
误剪掉本该通过的股票。宁可少一项优化,也不接受一个会改变结果的上界。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from hdiv.core.config import UniverseConfig
|
||||
from hdiv.core.errors import HdivError
|
||||
from hdiv.data import db
|
||||
from hdiv.universe.pit import PitRepo
|
||||
from hdiv.universe.selector import UniverseSelector
|
||||
|
||||
__all__ = ["DailyUniverseScreener", "PruneReport", "ScreenDay"]
|
||||
|
||||
#: daily_basic 的市值列以**万元**存放(见 data/units.py)
|
||||
_WAN = 1e4
|
||||
|
||||
|
||||
@dataclass
|
||||
class PruneReport:
|
||||
"""预剪枝的规模,用于回答「为什么候选从 5000 变成 1000」。"""
|
||||
|
||||
total: int = 0
|
||||
pruned_exchange: int = 0
|
||||
pruned_board: int = 0
|
||||
pruned_listing: int = 0
|
||||
pruned_market_cap: int = 0
|
||||
kept: int = 0
|
||||
|
||||
def as_dict(self) -> dict[str, int]:
|
||||
return dict(self.__dict__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScreenDay:
|
||||
"""某一天的选股结果。"""
|
||||
|
||||
trade_date: date
|
||||
candidate_count: int
|
||||
member_count: int
|
||||
symbols: list[str]
|
||||
members: pd.DataFrame
|
||||
stats: dict[str, int] = field(default_factory=dict)
|
||||
#: 当日**市场候选数**(未预剪枝)。与 ``candidate_count``(已预剪枝)分开记录 ——
|
||||
#: 预剪枝是纯性能开关,不该让页面上的「候选」含义随开关变化。
|
||||
listed_count: int = 0
|
||||
#: 入选股票在**决策日的因子取值**(来自 ``UniverseSelector.run`` 的 ``values``)。
|
||||
#: 必须带上它:``selected`` 里没有 ``dividend_yield`` 列(它在滤网的 values 里),
|
||||
#: 只从 ``selected`` 取列会让落库的股息率**整列为 NULL**。
|
||||
values: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
|
||||
class DailyUniverseScreener:
|
||||
"""逐日重建股票池(PIT),复用既有滤网,不改判定口径。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: UniverseConfig,
|
||||
repo: PitRepo,
|
||||
*,
|
||||
verbose: bool = True,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.repo = repo
|
||||
self.selector = UniverseSelector(config, repo=repo)
|
||||
self.verbose = verbose
|
||||
self.prune = PruneReport()
|
||||
self._allowed: set[str] | None = None
|
||||
|
||||
@classmethod
|
||||
def from_strategy(cls, registry: Any, strategy: Any, repo: PitRepo,
|
||||
*, verbose: bool = True) -> DailyUniverseScreener:
|
||||
cfg = registry.resolved_universe(strategy)
|
||||
return cls(cfg, repo, verbose=verbose)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 预剪枝
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def build_prune_set(
|
||||
self, window_start: date, window_end: date, *, use_market_cap: bool = True
|
||||
) -> set[str]:
|
||||
"""计算「在 ``[window_start, window_end]`` 内不可能通过市场滤网」的补集。
|
||||
|
||||
返回**允许保留**的 symbol 集合。
|
||||
"""
|
||||
cfg = self.config.market
|
||||
master = self.repo.stock_master().copy()
|
||||
rep = PruneReport(total=len(master))
|
||||
keep = pd.Series(True, index=master.index)
|
||||
|
||||
def _drop(mask: pd.Series, counter: str) -> None:
|
||||
nonlocal keep
|
||||
hit = keep & mask
|
||||
setattr(rep, counter, getattr(rep, counter) + int(hit.sum()))
|
||||
keep = keep & ~mask
|
||||
|
||||
# --- 交易所 / 板块(与日期无关)---
|
||||
if cfg.exchanges:
|
||||
allowed = {str(x) for x in cfg.exchanges}
|
||||
_drop(~master["exchange"].astype(str).isin(allowed), "pruned_exchange")
|
||||
if cfg.markets:
|
||||
allowed_m = {str(x) for x in cfg.markets}
|
||||
_drop(~master["market"].astype(str).isin(allowed_m), "pruned_board")
|
||||
|
||||
# --- 上市年限:区间最后一天仍不足,则区间内永远不足 ---
|
||||
if cfg.min_listing_years and cfg.min_listing_years > 0:
|
||||
ld = pd.to_datetime(master["list_date"], errors="coerce")
|
||||
need_days = cfg.min_listing_years * 365.25
|
||||
can_pass = (window_end - ld.dt.date).apply(
|
||||
lambda x: x.days if pd.notna(x) else -1
|
||||
) >= need_days
|
||||
_drop(~can_pass, "pruned_listing")
|
||||
|
||||
# --- 市值:区间内 MAX(total_mv) 仍低于下限 ---
|
||||
if use_market_cap:
|
||||
cap = self._max_market_cap(window_start, window_end)
|
||||
if cap:
|
||||
for col, limit, counter in (
|
||||
("total_mv", cfg.min_market_cap, "pruned_market_cap"),
|
||||
("circ_mv", cfg.min_float_market_cap, "pruned_market_cap"),
|
||||
):
|
||||
if limit is None:
|
||||
continue
|
||||
mx = master["symbol"].map(cap.get(col, {}))
|
||||
# 取不到市值时不剪(保守):只有**确知**上限低于阈值才剔除
|
||||
too_small = mx.notna() & (mx < float(limit))
|
||||
_drop(too_small, counter)
|
||||
|
||||
allowed = set(master.loc[keep, "symbol"].astype(str).tolist())
|
||||
rep.kept = len(allowed)
|
||||
self._allowed = allowed
|
||||
self.prune = rep
|
||||
self.repo.set_candidate_scope(allowed)
|
||||
return allowed
|
||||
|
||||
def _max_market_cap(
|
||||
self, start: date, end: date
|
||||
) -> dict[str, dict[str, float]]:
|
||||
"""区间内逐股 ``MAX(total_mv)`` / ``MAX(circ_mv)``,单位为**元**。
|
||||
|
||||
返回 ``{"total_mv": {symbol: 元}, "circ_mv": {symbol: 元}}``。
|
||||
|
||||
单位:``daily_basic.total_mv`` / ``circ_mv`` 以**万元**存放(Tushare 口径),
|
||||
因此换算为元后再与配置里的元阈值比较。与 ``normalize_market_panel``
|
||||
的 ×1e4 是同一件事。
|
||||
"""
|
||||
cfg = self.repo.cfg
|
||||
if not db.table_exists("daily_basic", cfg):
|
||||
return {}
|
||||
try:
|
||||
df = db.read_sql(
|
||||
"SELECT symbol, MAX(total_mv) AS mx_total_mv, "
|
||||
" MAX(circ_mv) AS mx_circ_mv "
|
||||
"FROM daily_basic WHERE trade_date BETWEEN :s AND :e "
|
||||
"GROUP BY symbol",
|
||||
{"s": start, "e": end}, cfg=cfg,
|
||||
)
|
||||
except Exception: # pragma: no cover - 取不到就退化为不剪枝
|
||||
return {}
|
||||
if df.empty:
|
||||
return {}
|
||||
out: dict[str, dict[str, float]] = {"total_mv": {}, "circ_mv": {}}
|
||||
syms = df["symbol"].astype(str).tolist()
|
||||
total = pd.to_numeric(df["mx_total_mv"], errors="coerce") * _WAN
|
||||
circ = pd.to_numeric(df["mx_circ_mv"], errors="coerce") * _WAN
|
||||
for sym, t, c in zip(syms, total, circ, strict=False):
|
||||
if pd.notna(t):
|
||||
out["total_mv"][sym] = float(t)
|
||||
if pd.notna(c):
|
||||
out["circ_mv"][sym] = float(c)
|
||||
return out
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 逐日筛选
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def screen_day(self, day: date) -> ScreenDay:
|
||||
"""筛选单个交易日。"""
|
||||
|
||||
def _hook(stage: str, live: pd.DataFrame) -> None:
|
||||
# 把取数范围收窄到本阶段真正要评估的股票(纯性能开关)
|
||||
self.repo.restrict_to(live["symbol"].tolist())
|
||||
|
||||
try:
|
||||
res = self.selector.run(
|
||||
asof=day, persist=False, verbose=False, on_stage=_hook
|
||||
)
|
||||
finally:
|
||||
self.repo.restrict_to(None)
|
||||
members = res["selected"]
|
||||
symbols = [str(s) for s in members["symbol"].tolist()]
|
||||
listed_total, screened = self.repo.listed_counts(res["asof_date"])
|
||||
vals = res.get("values") or {}
|
||||
return ScreenDay(
|
||||
trade_date=res["asof_date"],
|
||||
candidate_count=screened,
|
||||
member_count=int(res["member_count"]),
|
||||
symbols=symbols,
|
||||
members=members,
|
||||
stats=dict(res["stats"]),
|
||||
listed_count=listed_total,
|
||||
# 只为入选股票保留因子取值(全市场 4000 余只 × 1600 天会白占内存)
|
||||
values={s: dict(vals.get(s) or {}) for s in symbols},
|
||||
)
|
||||
|
||||
def screen(self, days: list[date]) -> dict[date, set[str]]:
|
||||
"""对 ``days`` 逐日筛选,返回 ``{交易日: 入选代码集合}``。"""
|
||||
if self._allowed is None:
|
||||
raise HdivError(
|
||||
"DailyUniverseScreener 必须先 build_prune_set(...) 再 screen(...)。\n"
|
||||
" 预剪枝是可选的性能优化;若不想剪枝,请显式传 use_market_cap=False\n"
|
||||
" 并把候选范围设为「全部上市股票」。"
|
||||
)
|
||||
out: dict[date, set[str]] = {}
|
||||
for day in days:
|
||||
sd = self.screen_day(day)
|
||||
out[sd.trade_date] = set(sd.symbols)
|
||||
return out
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 落库行
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def member_rows(
|
||||
run_id: str, screens: list[ScreenDay], *, created_at: Any
|
||||
) -> list[dict[str, Any]]:
|
||||
"""把逐日选股结果摊平成 ``hd_daily_universe`` 的行。
|
||||
|
||||
取值优先级:**滤网的 ``values`` → ``selected`` 的列**。
|
||||
股息率、支付率、FCF 覆盖等只在 ``values`` 里(它们是滤网算出来的),
|
||||
``selected`` 只有行情/年报均值那几列;反过来 ``total_mv``/``roe_avg``
|
||||
只在列里。只取其中一边都会让某些列整列为 NULL。
|
||||
"""
|
||||
rows: list[dict[str, Any]] = []
|
||||
for sd in screens:
|
||||
m = sd.members
|
||||
if m is None or m.empty:
|
||||
continue
|
||||
for rec in m.to_dict("records"):
|
||||
sym = str(rec.get("symbol"))
|
||||
vals = dict(sd.values.get(sym) or {})
|
||||
merged = {**{k: rec.get(k) for k in rec}, **vals}
|
||||
|
||||
def pick(key: str) -> Any:
|
||||
v = vals.get(key)
|
||||
if v is None or (isinstance(v, float) and v != v):
|
||||
v = rec.get(key)
|
||||
return v
|
||||
|
||||
rows.append({
|
||||
"run_id": run_id,
|
||||
"trade_date": sd.trade_date,
|
||||
"symbol": sym,
|
||||
"name": _s(rec.get("name")),
|
||||
"industry": _s(rec.get("industry")),
|
||||
# 股息率:筛选口径(自算优先)在 values 里;dv_ttm 在列里
|
||||
"dividend_yield": _f(pick("dividend_yield")),
|
||||
"total_mv": _f(pick("total_mv")),
|
||||
"roe_avg": _f(pick("roe_avg")),
|
||||
"listed_count": int(sd.listed_count) if sd.listed_count else None,
|
||||
"candidate_count": int(sd.candidate_count),
|
||||
"values_json": _values_json(merged),
|
||||
"created_at": created_at,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _s(v: Any) -> str | None:
|
||||
if v is None or (isinstance(v, float) and v != v):
|
||||
return None
|
||||
return str(v)[:64]
|
||||
|
||||
|
||||
def _f(v: Any) -> float | None:
|
||||
if v is None:
|
||||
return None
|
||||
try:
|
||||
x = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None if (x != x or np.isinf(x)) else x
|
||||
|
||||
|
||||
def _values_json(rec: dict[str, Any]) -> str:
|
||||
"""入选时的关键因子快照(供「为什么是这只」复核)。"""
|
||||
import json
|
||||
|
||||
keys = (
|
||||
"dividend_yield", "dividend_yield_computed", "dv_ttm", "ttm_dps",
|
||||
"pe_ttm", "pb", "ps_ttm", "total_mv", "circ_mv", "avg_amount_20d",
|
||||
"dividend_continuity_years", "dividend_years_in_window", "payout_ratio",
|
||||
"fcf_dividend_cover", "dps_cagr_5y", "roe", "roe_avg", "roic", "roic_avg",
|
||||
"debt_ratio", "ocf_to_netprofit", "ocf_to_profit_avg", "fin_years_count",
|
||||
)
|
||||
out: dict[str, Any] = {}
|
||||
for k in keys:
|
||||
if k not in rec:
|
||||
continue
|
||||
v = _f(rec.get(k))
|
||||
if v is not None:
|
||||
out[k] = v
|
||||
return json.dumps(out, ensure_ascii=False)
|
||||
@@ -0,0 +1,884 @@
|
||||
"""Point-in-Time 批量取数层(每日动态股票池的性能基座)。
|
||||
|
||||
**为什么需要它**:``--mode daily`` 要在**每个交易日**按当时可见数据重建股票池,
|
||||
而 :class:`~hdiv.universe.selector.UniverseSelector` 单次运行约 10~18 秒 ——
|
||||
其中 ``financial_panel`` 独占约 6 秒。这个开销是**表量级**的(与查询哪一天无关),
|
||||
于是 1600 个交易日 × 12 秒 ≈ 5~8 小时,逐日筛选根本跑不完。
|
||||
|
||||
本模块把「取数」与「派生」拆开:
|
||||
|
||||
- **取数**改为**按区间批量预载一次**,之后逐日切片在内存里完成;
|
||||
- **派生**(最新一期财报的合并、ROE 年化、单位归一化、支付率口径……)
|
||||
**一行都不重写** —— :class:`PitRepo` 继承 :class:`~hdiv.data.repo.Repo`,
|
||||
只覆盖**最底层的那几个取数方法**。上层的
|
||||
``financial_panel`` / ``annual_financial_averages`` 等一律沿用父类实现,
|
||||
因此它们调用到的都是被覆盖后的底层方法。
|
||||
|
||||
这样做的直接后果:**筛选口径只有一份**。``UniverseSelector`` 与四个 ``Filter``
|
||||
在 daily 模式下逐字未改,它们看到的 DataFrame 与直连数据库时逐值相同。
|
||||
这一点有回归测试锁定(``tests/test_daily.py::test_pit_repo_matches_direct_repo``),
|
||||
与 ``tests/test_profile_pit.py`` 锁定实时画像/批量画像一致性的做法相同。
|
||||
|
||||
**内存**:行情与每日指标按区块(默认一年)载入,用完即 ``release_range()`` 释放;
|
||||
财务四表与分红明细体量小(合计约 120 万行),一次性常驻。
|
||||
|
||||
**越界即报错**:``market_panel`` / ``avg_amount`` 只对已载入区间内的时点有定义。
|
||||
查询落在区间外时**抛错而不是返回空表** —— 空表会被上层当成「当天没有股票」,
|
||||
静默产出一个空股票池,属于最难发现的一类错误。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from hdiv.core.config import DataSourceConfig, load_config
|
||||
from hdiv.core.errors import DataGapError, HdivError
|
||||
from hdiv.data import db
|
||||
from hdiv.data.repo import Repo
|
||||
from hdiv.data.units import (
|
||||
normalize_financial_panel,
|
||||
normalize_market_panel,
|
||||
normalize_ohlcv_units,
|
||||
)
|
||||
|
||||
__all__ = ["PitRepo"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 取数列清单(与 Repo 里的 SQL 逐列一致,多一列少一列都会破坏等价性)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MARKET_COLS = (
|
||||
"symbol, trade_date, close, turnover_rate, pe, pe_ttm, pb, ps, ps_ttm, "
|
||||
"dv_ratio, dv_ttm, total_share, float_share, free_share, total_mv, circ_mv"
|
||||
)
|
||||
|
||||
_DAILY_COLS = "symbol, close, volume, amount, trade_date"
|
||||
|
||||
#: 财务表 → 需要的列。``report_type`` 只用于过滤,**不进入返回值**
|
||||
#: (与 ``Repo._latest_financial`` 的 SELECT 列表保持一致)。
|
||||
_FIN_COLS: dict[str, tuple[str, ...]] = {
|
||||
"hd_fina_indicator": (
|
||||
"roe", "roic", "debt_to_assets", "grossprofit_margin",
|
||||
"netprofit_margin", "ocf_to_profit",
|
||||
),
|
||||
"hd_cashflow": (
|
||||
"report_type", "n_cashflow_act", "free_cashflow", "c_pay_dist_dpcp_int_exp",
|
||||
),
|
||||
"hd_balancesheet": (
|
||||
"report_type", "total_assets", "total_liab", "total_hldr_eqy_exc_min_int",
|
||||
"money_cap", "goodwill",
|
||||
),
|
||||
"hd_income": (
|
||||
"report_type", "total_revenue", "revenue", "n_income", "n_income_attr_p",
|
||||
),
|
||||
}
|
||||
|
||||
#: 需要 ``report_type = '1'``(合并报表)过滤的表 —— 与 Repo 中的判断一致
|
||||
_REPORT_TYPE_TABLES = frozenset({"hd_cashflow", "hd_balancesheet", "hd_income"})
|
||||
|
||||
_DIVIDEND_COLS = (
|
||||
"symbol, end_date, ann_date, imp_ann_date, div_proc, cash_div_tax, cash_div, "
|
||||
"stk_div, stk_bo_rate, stk_co_rate, record_date, ex_date, pay_date, base_share"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 诊断计数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class PitRepoStats:
|
||||
"""取数与切片次数(用于回答「到底慢在哪」,而不是靠猜)。"""
|
||||
|
||||
ranges_loaded: int = 0
|
||||
rows_loaded: int = 0
|
||||
reference_loaded: int = 0
|
||||
reference_rows: int = 0
|
||||
market_slices: int = 0
|
||||
amount_slices: int = 0
|
||||
financial_slices: int = 0
|
||||
dividend_slices: int = 0
|
||||
|
||||
def as_dict(self) -> dict[str, int]:
|
||||
return dict(self.__dict__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PitRepo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PitRepo(Repo):
|
||||
"""``Repo`` 的批量预载版本:同样的接口、同样的口径,不同的取数方式。
|
||||
|
||||
用法::
|
||||
|
||||
repo = PitRepo()
|
||||
repo.load_reference(end=date(2026, 9, 4)) # 财务/分红/日历(一次)
|
||||
repo.load_range(date(2020, 1, 1), date(2020, 12, 31))
|
||||
sel = UniverseSelector(cfg, repo=repo)
|
||||
sel.run(asof=date(2020, 6, 3), persist=False)
|
||||
repo.release_range() # 释放行情,换下一个区块
|
||||
"""
|
||||
|
||||
#: 预载时向前多取的自然日数,保证区块首日也能算 20 日均额 / 5 日回看
|
||||
OVERLAP_DAYS = 120
|
||||
|
||||
def __init__(self, cfg: DataSourceConfig | None = None) -> None:
|
||||
super().__init__(cfg)
|
||||
self.stats_ = PitRepoStats()
|
||||
self._loaded = False
|
||||
# 交易日历(常驻)
|
||||
self._cal: np.ndarray = np.array([], dtype="datetime64[ns]")
|
||||
self._cal_days: list[date] = []
|
||||
self._today = date.today()
|
||||
# 常驻参照数据
|
||||
self._fin: dict[str, pd.DataFrame] = {}
|
||||
self._dividend = pd.DataFrame()
|
||||
# 区块数据(可释放)
|
||||
self._market = pd.DataFrame()
|
||||
self._daily = pd.DataFrame()
|
||||
self._daily_pos: dict[str, np.ndarray] = {}
|
||||
self._suspend = pd.DataFrame()
|
||||
self._range: tuple[date, date] | None = None
|
||||
#: 参照数据(财务/分红)的终点。常驻帧只覆盖到这一天,
|
||||
#: 任何超出它的区间查询都必须退回直连,否则会静默少行。
|
||||
self._ref_end: date | None = None
|
||||
# 可见性指纹(每张财务表的已公告日集合)—— 缓存的键
|
||||
self._vis_dates: dict[str, np.ndarray] = {}
|
||||
self._memo: dict[tuple, Any] = {}
|
||||
#: 只对**指定股票**回答 ``annual_financials``(见 :meth:`restrict_to`)
|
||||
self._scope: set[str] | None = None
|
||||
#: 候选集预剪枝(见 :meth:`set_candidate_scope`);None = 与 Repo 口径一致
|
||||
self._candidate_scope: set[str] | None = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 载入
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def load_reference(self, end: date) -> None:
|
||||
"""一次性载入跨区块共享的数据:交易日历、财务四表、分红明细。
|
||||
|
||||
财务表按 ``ann_date <= end`` 取全量(**不加 end_date 下界**)——
|
||||
``Repo._latest_financial`` 本身没有报告期下界,若在这里加了,
|
||||
「最新一期财报很旧」的股票会被静默漏掉,与直连口径不一致。
|
||||
"""
|
||||
cfg = self.cfg
|
||||
# --- 交易日历:从 2000 年起,保证任何 PIT 回看窗口都有交易日 ---
|
||||
cal = db.read_sql(
|
||||
"SELECT calendar_date FROM trading_calendar "
|
||||
"WHERE is_open = 1 AND calendar_date <= :e ORDER BY calendar_date",
|
||||
{"e": end}, cfg=cfg,
|
||||
)
|
||||
if cal.empty:
|
||||
raise DataGapError("交易日历为空,无法进行每日选股")
|
||||
self._cal = pd.to_datetime(cal["calendar_date"]).to_numpy(dtype="datetime64[ns]")
|
||||
self._cal_days = [pd.Timestamp(x).date() for x in self._cal]
|
||||
self.stats_.reference_rows += len(cal)
|
||||
|
||||
# --- 财务四表 ---
|
||||
for table, cols in _FIN_COLS.items():
|
||||
if not db.table_exists(table, cfg):
|
||||
self._fin[table] = pd.DataFrame(
|
||||
columns=["symbol", "end_date", "ann_date", *cols]
|
||||
)
|
||||
continue
|
||||
sel = ", ".join(f"`{c}`" for c in ("symbol", "end_date", "ann_date", *cols))
|
||||
df = db.read_sql(
|
||||
f"SELECT {sel} FROM `{table}` WHERE ann_date <= :e "
|
||||
"ORDER BY symbol, end_date, ann_date",
|
||||
{"e": end}, cfg=cfg,
|
||||
)
|
||||
for c in ("end_date", "ann_date"):
|
||||
if not df.empty:
|
||||
df[c] = pd.to_datetime(df[c]).dt.date
|
||||
# 内部再存一份 datetime64 版本:**判定与排序一律走它**。
|
||||
# object dtype 的 datetime.date 列在 pandas 里做比较/排序会退化到
|
||||
# Python 循环,实测一个 groupby(...).max() 就要 2.7 秒/次;
|
||||
# 换成 datetime64 后是 Cython 路径,快两个数量级。
|
||||
# 对外的 ann_date/end_date 仍是 date 对象,与 Repo 的返回类型一致。
|
||||
df["_ann"] = pd.to_datetime(df["ann_date"]).to_numpy(dtype="datetime64[ns]")
|
||||
df["_end"] = pd.to_datetime(df["end_date"]).to_numpy(dtype="datetime64[ns]")
|
||||
df["_end_month"] = pd.to_datetime(df["end_date"]).dt.month.to_numpy()
|
||||
if "report_type" in df.columns:
|
||||
df["_is1"] = df["report_type"].astype(str).to_numpy() == "1"
|
||||
else:
|
||||
df["_is1"] = True
|
||||
for c in cols:
|
||||
if c in df.columns:
|
||||
df[c] = pd.to_numeric(df[c], errors="coerce")
|
||||
# **一次性预排序**(symbol, 报告期, 公告日)升序。
|
||||
# 这一步让每个查询都不必再对 30 万行排序:窗口函数要的
|
||||
# 「每个 symbol 取 end_date 最大、并列时 ann_date 最大」恰好等价于
|
||||
# 「在已按 (symbol, end_date, ann_date) 升序的帧上按 symbol 取最后一行」,
|
||||
# 于是 drop_duplicates(keep="last") 就是 SQL 里
|
||||
# ROW_NUMBER() OVER (PARTITION BY symbol ORDER BY end_date DESC,
|
||||
# ann_date DESC) = 1。
|
||||
# 实测:每次查询省掉一次约 0.3~0.5 秒的全表排序;年报季缓存每天都会
|
||||
# 失效(新公告改变了可见集合),这一项就是模拟阶段的主要开销。
|
||||
df = df.sort_values(
|
||||
["symbol", "_end", "_ann"], kind="mergesort", ignore_index=True
|
||||
)
|
||||
self._fin[table] = df
|
||||
self.stats_.reference_rows += len(df)
|
||||
if not df.empty:
|
||||
self._vis_dates[table] = np.unique(df["_ann"])
|
||||
|
||||
# --- 分红(PIT 过滤留给查询时,因为窗口随 asof 滑动)---
|
||||
#
|
||||
# 预载条件必须是**超集**:`imp_ann_date <= :e OR ex_date <= :e`。
|
||||
# 若只按 `imp_ann_date <= :e` 取,会漏掉两类行,而 `dividend_events`
|
||||
# (持仓期间分红入账)需要它们:
|
||||
# - 74 行 `imp_ann_date IS NULL` 但有 `ex_date`(Repo.dividend_events
|
||||
# 本身没有 imp_ann_date 条件,是包含这些行的);
|
||||
# - 9 行 `imp_ann_date > ex_date`(数据源瑕疵)。
|
||||
# 漏掉就是**静默少算现金分红**,所以宁可取宽。
|
||||
# `dividend_records` 的 PIT 语义不受影响:它的 `imp_ann_date <= asof`
|
||||
# 会把这两类行排除(NaT 比较为 False)。
|
||||
if db.table_exists("hd_dividend", cfg):
|
||||
div = db.read_sql(
|
||||
f"SELECT {_DIVIDEND_COLS} FROM hd_dividend "
|
||||
"WHERE (imp_ann_date <= :e OR ex_date <= :e)",
|
||||
{"e": end}, cfg=cfg,
|
||||
)
|
||||
for c in ("end_date", "ann_date", "imp_ann_date", "record_date",
|
||||
"ex_date", "pay_date"):
|
||||
if c in div.columns and not div.empty:
|
||||
div[c] = pd.to_datetime(div[c]).dt.date
|
||||
for c in ("cash_div_tax", "cash_div", "stk_div", "stk_bo_rate",
|
||||
"stk_co_rate", "base_share"):
|
||||
if c in div.columns:
|
||||
div[c] = pd.to_numeric(div[c], errors="coerce")
|
||||
else:
|
||||
div = pd.DataFrame(columns=[c.strip() for c in _DIVIDEND_COLS.split(",")])
|
||||
self._dividend = div
|
||||
self.stats_.reference_rows += len(div)
|
||||
self.stats_.reference_loaded += 1
|
||||
self._ref_end = end
|
||||
self._loaded = True
|
||||
|
||||
def load_range(self, start: date, end: date) -> None:
|
||||
"""载入 ``[start, end]`` 的行情/每日指标/停牌数据(替换上一个区块)。
|
||||
|
||||
实际取数区间会向前扩 :attr:`OVERLAP_DAYS` 个自然日,使区块首日的
|
||||
「5 日回看」「20 日均额」仍能算全。
|
||||
"""
|
||||
if not self._loaded:
|
||||
raise HdivError("PitRepo 必须先 load_reference(end) 再 load_range(...)")
|
||||
cfg = self.cfg
|
||||
lo = start - timedelta(days=self.OVERLAP_DAYS)
|
||||
|
||||
mk = db.read_sql(
|
||||
f"SELECT {_MARKET_COLS} FROM daily_basic "
|
||||
"WHERE trade_date BETWEEN :s AND :e",
|
||||
{"s": lo, "e": end}, cfg=cfg,
|
||||
)
|
||||
if not mk.empty:
|
||||
mk["trade_date"] = pd.to_datetime(mk["trade_date"])
|
||||
# 排序在 pandas 里做(约 0.5 秒),不用 SQL 的 ORDER BY ——
|
||||
# 实测 MySQL 对 170 万行结果集做 filesort 要多花约 60 秒,
|
||||
# 而后续的 searchsorted 切片只要求「按 trade_date 升序」。
|
||||
mk = mk.sort_values(["trade_date", "symbol"], ignore_index=True)
|
||||
for c in ("close", "turnover_rate", "pe", "pe_ttm", "pb", "ps", "ps_ttm",
|
||||
"dv_ratio", "dv_ttm", "total_share", "float_share", "free_share",
|
||||
"total_mv", "circ_mv"):
|
||||
if c in mk.columns:
|
||||
mk[c] = pd.to_numeric(mk[c], errors="coerce")
|
||||
self._market = mk.reset_index(drop=True)
|
||||
self.stats_.rows_loaded += len(mk)
|
||||
|
||||
dl = db.read_sql(
|
||||
f"SELECT {_DAILY_COLS} FROM stock_daily "
|
||||
"WHERE trade_date BETWEEN :s AND :e",
|
||||
{"s": lo, "e": end}, cfg=cfg,
|
||||
)
|
||||
if not dl.empty:
|
||||
dl["trade_date"] = pd.to_datetime(dl["trade_date"])
|
||||
dl = dl.sort_values(["trade_date", "symbol"], ignore_index=True)
|
||||
for c in ("close", "volume", "amount"):
|
||||
if c in dl.columns:
|
||||
dl[c] = pd.to_numeric(dl[c], errors="coerce")
|
||||
self._daily = dl.reset_index(drop=True)
|
||||
self._daily_pos = self._build_symbol_index(self._daily)
|
||||
self.stats_.rows_loaded += len(dl)
|
||||
|
||||
if db.table_exists("hd_suspend", cfg):
|
||||
sp = db.read_sql(
|
||||
"SELECT symbol, trade_date FROM hd_suspend "
|
||||
"WHERE trade_date BETWEEN :s AND :e AND suspend_type = 'S'",
|
||||
{"s": lo, "e": end}, cfg=cfg,
|
||||
)
|
||||
if not sp.empty:
|
||||
sp["trade_date"] = pd.to_datetime(sp["trade_date"]).dt.date
|
||||
else:
|
||||
sp = pd.DataFrame(columns=["symbol", "trade_date"])
|
||||
self._suspend = sp
|
||||
self.stats_.rows_loaded += len(sp)
|
||||
|
||||
self._range = (lo, end)
|
||||
self.stats_.ranges_loaded += 1
|
||||
|
||||
def release_range(self) -> None:
|
||||
"""释放区块数据(内存随区块数保持常数,而不是随回测长度增长)。"""
|
||||
self._market = pd.DataFrame()
|
||||
self._daily = pd.DataFrame()
|
||||
self._daily_pos = {}
|
||||
self._suspend = pd.DataFrame()
|
||||
self._range = None
|
||||
|
||||
@staticmethod
|
||||
def _build_symbol_index(df: pd.DataFrame) -> dict[str, np.ndarray]:
|
||||
"""``symbol → 行位置数组``(行内保持日期升序)。
|
||||
|
||||
只存整数位置(每行 8 字节),不复制数据。财务/画像会对**单只股票**做
|
||||
上万次 ``avg_amount`` 查询,逐次 ``df[df.symbol == s]`` 是全表扫描,
|
||||
实测会占掉每日循环的大头。
|
||||
"""
|
||||
if df.empty:
|
||||
return {}
|
||||
codes, labels = pd.factorize(df["symbol"])
|
||||
order = np.argsort(codes, kind="stable")
|
||||
sorted_codes = codes[order]
|
||||
bounds = np.searchsorted(sorted_codes, np.arange(len(labels)), side="left")
|
||||
ends = np.searchsorted(sorted_codes, np.arange(len(labels)), side="right")
|
||||
return {
|
||||
str(label): order[bounds[i]:ends[i]] for i, label in enumerate(labels)
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 交易日历(覆盖父类的逐次 SQL 查询)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _require_loaded(self) -> None:
|
||||
if not self._loaded or self._cal.size == 0:
|
||||
raise HdivError("PitRepo 未载入参照数据,请先调用 load_reference(end)")
|
||||
|
||||
def trading_day(self, asof: date | None = None) -> date:
|
||||
self._require_loaded()
|
||||
if asof is None:
|
||||
asof = min(self._today, self._cal_days[-1])
|
||||
i = int(np.searchsorted(self._cal, np.datetime64(asof, "ns"), side="right")) - 1
|
||||
if i < 0:
|
||||
raise DataGapError(f"交易日历中找不到 <= {asof} 的交易日")
|
||||
return self._cal_days[i]
|
||||
|
||||
def prev_trading_day(self, d: date) -> date:
|
||||
self._require_loaded()
|
||||
i = int(np.searchsorted(self._cal, np.datetime64(d, "ns"), side="left")) - 1
|
||||
if i < 0:
|
||||
raise DataGapError(f"交易日历中找不到 < {d} 的交易日")
|
||||
return self._cal_days[i]
|
||||
|
||||
def trading_days(self, start: date, end: date) -> list[date]:
|
||||
self._require_loaded()
|
||||
i0 = int(np.searchsorted(self._cal, np.datetime64(start, "ns"), side="left"))
|
||||
i1 = int(np.searchsorted(self._cal, np.datetime64(end, "ns"), side="right"))
|
||||
return self._cal_days[i0:i1]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 区块内切片助手
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _assert_in_range(self, asof: date, what: str) -> None:
|
||||
if self._range is None:
|
||||
raise HdivError(f"PitRepo 尚未 load_range,无法查询 {what}")
|
||||
lo, hi = self._range
|
||||
if not (lo <= asof <= hi):
|
||||
raise HdivError(
|
||||
f"{what} 的时点 {asof} 落在已载入区间 [{lo}, {hi}] 之外。\n"
|
||||
f" 这是保护性报错:返回空表会被上层当成「该日没有股票」,\n"
|
||||
f" 静默产出一个空股票池。请调整 load_range 的区间。"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _slice_by_date(df: pd.DataFrame, lo: date, hi: date) -> pd.DataFrame:
|
||||
"""按 ``trade_date``(datetime64)取闭区间切片(对数复杂度)。"""
|
||||
if df.empty:
|
||||
return df
|
||||
col = df["trade_date"].to_numpy(dtype="datetime64[ns]")
|
||||
i0 = int(np.searchsorted(col, np.datetime64(lo, "ns"), side="left"))
|
||||
i1 = int(np.searchsorted(col, np.datetime64(hi, "ns"), side="right"))
|
||||
return df.iloc[i0:i1]
|
||||
|
||||
def _window_days(self, d0: date, window: int) -> list[date]:
|
||||
"""``<= d0`` 的最近 ``window`` 个交易日(与 Repo 的取值方式一致)。"""
|
||||
i1 = int(np.searchsorted(self._cal, np.datetime64(d0, "ns"), side="right"))
|
||||
# Repo 先按自然日 [d0 - 3*window, d0] 取全部交易日,再取末尾 window 个
|
||||
lo = d0 - timedelta(days=window * 3)
|
||||
i0 = int(np.searchsorted(self._cal, np.datetime64(lo, "ns"), side="left"))
|
||||
return self._cal_days[i0:i1][-window:]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 行情 / 每日指标
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_candidate_scope(self, allowed: list[str] | set[str] | None) -> None:
|
||||
"""把 :meth:`listed_universe` 的候选集限制在 ``allowed``(``None`` = 不限制)。
|
||||
|
||||
仅供每日选股器的**保守预剪枝**使用:``allowed`` 必须是「在整个回测区间内
|
||||
不可能通过市场滤网」的补集(见 :mod:`hdiv.universe.daily`)。
|
||||
设了范围之后 :meth:`listed_universe` 不再与 :class:`Repo` 逐值一致 ——
|
||||
这是**故意的**,因此默认 ``None``,等价性测试在默认状态下进行。
|
||||
"""
|
||||
self._candidate_scope = None if allowed is None else {str(s) for s in allowed}
|
||||
|
||||
def listed_universe(self, asof: date) -> pd.DataFrame:
|
||||
"""与 :meth:`Repo.listed_universe` 一致,另可叠加候选集预剪枝。"""
|
||||
out = super().listed_universe(asof)
|
||||
if self._candidate_scope is None or out.empty:
|
||||
return out
|
||||
keep = out["symbol"].astype(str).isin(self._candidate_scope)
|
||||
return out[keep].reset_index(drop=True)
|
||||
|
||||
def listed_counts(self, asof: date) -> tuple[int, int]:
|
||||
"""``(当日市场候选数, 预剪枝后候选数)``。
|
||||
|
||||
两个数都要留痕:只记一个会让页面上的「候选」在开/关预剪枝时含义不同,
|
||||
而预剪枝是**纯性能开关**,不该改变任何可见数字的语义。
|
||||
额外的这次 ``listed_universe`` 约 1 毫秒(``stock_master`` 已缓存)。
|
||||
"""
|
||||
out = Repo.listed_universe(self, asof)
|
||||
total = int(len(out))
|
||||
if self._candidate_scope is None or out.empty:
|
||||
return total, total
|
||||
kept = int(out["symbol"].astype(str).isin(self._candidate_scope).sum())
|
||||
return total, kept
|
||||
|
||||
def market_panel(self, asof: date, *, lookback_days: int = 0) -> pd.DataFrame:
|
||||
"""与 :meth:`Repo.market_panel` 逐值一致,只是数据来自内存区块。"""
|
||||
self._assert_in_range(asof, "market_panel")
|
||||
self.stats_.market_slices += 1
|
||||
d0 = self.trading_day(asof)
|
||||
if lookback_days <= 0:
|
||||
sub = self._slice_by_date(self._market, d0, d0).copy()
|
||||
if not sub.empty:
|
||||
sub["trade_date"] = sub["trade_date"].dt.date
|
||||
sub["is_fresh"] = True
|
||||
sub["asof_trade_date"] = d0
|
||||
return normalize_market_panel(sub)
|
||||
|
||||
days = self._window_days(d0, lookback_days)
|
||||
if not days:
|
||||
out = self._market.iloc[0:0].copy()
|
||||
out["is_fresh"] = pd.Series(dtype=bool)
|
||||
out["asof_trade_date"] = pd.Series(dtype="object")
|
||||
return out
|
||||
sub = self._slice_by_date(self._market, days[0], d0).copy()
|
||||
if sub.empty:
|
||||
sub["is_fresh"] = pd.Series(dtype=bool)
|
||||
sub["asof_trade_date"] = pd.Series(dtype="object")
|
||||
return sub
|
||||
# Repo 把 trade_date 转成 datetime.date 后再排序取 last,此处保持一致
|
||||
sub["trade_date"] = sub["trade_date"].dt.date
|
||||
sub = sub.sort_values(["symbol", "trade_date"])
|
||||
last = sub.groupby("symbol", as_index=False).last()
|
||||
last["is_fresh"] = last["trade_date"].eq(d0)
|
||||
last["asof_trade_date"] = d0
|
||||
return normalize_market_panel(last.reset_index(drop=True))
|
||||
|
||||
def avg_amount(
|
||||
self, asof: date, window: int = 20, *, symbols: list[str] | None = None
|
||||
) -> pd.DataFrame:
|
||||
"""与 :meth:`Repo.avg_amount` 逐值一致。
|
||||
|
||||
``symbols`` 非空时走**预建的 symbol→行位置索引**:画像会对单只股票
|
||||
反复查询,逐次全表过滤会让每日循环退化成「天数 × 全表扫描」。
|
||||
|
||||
**区块外**:带 ``symbols`` 的窄查询回退到直连数据库 —— 它走
|
||||
``symbol IN (...)`` 索引,是毫秒级;而全市场查询必须落在已载入区块内,
|
||||
否则会返回空表(被上层当成「当天没有股票」)。画像路径在区块释放后
|
||||
正是靠这条回退工作的。
|
||||
"""
|
||||
if self._range is None or not (self._range[0] <= asof <= self._range[1]):
|
||||
if symbols:
|
||||
return Repo.avg_amount(self, asof, window=window, symbols=symbols)
|
||||
self._assert_in_range(asof, "avg_amount(全市场)")
|
||||
self.stats_.amount_slices += 1
|
||||
d0 = self.trading_day(asof)
|
||||
days = self._window_days(d0, window)
|
||||
if not days:
|
||||
return pd.DataFrame(columns=["symbol", "avg_amount", "n"])
|
||||
lo, hi = days[0], days[-1]
|
||||
if symbols:
|
||||
parts: list[pd.DataFrame] = []
|
||||
for sym in symbols:
|
||||
pos = self._daily_pos.get(str(sym))
|
||||
if pos is None or pos.size == 0:
|
||||
continue
|
||||
sub = self._daily.take(pos)
|
||||
sub = self._slice_by_date(sub, lo, hi)
|
||||
if not sub.empty:
|
||||
parts.append(sub)
|
||||
df = pd.concat(parts, ignore_index=True) if parts else self._daily.iloc[0:0]
|
||||
else:
|
||||
df = self._slice_by_date(self._daily, lo, hi)
|
||||
|
||||
if df.empty:
|
||||
return pd.DataFrame(columns=["symbol", "avg_amount", "n"])
|
||||
df = df[["symbol", "close", "volume", "amount"]].copy()
|
||||
for c in ("close", "volume", "amount"):
|
||||
df[c] = pd.to_numeric(df[c], errors="coerce")
|
||||
# 必须**逐切片**归一化:单位判定是逐行的,切片与全表的判定结果相同,
|
||||
# 但如果在预载时统一换算,区块边界处会与 Repo 的窗口口径分叉。
|
||||
df, _diag = normalize_ohlcv_units(df)
|
||||
g = df.groupby("symbol", as_index=False).agg(
|
||||
avg_amount=("amount", "mean"), n=("amount", "size")
|
||||
)
|
||||
return g
|
||||
|
||||
def suspended_on(self, asof: date) -> set[str]:
|
||||
self._assert_in_range(asof, "suspended_on")
|
||||
if self._suspend.empty:
|
||||
return set()
|
||||
d0 = self.trading_day(asof)
|
||||
hit = self._suspend[self._suspend["trade_date"] == d0]
|
||||
return set(hit["symbol"].tolist())
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 财务(只覆盖底层取数;上层派生一律沿用父类)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _fin_table(self, table: str) -> pd.DataFrame:
|
||||
df = self._fin.get(table)
|
||||
if df is None:
|
||||
return pd.DataFrame(columns=["symbol", "end_date", "ann_date"])
|
||||
return df
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 可见性缓存:同一批「已公告财报」→ 同一个结果
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _visible_key(self, table: str, asof: date) -> int:
|
||||
"""``asof`` 之前已公告的财报**条数**(该表已公告日的排序插入位置)。
|
||||
|
||||
这是「哪些财报当时可见」的**充分统计量**:条数相同,可见集合就相同,
|
||||
因此以它为缓存键是**精确**的,不是近似 —— 与「按 asof 缓存」不同,
|
||||
后者在年报季几乎每天都不命中,等于没缓存。
|
||||
"""
|
||||
arr = self._vis_dates.get(table)
|
||||
if arr is None or arr.size == 0:
|
||||
return 0
|
||||
return int(np.searchsorted(arr, np.datetime64(asof, "ns"), side="right"))
|
||||
|
||||
def _memo_get(self, key: tuple, build: Any) -> Any:
|
||||
hit = self._memo.get(key)
|
||||
if hit is None:
|
||||
hit = build()
|
||||
# 键随 asof 单调变化,历史条目不会再被命中 —— 小容量即可
|
||||
if len(self._memo) > 16:
|
||||
self._memo.clear()
|
||||
self._memo[key] = hit
|
||||
return hit
|
||||
|
||||
def restrict_to(self, symbols: list[str] | None) -> None:
|
||||
"""把 ``annual_financials`` 的回答范围收窄到 ``symbols``(``None`` = 全市场)。
|
||||
|
||||
为什么可以收窄:``annual_financials`` 唯一的消费者是分红滤网的
|
||||
``_fy_table``,它把结果建成 ``{(symbol, 财年): 行}`` 后只按**候选股**查表,
|
||||
非候选股的行永远不会被读取。而它默认会对全市场 5000 余只 × 10 个财年
|
||||
构造约 5 万行再逐行 ``iterrows()`` —— 实测 2.8 秒/天,是每日选股里
|
||||
最大的单项开销;收窄到市场/风险滤网的存活者(约 170 只)后降到 0.1 秒级。
|
||||
|
||||
这不是「近似」:被剔除的行在调用方从未被访问。收窄只在**筛选路径**上生效,
|
||||
调用方必须在用完后显式 ``restrict_to(None)`` 复位(每日选股器就是这么做的),
|
||||
否则画像路径的 ``annual_financials`` 会被误伤。
|
||||
"""
|
||||
self._scope = None if symbols is None else {str(s) for s in symbols}
|
||||
|
||||
def _scoped(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
if self._scope is None or df.empty or "symbol" not in df.columns:
|
||||
return df
|
||||
return df[df["symbol"].isin(self._scope)]
|
||||
|
||||
@staticmethod
|
||||
def _np(d: date) -> np.datetime64:
|
||||
return np.datetime64(d, "ns")
|
||||
|
||||
def _latest_financial(
|
||||
self, asof: date, table: str, cols: list[str], *,
|
||||
symbols: list[str] | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""等价于 ``Repo._latest_financial`` 的窗口函数:
|
||||
|
||||
``ROW_NUMBER() OVER (PARTITION BY symbol ORDER BY end_date DESC, ann_date DESC)``
|
||||
,条件 ``ann_date <= asof AND ann_date >= end_date``(三张表另加
|
||||
``report_type = '1'``)。父类的 :meth:`Repo.financial_panel` 会继续
|
||||
调用本方法,因此合并与衍生指标的代码原封不动。
|
||||
"""
|
||||
self.stats_.financial_slices += 1
|
||||
empty = pd.DataFrame(columns=["symbol", "end_date", "ann_date", *cols])
|
||||
df = self._fin_table(table)
|
||||
if df.empty:
|
||||
return empty
|
||||
|
||||
def _build() -> pd.DataFrame:
|
||||
a = self._np(asof)
|
||||
m = (df["_ann"] <= a) & (df["_ann"] >= df["_end"])
|
||||
if table in _REPORT_TYPE_TABLES:
|
||||
m &= df["_is1"].to_numpy()
|
||||
sub = df[m]
|
||||
if sub.empty:
|
||||
return empty
|
||||
# 存储已按 (symbol, _end, _ann) 升序预排 → 取每个 symbol 的最后一行
|
||||
# 就是 SQL 的 ROW_NUMBER(... ORDER BY end_date DESC, ann_date DESC) = 1
|
||||
first = sub.drop_duplicates(subset=["symbol"], keep="last")
|
||||
out = first[["symbol", "end_date", "ann_date", *cols]].reset_index(drop=True)
|
||||
return normalize_financial_panel(out)
|
||||
|
||||
# 全市场结果按「可见财报条数」缓存;symbols 只是它的子集,不能反过来
|
||||
# 用子集覆盖全量缓存(否则下一次全市场查询会拿到残缺的面板)。
|
||||
key = ("latest", table, tuple(cols), self._visible_key(table, asof))
|
||||
full = self._memo_get(key, _build)
|
||||
if symbols:
|
||||
return full[full["symbol"].isin(set(symbols))].reset_index(drop=True)
|
||||
return full
|
||||
|
||||
def annual_financial_history(
|
||||
self, asof: date, *, years: int = 6, symbols: list[str] | None = None
|
||||
) -> pd.DataFrame:
|
||||
"""等价于 ``Repo.annual_financial_history``:只取年报,按
|
||||
``(symbol, end_date)`` 取最大公告日那一行,再做百分数归一化。"""
|
||||
df = self._fin_table("hd_fina_indicator")
|
||||
if df.empty:
|
||||
return pd.DataFrame(columns=["symbol", "year", "roe", "roic"])
|
||||
since = date(asof.year - years - 1, 12, 31)
|
||||
|
||||
def _build() -> pd.DataFrame:
|
||||
a, sn = self._np(asof), self._np(since)
|
||||
m = (
|
||||
(df["_ann"] <= a)
|
||||
& (df["_ann"] >= df["_end"])
|
||||
& (df["_end_month"] == 12)
|
||||
& (df["_end"] >= sn)
|
||||
)
|
||||
sub = df[m]
|
||||
if sub.empty:
|
||||
return pd.DataFrame(columns=["symbol", "year", "roe", "roic"])
|
||||
# 存储已按 (symbol, _end, _ann) 升序预排 → 每个 (symbol, 报告期)
|
||||
# 取最后一行即「最大 ann_date」那一行(等价于 SQL 的 MAX(ann_date) 自连接;
|
||||
# 前提是 (symbol, end_date, ann_date) 无重复,已由实测确认)。
|
||||
fin = sub.drop_duplicates(subset=["symbol", "_end"], keep="last").copy()
|
||||
fin["year"] = fin["_end"].dt.year
|
||||
# 只保留 Repo.annual_financial_history 的 SELECT 列表 ——
|
||||
# 多带一列(如 debt_to_assets)会让下游列集合与直连口径分叉。
|
||||
keep = ["symbol", "end_date", "ann_date", "roe", "roic",
|
||||
"grossprofit_margin", "netprofit_margin", "ocf_to_profit", "year"]
|
||||
for c in ("roe", "roic", "grossprofit_margin", "netprofit_margin",
|
||||
"ocf_to_profit"):
|
||||
if c in fin.columns:
|
||||
fin[c] = fin[c] / 100.0
|
||||
fin = fin[keep]
|
||||
ocf = self._annual_ocf_ratio(asof, since, symbols=None)
|
||||
if not ocf.empty:
|
||||
fin = fin.merge(ocf, on=["symbol", "year"], how="left")
|
||||
return fin
|
||||
|
||||
key = ("afh", self._visible_key("hd_fina_indicator", asof), years, str(since))
|
||||
full = self._memo_get(key, _build)
|
||||
if symbols:
|
||||
return full[full["symbol"].isin(set(symbols))].reset_index(drop=True)
|
||||
return full
|
||||
|
||||
def annual_financial_averages(
|
||||
self, asof: date, *, years: int = 5, min_years: int = 3,
|
||||
symbols: list[str] | None = None, hist: pd.DataFrame | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""缓存版本:全市场结果按可见财报条数复用,``symbols`` 只做子集。
|
||||
|
||||
逐股聚合与「是否只算这些股票」无关(每个 symbol 独立求均值),
|
||||
因此「先算全市场再取子集」与直连口径逐值相同。
|
||||
"""
|
||||
if hist is not None:
|
||||
return Repo.annual_financial_averages(
|
||||
self, asof, years=years, min_years=min_years, symbols=symbols, hist=hist,
|
||||
)
|
||||
key = ("afa", self._visible_key("hd_fina_indicator", asof), years, min_years)
|
||||
full = self._memo_get(
|
||||
key,
|
||||
lambda: Repo.annual_financial_averages(
|
||||
self, asof, years=years, min_years=min_years, symbols=None,
|
||||
),
|
||||
)
|
||||
if symbols:
|
||||
return full[full["symbol"].isin(set(symbols))].reset_index(drop=True)
|
||||
return full
|
||||
|
||||
def _annual_ocf_ratio(
|
||||
self, asof: date, since: date, *, symbols: list[str] | None = None
|
||||
) -> pd.DataFrame:
|
||||
"""等价于 ``Repo._annual_ocf_ratio``:现金流量表 ⋈ 利润表(同年报期同公告日)。"""
|
||||
cf, inc = self._fin_table("hd_cashflow"), self._fin_table("hd_income")
|
||||
if cf.empty or inc.empty:
|
||||
return pd.DataFrame(columns=["symbol", "year", "ocf_to_netprofit_calc"])
|
||||
|
||||
def _build() -> pd.DataFrame:
|
||||
a, sn = self._np(asof), self._np(since)
|
||||
c = cf[cf["_is1"].to_numpy()]
|
||||
i = inc[inc["_is1"].to_numpy()]
|
||||
c = c[(c["_end_month"] == 12) & (c["_end"] >= sn) & (c["_ann"] <= a)]
|
||||
if c.empty:
|
||||
return pd.DataFrame(columns=["symbol", "year", "ocf_to_netprofit_calc"])
|
||||
joined = c.merge(
|
||||
i[["symbol", "_end", "_ann", "n_income_attr_p"]],
|
||||
on=["symbol", "_end", "_ann"], how="inner",
|
||||
suffixes=("", "_i"),
|
||||
)
|
||||
if joined.empty:
|
||||
return pd.DataFrame(columns=["symbol", "year", "ocf_to_netprofit_calc"])
|
||||
joined["year"] = joined["_end"].dt.year
|
||||
ocf = pd.to_numeric(joined["n_cashflow_act"], errors="coerce")
|
||||
ni = pd.to_numeric(joined["n_income_attr_p"], errors="coerce")
|
||||
joined["ocf_to_netprofit_calc"] = ocf / ni.replace(0, pd.NA)
|
||||
return joined[["symbol", "year", "ocf_to_netprofit_calc"]]
|
||||
|
||||
key = ("ocf", self._visible_key("hd_cashflow", asof),
|
||||
self._visible_key("hd_income", asof), str(since))
|
||||
full = self._memo_get(key, _build)
|
||||
if symbols:
|
||||
return full[full["symbol"].isin(set(symbols))].reset_index(drop=True)
|
||||
return full
|
||||
|
||||
def annual_financials(
|
||||
self, asof: date, *, years: int = 12, symbols: list[str] | None = None
|
||||
) -> pd.DataFrame:
|
||||
"""等价于 ``Repo.annual_financials``(年报口径、按财年对齐)。
|
||||
|
||||
``symbols`` 为空时若已通过 :meth:`restrict_to` 设定了范围,则只返回
|
||||
范围内的股票 —— 见 :meth:`restrict_to` 对「为什么这是精确的」的说明。
|
||||
"""
|
||||
inc, cf = self._fin_table("hd_income"), self._fin_table("hd_cashflow")
|
||||
empty = pd.DataFrame(
|
||||
columns=["symbol", "year", "n_income", "n_income_attr_p",
|
||||
"n_cashflow_act", "free_cashflow", "c_pay_dist_dpcp_int_exp"]
|
||||
)
|
||||
if inc.empty:
|
||||
return empty
|
||||
since = date(asof.year - years - 1, 12, 31)
|
||||
a, sn = self._np(asof), self._np(since)
|
||||
want = set(symbols) if symbols else self._scope
|
||||
n12 = inc["_end_month"].to_numpy() == 12
|
||||
i = inc[inc["_is1"].to_numpy() & n12].copy()
|
||||
i = i[(i["_end"] >= sn) & (i["_ann"] <= a) & (i["_ann"] >= i["_end"])]
|
||||
if want:
|
||||
i = i[i["symbol"].isin(want)]
|
||||
if i.empty:
|
||||
return empty
|
||||
if cf.empty:
|
||||
joined = i.copy()
|
||||
for c in ("n_cashflow_act", "free_cashflow", "c_pay_dist_dpcp_int_exp"):
|
||||
joined[c] = pd.NA
|
||||
else:
|
||||
cn12 = cf["_end_month"].to_numpy() == 12
|
||||
f = cf[cf["_is1"].to_numpy() & cn12 & (cf["_end"].to_numpy() >= sn)][
|
||||
["symbol", "_end", "_ann", "n_cashflow_act", "free_cashflow",
|
||||
"c_pay_dist_dpcp_int_exp"]
|
||||
]
|
||||
joined = i.merge(f, on=["symbol", "_end", "_ann"], how="left")
|
||||
# 利润表与现金流量表可能各有多行(重复公告),LEFT JOIN 会放大行数 ——
|
||||
# 与 SQL 的 LEFT JOIN 行为一致,故此处不额外去重。
|
||||
joined["year"] = joined["_end"].dt.year
|
||||
for c in ("n_income", "n_income_attr_p", "n_cashflow_act", "free_cashflow",
|
||||
"c_pay_dist_dpcp_int_exp"):
|
||||
if c in joined.columns:
|
||||
joined[c] = pd.to_numeric(joined[c], errors="coerce")
|
||||
return joined[["symbol", "year", "n_income", "n_income_attr_p", "n_cashflow_act",
|
||||
"free_cashflow", "c_pay_dist_dpcp_int_exp"]]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 分红
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def dividend_records(
|
||||
self, asof: date, *, years_back: int = 12, implemented_only: bool = True
|
||||
) -> pd.DataFrame:
|
||||
"""等价于 ``Repo.dividend_records`` 的 PIT 三重条件(内存过滤)。
|
||||
|
||||
**P4:若已 ``restrict_to`` 设定范围,只返回范围内的股票。**
|
||||
分红滤网拿到记录后先 ``_group()`` 建成 ``{symbol: [记录]}``,再**只按候选股**
|
||||
查表;为全市场建表的那部分永远不会被读到。逐日筛选时这一步是每天对
|
||||
2 万余行做一次 ``to_dict("records")``,而候选通常只有一两百只 ——
|
||||
收窄范围把它降到百分之几,且不改变任何候选股的判定输入。
|
||||
|
||||
注意:画像路径(``PitProfileService._context`` 里的 ``self._all_dividends``)
|
||||
调用本方法时范围必须是 ``None``;每日选股器用完后会显式复位。
|
||||
"""
|
||||
self.stats_.dividend_slices += 1
|
||||
d = self._dividend
|
||||
if d.empty:
|
||||
return d
|
||||
if self._scope is not None:
|
||||
d = d[d["symbol"].isin(self._scope)]
|
||||
if d.empty:
|
||||
return d
|
||||
since = asof - timedelta(days=int(years_back * 365.25))
|
||||
imp = pd.to_datetime(d["imp_ann_date"]).dt.date
|
||||
ex = pd.to_datetime(d["ex_date"], errors="coerce").dt.date
|
||||
m = (imp <= asof) & ex.notna() & (ex <= asof) & (ex >= since)
|
||||
if implemented_only:
|
||||
m &= d["div_proc"].astype(str) == "实施"
|
||||
out = d[m].sort_values(["symbol", "ex_date"])
|
||||
return out.reset_index(drop=True)
|
||||
|
||||
def dividend_events(
|
||||
self, start: date, end: date, *, implemented_only: bool = True
|
||||
) -> pd.DataFrame:
|
||||
"""等价于 ``Repo.dividend_events``,但直接从常驻分红明细里筛(P6)。
|
||||
|
||||
引擎在 ``_prepare`` 里为「持仓期间的分红入账」取一次区间事件,
|
||||
原先这会再查一次库;而参照数据阶段已经把**全部分红明细**常驻在内存里了。
|
||||
过滤条件与 ``Repo.dividend_events`` 逐条对齐(区间按 ex_date、
|
||||
现金或送转为正、可选只取已实施)。
|
||||
"""
|
||||
cols = ["symbol", "end_date", "imp_ann_date", "cash_div_tax", "cash_div",
|
||||
"stk_div", "stk_bo_rate", "stk_co_rate", "record_date", "ex_date",
|
||||
"pay_date"]
|
||||
if self._ref_end is not None and end > self._ref_end:
|
||||
# 区间超出参照数据的终点:常驻帧里可能缺行(超集只覆盖到 _ref_end)。
|
||||
# 这种情况宁可退回直连查询,也不能返回一个**看起来正常但少了几行**的表
|
||||
# —— 少一行就是少一笔现金分红。
|
||||
return Repo.dividend_events(
|
||||
self, start, end, implemented_only=implemented_only
|
||||
)
|
||||
d = self._dividend
|
||||
if d.empty:
|
||||
return pd.DataFrame(columns=cols)
|
||||
ex = pd.to_datetime(d["ex_date"], errors="coerce")
|
||||
cash = pd.to_numeric(d["cash_div_tax"], errors="coerce")
|
||||
stk = pd.to_numeric(d["stk_div"], errors="coerce")
|
||||
m = (
|
||||
ex.notna()
|
||||
& (ex >= pd.Timestamp(start))
|
||||
& (ex <= pd.Timestamp(end))
|
||||
& ((cash > 0) | (stk > 0))
|
||||
)
|
||||
if implemented_only:
|
||||
m &= d["div_proc"].astype(str) == "实施"
|
||||
out = d[m].sort_values(["ex_date", "symbol"])[cols].reset_index(drop=True)
|
||||
# 可空日期列统一成「object,缺值为 None」—— 与 `Repo.dividend_events`
|
||||
# (直接读 SQL)的形态一致,而不是 NaT。`NaT != None` 这种差别虽然不影响
|
||||
# 当前调用方(引擎只用 cash_div_tax / stk_div),但会让逐值比对失败,
|
||||
# 也会给后续消费者埋一个「判空写法依赖列类型」的坑。
|
||||
for c in ("end_date", "imp_ann_date", "record_date", "ex_date", "pay_date"):
|
||||
if c in out.columns:
|
||||
s = pd.to_datetime(out[c], errors="coerce")
|
||||
out[c] = s.dt.date.where(s.notna(), None).astype(object)
|
||||
return out
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 诊断
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def stats(self) -> dict[str, int]:
|
||||
d = self.stats_.as_dict()
|
||||
d["market_rows"] = int(len(self._market))
|
||||
d["daily_rows"] = int(len(self._daily))
|
||||
d["fin_rows"] = int(sum(len(v) for v in self._fin.values()))
|
||||
d["dividend_rows"] = int(len(self._dividend))
|
||||
return d
|
||||
@@ -41,9 +41,12 @@ FILTER_ORDER: tuple[str, ...] = ("market", "risk", "dividend", "quality")
|
||||
|
||||
|
||||
class UniverseSelector:
|
||||
def __init__(self, config: UniverseConfig) -> None:
|
||||
def __init__(self, config: UniverseConfig, repo: Any | None = None) -> None:
|
||||
self.config = config
|
||||
self.repo = Repo()
|
||||
#: 取数出口。默认直连数据库;``--mode daily`` 会注入
|
||||
#: :class:`~hdiv.universe.pit.PitRepo`(批量预载版,口径相同)。
|
||||
#: 注入点放在这里,是为了让滤网代码与口径**完全不需要改**。
|
||||
self.repo = repo if repo is not None else Repo()
|
||||
self._filters: dict[str, Filter] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -51,14 +54,14 @@ class UniverseSelector:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, path: str | Path) -> UniverseSelector:
|
||||
def from_config(cls, path: str | Path, repo: Any | None = None) -> UniverseSelector:
|
||||
"""从 YAML 加载筛选配置(支持 ``strategy`` 段落里的 override)。"""
|
||||
raw = _read_yaml(path)
|
||||
return cls(UniverseConfig.model_validate(raw))
|
||||
return cls(UniverseConfig.model_validate(raw), repo=repo)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw: dict[str, Any]) -> UniverseSelector:
|
||||
return cls(UniverseConfig.model_validate(raw))
|
||||
def from_dict(cls, raw: dict[str, Any], repo: Any | None = None) -> UniverseSelector:
|
||||
return cls(UniverseConfig.model_validate(raw), repo=repo)
|
||||
|
||||
@classmethod
|
||||
def with_override(
|
||||
@@ -97,6 +100,7 @@ class UniverseSelector:
|
||||
*,
|
||||
persist: bool = True,
|
||||
verbose: bool = True,
|
||||
on_stage: Any | None = None,
|
||||
) -> dict[str, Any]:
|
||||
cfg = load_config("datasource")
|
||||
filters = self._build_filters()
|
||||
@@ -126,6 +130,11 @@ class UniverseSelector:
|
||||
if live.empty:
|
||||
stats[fname] = 0
|
||||
continue
|
||||
# 可选钩子:把「本阶段将要评估的股票」告诉调用方。
|
||||
# 每日选股用它把取数范围收窄到存活者(``PitRepo.restrict_to``),
|
||||
# 这是纯性能开关,不改变任何判定 —— 默认 None 时行为与改造前一致。
|
||||
if on_stage is not None:
|
||||
on_stage(fname, live)
|
||||
outcome = flt.compute(live, self.repo, effective)
|
||||
# 注意:outcome.passed 的索引是 live 的 DataFrame 索引(不是 symbol),
|
||||
# 必须经 live.at[i, "symbol"] 映射,否则会把索引当代码用。
|
||||
|
||||
+231
-8
@@ -20,7 +20,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, timedelta
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
@@ -48,6 +48,10 @@ SERIES_KEYS = ("close", "dv_yield", "pe_ttm", "roe", "pb", "drawdown")
|
||||
#: 只影响画图,不影响买卖点(买卖点单独返回且不降采样)。
|
||||
MAX_POINTS = 3200
|
||||
|
||||
#: 缺省区间往前至少回看的年数:股息率分位是买入判据,
|
||||
#: 成交之前那几年的历史正是「凭什么买」的依据。
|
||||
MIN_LOOKBACK_YEARS = 5
|
||||
|
||||
|
||||
def _v(x: Any) -> Any:
|
||||
if x is None:
|
||||
@@ -278,6 +282,146 @@ def run_stocks(run_id: str) -> list[dict[str, Any]]:
|
||||
return out
|
||||
|
||||
|
||||
def _reason_of(raw: Any) -> dict[str, Any]:
|
||||
"""reason_json → dict;坏数据不该让整个接口挂掉。"""
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
out = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
return out if isinstance(out, dict) else {}
|
||||
|
||||
|
||||
def closed_positions(run_id: str) -> dict[str, Any]:
|
||||
"""已清仓(期末不再持有)的个股清单。
|
||||
|
||||
「已清仓」以**持仓表**为准:在该回测中持有过、但最后一个持仓日已不在其中。
|
||||
只按成交净额判断会漏掉「卖了又买回、期末仍持有」的股票。
|
||||
|
||||
每只票带上「卖出后至今」涨跌:清仓复盘真正要回答的是
|
||||
「这笔卖对了没有」,只看成交明细是答不了的。
|
||||
"""
|
||||
cfg = load_config("datasource")
|
||||
if db.read_sql("SELECT 1 AS x FROM hd_backtest_run WHERE run_id = :r LIMIT 1",
|
||||
{"r": run_id}, cfg=cfg).empty:
|
||||
raise HdivError(f"回测不存在:{run_id}")
|
||||
pos = db.read_sql(
|
||||
"SELECT symbol, COUNT(*) AS hold_days, MIN(trade_date) AS first_hold, "
|
||||
" MAX(trade_date) AS last_hold "
|
||||
"FROM hd_backtest_position WHERE run_id = :r GROUP BY symbol",
|
||||
{"r": run_id}, cfg=cfg,
|
||||
)
|
||||
last_day = _v(db.read_sql(
|
||||
"SELECT MAX(trade_date) AS d FROM hd_backtest_position WHERE run_id = :r",
|
||||
{"r": run_id}, cfg=cfg)["d"].iloc[0])
|
||||
empty: dict[str, Any] = {
|
||||
"items": [],
|
||||
"summary": {"count": 0, "realized_pnl": 0.0, "since_sell_up": 0,
|
||||
"since_sell_down": 0, "asof": None},
|
||||
"asof": last_day,
|
||||
}
|
||||
if last_day is None or pos.empty:
|
||||
return empty
|
||||
held = set(db.read_sql(
|
||||
"SELECT DISTINCT symbol FROM hd_backtest_position "
|
||||
"WHERE run_id = :r AND trade_date = :d",
|
||||
{"r": run_id, "d": last_day}, cfg=cfg)["symbol"])
|
||||
closed = [s for s in pos["symbol"] if s not in held]
|
||||
if not closed:
|
||||
return empty
|
||||
|
||||
# 成交汇总:买入/卖出金额、已实现盈亏、最后一笔卖出的日期与理由
|
||||
tr = db.read_sql(
|
||||
"SELECT symbol, side, execution_date, amount, realized_pnl, reason_json "
|
||||
"FROM hd_backtest_trade WHERE run_id = :r ORDER BY execution_date, trade_id",
|
||||
{"r": run_id}, cfg=cfg,
|
||||
)
|
||||
agg: dict[str, dict[str, Any]] = {}
|
||||
for _, r in tr.iterrows():
|
||||
a = agg.setdefault(r["symbol"], {"buy": 0.0, "sell": 0.0, "pnl": 0.0,
|
||||
"last_sell": None, "reason": None})
|
||||
if r["side"] == "BUY":
|
||||
a["buy"] += _fnum(r["amount"]) or 0.0
|
||||
else:
|
||||
a["sell"] += _fnum(r["amount"]) or 0.0
|
||||
a["pnl"] += _fnum(r["realized_pnl"]) or 0.0
|
||||
a["last_sell"] = _v(r["execution_date"])
|
||||
a["reason"] = _reason_text(_reason_of(r["reason_json"]))
|
||||
|
||||
ph = ", ".join(f":c{i}" for i in range(len(closed)))
|
||||
cparams = {f"c{i}": s for i, s in enumerate(closed)}
|
||||
meta = {r["symbol"]: r for _, r in db.read_sql(
|
||||
f"SELECT symbol, MAX(name) AS name, MAX(industry) AS industry FROM stock "
|
||||
f"WHERE symbol IN ({ph}) GROUP BY symbol", cparams, cfg=cfg).iterrows()}
|
||||
|
||||
# 清仓当日收盘(「卖出后至今」的基准)与最新收盘
|
||||
pairs = [(s, agg[s]["last_sell"]) for s in closed
|
||||
if agg.get(s, {}).get("last_sell")]
|
||||
at_sell: dict[str, float | None] = {}
|
||||
if pairs:
|
||||
cond = ", ".join(f"(:p{i}s, :p{i}d)" for i in range(len(pairs)))
|
||||
pparams: dict[str, Any] = {}
|
||||
for i, (s, d) in enumerate(pairs):
|
||||
pparams[f"p{i}s"], pparams[f"p{i}d"] = s, d
|
||||
for _, r in db.read_sql(
|
||||
f"SELECT symbol, close FROM daily_basic "
|
||||
f"WHERE (symbol, trade_date) IN ({cond})", pparams, cfg=cfg,
|
||||
).iterrows():
|
||||
at_sell[r["symbol"]] = _fnum(r["close"])
|
||||
|
||||
latest: dict[str, tuple[float | None, str | None]] = {}
|
||||
for _, r in db.read_sql(
|
||||
f"SELECT d.symbol, d.trade_date, d.close FROM daily_basic d "
|
||||
f"JOIN (SELECT symbol, MAX(trade_date) AS mx FROM daily_basic "
|
||||
f" WHERE symbol IN ({ph}) GROUP BY symbol) t "
|
||||
f" ON t.symbol = d.symbol AND t.mx = d.trade_date",
|
||||
cparams, cfg=cfg,
|
||||
).iterrows():
|
||||
latest[r["symbol"]] = (_fnum(r["close"]), _v(r["trade_date"]))
|
||||
|
||||
items = []
|
||||
for s in closed:
|
||||
a = agg.get(s, {})
|
||||
row = pos[pos["symbol"] == s].iloc[0]
|
||||
pnl = _fnum(a.get("pnl")) or 0.0
|
||||
buy = _fnum(a.get("buy")) or 0.0
|
||||
c0 = at_sell.get(s)
|
||||
c1, c1_date = latest.get(s, (None, None))
|
||||
since = (c1 / c0 - 1.0) if (c0 and c1 and c0 > 0) else None
|
||||
items.append({
|
||||
"symbol": s,
|
||||
"name": _v(meta[s]["name"]) if s in meta else None,
|
||||
"industry": _v(meta[s]["industry"]) if s in meta else None,
|
||||
"first_hold": _v(row["first_hold"]), "last_hold": _v(row["last_hold"]),
|
||||
"hold_days": int(row["hold_days"]),
|
||||
"last_sell": a.get("last_sell"),
|
||||
"buy_amount": buy or None, "sell_amount": _fnum(a.get("sell")),
|
||||
"realized_pnl": pnl,
|
||||
# 已清仓,所以「已实现盈亏 ÷ 买入金额」就是这笔投资的收益率
|
||||
"return_pct": (pnl / buy) if buy > 0 else None,
|
||||
"close_at_sell": c0, "close_latest": c1, "price_asof": c1_date,
|
||||
"since_sell_pct": since,
|
||||
"sell_reason": a.get("reason"),
|
||||
})
|
||||
items.sort(key=lambda x: (x["last_sell"] or "", x["symbol"]), reverse=True)
|
||||
|
||||
asof = max((x["price_asof"] for x in items if x["price_asof"]), default=None)
|
||||
return {
|
||||
"items": items,
|
||||
"summary": {
|
||||
"count": len(items),
|
||||
"realized_pnl": sum(x["realized_pnl"] or 0.0 for x in items),
|
||||
"since_sell_up": sum(1 for x in items if (x["since_sell_pct"] or 0) > 0),
|
||||
"since_sell_down": sum(1 for x in items
|
||||
if x["since_sell_pct"] is not None
|
||||
and x["since_sell_pct"] <= 0),
|
||||
"asof": asof,
|
||||
},
|
||||
"asof": asof or last_day,
|
||||
}
|
||||
|
||||
|
||||
def _price_panel(symbol: str, start: date, end: date, cfg: Any) -> pd.DataFrame:
|
||||
"""不复权收盘价 + PE/PB(同一张 daily_basic,一次查询)。
|
||||
|
||||
@@ -346,6 +490,46 @@ def _dividend_yield_series(
|
||||
return out.astype(float)
|
||||
|
||||
|
||||
def _to_date(x: Any) -> date | None:
|
||||
"""DB 取出的日期/时间戳 → ``date``;空值或 NaN 返回 None。"""
|
||||
if x is None:
|
||||
return None
|
||||
if isinstance(x, float) and x != x:
|
||||
return None
|
||||
if isinstance(x, date) and not isinstance(x, datetime):
|
||||
return x
|
||||
try:
|
||||
return pd.Timestamp(x).date()
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_date(s: str | None, field: str) -> date | None:
|
||||
"""用户传入的日期参数;格式不对要报可读的 400,而不是 500。"""
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return pd.Timestamp(str(s)).date()
|
||||
except (TypeError, ValueError):
|
||||
raise HdivError(f"{field} 不是合法日期:{s}(应为 YYYY-MM-DD)") from None
|
||||
|
||||
|
||||
def _lookback_years(run_row: pd.DataFrame) -> int:
|
||||
"""买入判据的回看年数 = 股息率滚动分位的窗口长度(config: percentile_reference)。
|
||||
|
||||
至少要 ``MIN_LOOKBACK_YEARS`` 年:判据数据本身就在成交之前,
|
||||
图上不带上它就没法回答「当时凭什么买」。
|
||||
"""
|
||||
years = MIN_LOOKBACK_YEARS
|
||||
try:
|
||||
cfg_json = run_row["backtest_config_json"].iloc[0]
|
||||
pcfg = (json.loads(cfg_json) if cfg_json else {}).get("percentile_reference") or {}
|
||||
years = max(MIN_LOOKBACK_YEARS, int(pcfg.get("lookback_years") or 0))
|
||||
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
|
||||
pass
|
||||
return years
|
||||
|
||||
|
||||
def _downsample(n: int, target: int) -> np.ndarray:
|
||||
"""等间隔取索引,保留首尾。仅用于画图,买卖点不降采样。"""
|
||||
if n <= target:
|
||||
@@ -385,8 +569,14 @@ def stock_detail(
|
||||
"FROM hd_backtest_position WHERE run_id = :r AND symbol = :s",
|
||||
{"r": run_id, "s": symbol}, cfg=cfg,
|
||||
)
|
||||
first_trade = db.read_sql(
|
||||
"SELECT MIN(execution_date) AS a FROM hd_backtest_trade "
|
||||
"WHERE run_id = :r AND symbol = :s",
|
||||
{"r": run_id, "s": symbol}, cfg=cfg,
|
||||
)
|
||||
run = db.read_sql(
|
||||
"SELECT start_date, end_date FROM hd_backtest_run WHERE run_id = :r",
|
||||
"SELECT start_date, end_date, backtest_config_json "
|
||||
"FROM hd_backtest_run WHERE run_id = :r",
|
||||
{"r": run_id}, cfg=cfg,
|
||||
)
|
||||
if run.empty:
|
||||
@@ -394,16 +584,36 @@ def stock_detail(
|
||||
run_a, run_b = run["start_date"].iloc[0], run["end_date"].iloc[0]
|
||||
|
||||
has_hold = not hold.empty and hold["n"].iloc[0]
|
||||
d_a = pd.to_datetime(start).date() if start else (
|
||||
hold["a"].iloc[0] if has_hold else run_a)
|
||||
d_b = pd.to_datetime(end).date() if end else (
|
||||
hold["b"].iloc[0] if has_hold else run_b)
|
||||
|
||||
# 该股行情能覆盖到的范围(daily_basic 是本图唯一价格源)
|
||||
avail = db.read_sql(
|
||||
"SELECT MIN(trade_date) AS a, MAX(trade_date) AS b "
|
||||
"FROM daily_basic WHERE symbol = :s",
|
||||
{"s": symbol}, cfg=cfg,
|
||||
)
|
||||
avail_a = _to_date(avail["a"].iloc[0]) if not avail.empty else None
|
||||
avail_b = _to_date(avail["b"].iloc[0]) if not avail.empty else None
|
||||
|
||||
# 缺省区间:起点 = **首笔成交往前留够判据回看年数**(无成交则从回测起点往前留),
|
||||
# 终点 = 行情最新日期 —— 卖出当天之后曲线就断,等于把「卖飞了没有」这个问题
|
||||
# 从图上抹掉;而只画持仓期又把「当时凭什么买」的判据数据裁掉了。
|
||||
anchor = (_to_date(first_trade["a"].iloc[0]) if not first_trade.empty else None) \
|
||||
or (_to_date(hold["a"].iloc[0]) if has_hold else None) \
|
||||
or _to_date(run_a)
|
||||
def_a = (pd.Timestamp(anchor) - pd.DateOffset(years=_lookback_years(run))).date()
|
||||
if avail_a and def_a < avail_a: # 别超出该股行情,否则输入框会给出选不到的日期
|
||||
def_a = avail_a
|
||||
def_b = avail_b or _to_date(run_b)
|
||||
d_a = _parse_date(start, "start") or def_a
|
||||
d_b = _parse_date(end, "end") or def_b
|
||||
if d_a > d_b:
|
||||
raise HdivError(f"开始日期晚于结束日期:{d_a} > {d_b}")
|
||||
|
||||
panel = _price_panel(symbol, d_a, d_b, cfg)
|
||||
if panel.empty:
|
||||
span = f"{avail_a} ~ {avail_b}" if avail_a else "无"
|
||||
raise HdivError(
|
||||
f"{symbol} 在 {d_a} ~ {d_b} 没有行情数据。"
|
||||
f"该股行情覆盖见 stock_daily/daily_basic。"
|
||||
f"{symbol} 在 {d_a} ~ {d_b} 没有行情数据;该股行情覆盖 {span}。"
|
||||
)
|
||||
|
||||
dates = panel["trade_date"]
|
||||
@@ -463,6 +673,14 @@ def stock_detail(
|
||||
})
|
||||
|
||||
idx = _downsample(len(dates), MAX_POINTS)
|
||||
# 降采样必须保留成交日:前端是按日期把买卖点对到横轴上的,
|
||||
# 漏掉那一天,这笔成交就会从图上凭空消失(还会被误报成「不在所选区间内」)。
|
||||
# 实测:区间放宽到 5 年判据 + 至今之后,13 只降采样股票里有 7 只会丢成交日。
|
||||
keep = {_v(dates.iloc[i]): i for i in range(len(dates))}
|
||||
hits = sorted(keep[t["execution_date"]] for t in trades
|
||||
if t["execution_date"] in keep)
|
||||
if hits:
|
||||
idx = np.unique(np.concatenate([idx, np.asarray(hits, dtype=idx.dtype)]))
|
||||
dates_out = [_v(dates.iloc[i]) for i in idx]
|
||||
series_out = {k: [v[i] for i in idx] for k, v in series_out.items()}
|
||||
|
||||
@@ -476,6 +694,11 @@ def stock_detail(
|
||||
"info": {k: _v(v) for k, v in info.iloc[0].items()},
|
||||
"range": {"start": _v(dates.iloc[0]), "end": _v(dates.iloc[-1]),
|
||||
"requested_start": d_a.isoformat(), "requested_end": d_b.isoformat(),
|
||||
# 供前端日期选择器使用:缺省区间用于「重置」,
|
||||
# available_* 是该股行情边界,用作输入框的 min/max
|
||||
"default_start": def_a.isoformat(), "default_end": def_b.isoformat(),
|
||||
"available_start": avail_a.isoformat() if avail_a else None,
|
||||
"available_end": avail_b.isoformat() if avail_b else None,
|
||||
"points": len(dates), "downsampled": len(idx) < len(dates)},
|
||||
"available_series": list(SERIES_KEYS),
|
||||
"series": series_out,
|
||||
|
||||
@@ -246,12 +246,26 @@ def _run_stock_detail(run_id: str, symbol: str, q: dict[str, list[str]],
|
||||
)
|
||||
|
||||
|
||||
@route("GET", r"/api/backtests/(?P<run_id>[\w-]+)/closed-positions")
|
||||
def _closed_positions(run_id: str, **_: Any) -> dict[str, Any]:
|
||||
"""已清仓(期末不再持有)的个股清单,含清仓后至今涨跌。"""
|
||||
return analysis.closed_positions(run_id)
|
||||
|
||||
|
||||
@route("GET", r"/api/backtests/(?P<run_id>[\w-]+)/signals")
|
||||
def _backtest_signals(run_id: str, q: dict[str, list[str]], **_: Any) -> dict[str, Any]:
|
||||
return {"items": service.list_backtest_signals(
|
||||
run_id, only_skipped=_one(q, "only_skipped") != "0")}
|
||||
|
||||
|
||||
@route("GET", r"/api/backtests/(?P<run_id>[\w-]+)/daily-universe")
|
||||
def _backtest_daily_universe(
|
||||
run_id: str, q: dict[str, list[str]], **_: Any
|
||||
) -> dict[str, Any]:
|
||||
"""每日动态股票池(``--mode daily``):时间线或某日成员明细。"""
|
||||
return service.get_daily_universe(run_id, trade_date=_one(q, "date") or None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 请求辅助
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+88
-1
@@ -150,10 +150,38 @@ def describe_strategy(cfg: dict[str, Any]) -> dict[str, Any]:
|
||||
"status": st.get("status"),
|
||||
"description": st.get("description", "").strip(),
|
||||
"conditions": lines,
|
||||
# 个股画像闸门:触发买入后按**当日可见数据**重算画像再筛一遍。
|
||||
# 只回结构化规则,展示名/单位由前端既有的 LABEL/UNIT 表渲染
|
||||
# (profile.builder.METRIC_META 少 14 个指标,前端那张表反而更全)。
|
||||
"profile_gate": _profile_gate(entry.get("profile_gate")),
|
||||
}
|
||||
except Exception as exc: # 不因说明生成失败而让接口 500
|
||||
return {"id": None, "name": None, "version": None, "status": None,
|
||||
"description": f"(条件说明生成失败:{exc})", "conditions": []}
|
||||
"description": f"(条件说明生成失败:{exc})", "conditions": [],
|
||||
"profile_gate": None}
|
||||
|
||||
|
||||
def _profile_gate(gate: Any) -> dict[str, Any] | None:
|
||||
"""把 entry.profile_gate 规整成前端可直接渲染的结构。"""
|
||||
if not isinstance(gate, dict):
|
||||
return None
|
||||
rules = []
|
||||
for r in gate.get("rules") or []:
|
||||
if not isinstance(r, dict) or not r.get("metric"):
|
||||
continue
|
||||
rules.append({
|
||||
"metric": r.get("metric"),
|
||||
"stat": r.get("stat") or "current_value",
|
||||
"op": r.get("op"),
|
||||
"value": _num(r.get("value")),
|
||||
})
|
||||
return {
|
||||
"enabled": bool(gate.get("enabled")),
|
||||
"window_years": _num(gate.get("window_years")),
|
||||
"on_unverifiable": gate.get("on_unverifiable"),
|
||||
"min_window_coverage": _num(gate.get("min_window_coverage")),
|
||||
"rules": rules,
|
||||
}
|
||||
|
||||
|
||||
def _yi(v: Any) -> str:
|
||||
@@ -182,6 +210,11 @@ def summary() -> dict[str, Any]:
|
||||
WHERE deleted_at IS NULL AND mode = 'single') AS backtests,
|
||||
(SELECT COUNT(*) FROM hd_backtest_run
|
||||
WHERE deleted_at IS NULL AND mode = 'single' AND archived_at IS NULL) AS backtests_active,
|
||||
(SELECT COUNT(*) FROM hd_backtest_run
|
||||
WHERE deleted_at IS NULL AND mode = 'daily') AS daily_backtests,
|
||||
(SELECT COUNT(*) FROM hd_backtest_run
|
||||
WHERE deleted_at IS NULL AND mode = 'daily' AND archived_at IS NULL)
|
||||
AS daily_backtests_active,
|
||||
(SELECT COUNT(*) FROM hd_walkforward_run) AS walkforwards,
|
||||
(SELECT COUNT(*) FROM hd_sensitivity_run) AS sensitivities,
|
||||
(SELECT COUNT(*) FROM hd_profile_run) AS profiles,
|
||||
@@ -885,6 +918,57 @@ def list_backtest_signals(run_id: str, *, only_skipped: bool = True) -> list[dic
|
||||
return out
|
||||
|
||||
|
||||
def get_daily_universe(run_id: str, *, trade_date: str | None = None,
|
||||
limit: int = 5000) -> dict[str, Any]:
|
||||
"""每日动态股票池(``--mode daily`` 的逐日选股留痕)。
|
||||
|
||||
两个形态:
|
||||
- 不给 ``trade_date``:返回**时间线**(每个决策日的成员数),用于看池子如何变化;
|
||||
- 给 ``trade_date``:返回该日的成员明细(含入选时的因子快照)。
|
||||
"""
|
||||
cfg = load_config("datasource")
|
||||
if not db.table_exists("hd_daily_universe", cfg):
|
||||
return {"available": False, "timeline": [], "members": [], "trade_date": None}
|
||||
if trade_date:
|
||||
df = db.read_sql(
|
||||
"SELECT trade_date, symbol, name, industry, dividend_yield, total_mv, "
|
||||
" roe_avg, listed_count, candidate_count, values_json "
|
||||
"FROM hd_daily_universe "
|
||||
"WHERE run_id = :r AND trade_date = :d ORDER BY dividend_yield DESC "
|
||||
"LIMIT :lim",
|
||||
{"r": run_id, "d": trade_date, "lim": int(limit)}, cfg=cfg,
|
||||
)
|
||||
members = []
|
||||
for _, row in df.iterrows():
|
||||
m = _rec(row)
|
||||
m["values"] = _json_field(m.pop("values_json", None)) or {}
|
||||
members.append(m)
|
||||
return {"available": True, "trade_date": trade_date,
|
||||
"members": members, "timeline": []}
|
||||
|
||||
df = db.read_sql(
|
||||
"SELECT trade_date, COUNT(*) AS member_count, "
|
||||
" MAX(candidate_count) AS candidate_count, "
|
||||
" MAX(listed_count) AS listed_count "
|
||||
"FROM hd_daily_universe WHERE run_id = :r "
|
||||
"GROUP BY trade_date ORDER BY trade_date",
|
||||
{"r": run_id}, cfg=cfg,
|
||||
)
|
||||
timeline = [
|
||||
{
|
||||
"trade_date": str(_rec(r).get("trade_date")),
|
||||
"member_count": int(r["member_count"]),
|
||||
"candidate_count": int(r["candidate_count"])
|
||||
if pd.notna(r["candidate_count"]) else None,
|
||||
"listed_count": int(r["listed_count"])
|
||||
if pd.notna(r["listed_count"]) else None,
|
||||
}
|
||||
for _, r in df.iterrows()
|
||||
]
|
||||
return {"available": True, "timeline": timeline, "members": [],
|
||||
"trade_date": timeline[-1]["trade_date"] if timeline else None}
|
||||
|
||||
|
||||
_SKIP_LABELS = {
|
||||
"LIMIT_UP": "开盘涨停,无法买入",
|
||||
"LIMIT_DOWN": "开盘跌停,无法卖出",
|
||||
@@ -898,6 +982,9 @@ _SKIP_LABELS = {
|
||||
# 实时画像闸门剔除(信号类型 REJECT):不是撮合失败,而是「按当日可见
|
||||
# 数据重算画像后判定不值得买」。详情在 reason_json.profile_gate.checks。
|
||||
"PROFILE_GATE": "实时画像未通过,主动放弃买入",
|
||||
# 每日动态股票池(--mode daily):持仓已掉出当日股票池,
|
||||
# 按 pool_exit_action=hold 只停止加仓,不清仓。
|
||||
"OUT_OF_UNIVERSE": "已掉出当日动态股票池,停止加仓(不清仓)",
|
||||
}
|
||||
|
||||
|
||||
|
||||
+38
-1
@@ -168,6 +168,40 @@ def move_reports_to_subdir(*, verbose: bool = True) -> int:
|
||||
return n
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 权限:nginx worker 不是文件属主
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: 站点文件 / 目录的发布权限。
|
||||
#:
|
||||
#: nginx 的 master 以 root 运行、worker 以 nobody 运行(Homebrew 默认),所以
|
||||
#: **worker 不是站点文件的属主**:文件只要不是「所有人可读」就 403。
|
||||
#: 而 ``shutil.copy2`` 会连权限一起复制,用 umask 077 的编辑器/工具存下来的
|
||||
#: 600 文件会一路带进 output/ —— 症状是「HTML 打得开、CSS/JS 403、页面裸奔」,
|
||||
#: 而 nginx 错误日志里只写 ``failed (13: Permission denied)``。
|
||||
#: 这里在发布时统一收敛权限,别让单个文件的 umask 决定线上是否可用。
|
||||
SITE_FILE_MODE = 0o644
|
||||
SITE_DIR_MODE = 0o755
|
||||
|
||||
|
||||
def ensure_readable(root: Path, *, verbose: bool = False) -> int:
|
||||
"""把站点目录树收敛为「目录 755 / 文件 644」,返回被修正的条目数。"""
|
||||
if not root.is_dir():
|
||||
return 0
|
||||
fixed = 0
|
||||
for p in [root, *sorted(root.rglob("*"))]:
|
||||
want = SITE_DIR_MODE if p.is_dir() else SITE_FILE_MODE
|
||||
try:
|
||||
if (p.stat().st_mode & 0o777) != want:
|
||||
p.chmod(want)
|
||||
fixed += 1
|
||||
except OSError: # pragma: no cover - 权限不足/平台不支持
|
||||
pass
|
||||
if fixed and verbose:
|
||||
print(f" 已修正 {fixed} 个发布产物的权限(nginx worker 为 nobody,需要 world-readable)")
|
||||
return fixed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 前端同步
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -206,9 +240,12 @@ def sync_frontend(*, verbose: bool = True) -> dict[str, Any]:
|
||||
shutil.copy2(ec_src, assets_dst / "echarts.min.js")
|
||||
copied.append(str((assets_dst / "echarts.min.js").relative_to(project_root())))
|
||||
|
||||
# 整棵站点树(含 reports/ 与 archive/)统一权限,避免 copy2 把 600 带进来
|
||||
fixed = ensure_readable(out, verbose=verbose)
|
||||
|
||||
if verbose:
|
||||
print(f" 已同步前端 {len(copied)} 个文件到 output/")
|
||||
return {"copied": copied}
|
||||
return {"copied": copied, "perm_fixed": fixed}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+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}"
|
||||
|
||||
@@ -90,7 +90,7 @@ def test_no_undocumented_commands(parser) -> None:
|
||||
("strategy", {"validate", "register", "list", "diff"}),
|
||||
(
|
||||
"sync",
|
||||
{"dividend", "financial", "index", "price", "trading", "backfill"},
|
||||
{"daily", "dividend", "financial", "index", "price", "trading", "backfill"},
|
||||
),
|
||||
("site", {"normalize", "archive", "build", "status"}),
|
||||
],
|
||||
@@ -109,7 +109,10 @@ def test_documented_actions_exist(parser, cmd: str, expected: set[str]) -> None:
|
||||
[
|
||||
("sync", {"--only-missing", "--limit", "--symbols", "--apis",
|
||||
"--interleaved", "--start", "--end", "--no-resume", "--no-weight",
|
||||
"--basic-start", "--basic-end"}),
|
||||
"--basic-start", "--basic-end",
|
||||
# sync daily(手册 §5.2.1)
|
||||
"--dry-run", "--asof", "--lookback-days", "--only",
|
||||
"--no-financial", "--financial-limit", "--json"}),
|
||||
("universe", {"-c", "--config", "--asof", "--no-persist", "--no-html"}),
|
||||
("profile", {"--universe-run", "--symbols", "--asof", "--html-limit"}),
|
||||
("backtest", {"-s", "--strategy", "--mode", "--start", "--end", "--universe-run",
|
||||
@@ -139,6 +142,20 @@ def test_sync_interleaved_is_a_real_flag(parser) -> None:
|
||||
assert args2.interleaved is False
|
||||
|
||||
|
||||
def test_sync_daily_defaults(parser) -> None:
|
||||
"""手册 §5.2.1 承诺的 ``hdiv sync daily`` 必须存在,且默认是「真抓、含财报」。"""
|
||||
args = parser.parse_args(["sync", "daily"])
|
||||
assert args.dry_run is False, "默认必须真抓;--dry-run 是显式开关"
|
||||
assert args.no_financial is False, "默认应包含财报四表(有上限兜底)"
|
||||
assert args.financial_limit == 500
|
||||
assert args.lookback_days == 45
|
||||
assert args.only is None
|
||||
assert args.asof is None
|
||||
# --only 必须真的能解析
|
||||
a2 = parser.parse_args(["sync", "daily", "--only", "price", "dividend"])
|
||||
assert a2.only == ["price", "dividend"]
|
||||
|
||||
|
||||
def test_backtest_universe_run_flag(parser) -> None:
|
||||
"""股票池 ↔ 回测 的关联入口:``--universe-run`` 必须存在且可解析。"""
|
||||
args = parser.parse_args(["backtest", "--universe-run", "abc123"])
|
||||
@@ -241,11 +258,11 @@ def test_future_universe_is_rejected_by_default() -> None:
|
||||
|
||||
|
||||
def test_backtest_mode_choices(parser) -> None:
|
||||
"""手册只承诺 single / walkforward 两种模式。"""
|
||||
"""手册承诺 single / walkforward / daily 三种模式。"""
|
||||
sub = _subparsers(parser)["backtest"]
|
||||
for a in sub._actions:
|
||||
if "--mode" in a.option_strings:
|
||||
assert set(a.choices) == {"single", "walkforward"}
|
||||
assert set(a.choices) == {"single", "walkforward", "daily"}
|
||||
return
|
||||
raise AssertionError("backtest 缺少 --mode 参数")
|
||||
|
||||
@@ -273,7 +290,7 @@ def test_ddl_verify_output_mentions_table_count() -> None:
|
||||
|
||||
src = inspect.getsource(cli.cmd_ddl)
|
||||
assert "len(ddl.ALL_TABLES)" in src, "ddl verify 输出应包含表的数量"
|
||||
assert len(ALL_TABLES) == 30
|
||||
assert len(ALL_TABLES) == 31
|
||||
|
||||
|
||||
def test_help_text_is_chinese(parser) -> None:
|
||||
|
||||
@@ -0,0 +1,985 @@
|
||||
"""每日动态股票池回测(``--mode daily``)测试。
|
||||
|
||||
三条必须被锁定的性质:
|
||||
|
||||
1. **取数口径不分叉** —— ``PitRepo`` 在某个 asof 上返回的行情/每日指标/财务/
|
||||
分红,必须与直连数据库的 ``Repo`` **逐值一致**。它只改了「怎么取」,
|
||||
没有改「怎么算」;这是每日选股能跑得快却仍然可信的前提。
|
||||
与 ``tests/test_profile_pit.py`` 锁定「实时画像 = 批量画像」是同一个思路。
|
||||
|
||||
2. **预剪枝不改变最终入选** —— 市场/风险滤网的候选集预剪枝只剔除「在区间内
|
||||
不可能通过市场滤网」的股票(交易所/板块/上市年限/市值上界)。开与关必须
|
||||
选出**完全相同**的成员,否则它是「近似」而不是「等价」。
|
||||
|
||||
3. **动态池语义** —— 持仓掉出当日股票池时:``pool_exit_action=hold`` 只停止
|
||||
加仓(不清仓、仍按分位卖出),``sell`` 则清仓。买卖决策都会留下画像证据,
|
||||
与它是否在池内无关。
|
||||
|
||||
需要数据库的用例标记为 ``db``(无库时跳过)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from hdiv.core.config import DailyConfig, load_config
|
||||
from hdiv.universe.daily import DailyUniverseScreener
|
||||
from hdiv.backtest.daily import _chunks
|
||||
from hdiv.backtest.engine import build_yield_series
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 非 DB:配置与纯函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDailyConfig:
|
||||
def test_defaults_are_conservative(self) -> None:
|
||||
"""默认值必须让 daily 段「什么都没开」不影响既有模式。"""
|
||||
c = DailyConfig()
|
||||
assert c.universe_refresh_days == 1
|
||||
assert c.signal_frequency_days == 1
|
||||
assert c.pool_exit_action == "hold"
|
||||
assert c.profile_on_trade is True
|
||||
assert c.persist_daily_universe is True
|
||||
assert c.chunk_years == 1
|
||||
|
||||
def test_backtest_yml_has_daily_block(self) -> None:
|
||||
bt = load_config("backtest")
|
||||
assert bt.daily.universe_refresh_days >= 1
|
||||
assert bt.daily.pool_exit_action in {"hold", "sell"}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field", ["universe_refresh_days", "signal_frequency_days", "chunk_years"]
|
||||
)
|
||||
def test_non_positive_rejected(self, field: str) -> None:
|
||||
from hdiv.core.errors import SchemaValidationError
|
||||
|
||||
with pytest.raises((SchemaValidationError, ValueError)):
|
||||
DailyConfig(**{field: 0})
|
||||
|
||||
def test_bad_pool_exit_action_rejected(self) -> None:
|
||||
from hdiv.core.errors import SchemaValidationError
|
||||
|
||||
with pytest.raises((SchemaValidationError, ValueError)):
|
||||
DailyConfig(pool_exit_action="liquidate")
|
||||
|
||||
|
||||
class TestChunks:
|
||||
def test_single_year(self) -> None:
|
||||
days = [date(2024, 1, 2), date(2024, 3, 1), date(2024, 12, 31)]
|
||||
out = _chunks(days, 1)
|
||||
assert out == [days]
|
||||
|
||||
def test_year_boundary_splits(self) -> None:
|
||||
days = [date(2023, 12, 29), date(2024, 1, 2), date(2024, 12, 31),
|
||||
date(2025, 1, 2)]
|
||||
out = _chunks(days, 1)
|
||||
assert [len(c) for c in out] == [1, 2, 1]
|
||||
assert out[0][0] == date(2023, 12, 29)
|
||||
assert out[2][0] == date(2025, 1, 2)
|
||||
|
||||
def test_multi_year_chunk(self) -> None:
|
||||
days = [date(2020, 6, 1), date(2021, 6, 1), date(2022, 6, 1),
|
||||
date(2023, 6, 1), date(2024, 6, 1)]
|
||||
out = _chunks(days, 2)
|
||||
assert [len(c) for c in out] == [2, 2, 1]
|
||||
|
||||
def test_empty(self) -> None:
|
||||
assert _chunks([], 1) == []
|
||||
|
||||
|
||||
class TestEngineDefaultsAreOff:
|
||||
"""新增的引擎参数默认值必须让 single / walkforward 行为逐字不变。"""
|
||||
|
||||
def test_new_params_default_off(self) -> None:
|
||||
from hdiv.backtest.engine import BacktestEngine
|
||||
from hdiv.strategy.registry import StrategyRegistry
|
||||
|
||||
s = StrategyRegistry().load("config/strategy/high_dividend_v1.yml")
|
||||
eng = BacktestEngine(s)
|
||||
assert eng.universe_by_refresh is None
|
||||
assert eng.universe_refresh_days is None
|
||||
assert eng.signal_frequency_days is None
|
||||
assert eng.pool_exit_action == "hold"
|
||||
assert eng.profile_on_trade is False
|
||||
|
||||
def test_monthly_signal_path_preserved(self) -> None:
|
||||
"""按月判定信号的原始语义必须仍在源码里(daily 只是新增分支)。"""
|
||||
import inspect
|
||||
|
||||
from hdiv.backtest import engine as mod
|
||||
|
||||
src = inspect.getsource(mod.BacktestEngine._simulate)
|
||||
assert "freq_days" in src, "daily 的按交易日分支丢失"
|
||||
assert "_months_between(" in src, "原有按月判定分支被删除"
|
||||
assert "day_index % max(1, int(freq_days))" in src
|
||||
|
||||
def test_pool_exit_branches_present(self) -> None:
|
||||
import inspect
|
||||
|
||||
from hdiv.backtest import engine as mod
|
||||
|
||||
src = inspect.getsource(mod.BacktestEngine._evaluate)
|
||||
assert "in_universe" in src
|
||||
assert "pool_exit_action" in src
|
||||
assert "OUT_OF_UNIVERSE" in src
|
||||
assert "_trade_signal" in src, "买卖信号的画像留痕出口丢失"
|
||||
|
||||
|
||||
class TestMetricFormatting:
|
||||
"""短区间下指标不可计算时的格式化(**修掉一个既有的崩溃**)。"""
|
||||
|
||||
def test_pct_and_num_tolerate_none(self) -> None:
|
||||
from hdiv.backtest.engine import _num, _pct
|
||||
|
||||
assert _pct(None) == "—"
|
||||
assert _num(None) == "—"
|
||||
assert _pct(0.1234) == "12.34%"
|
||||
assert _num(1.5) == "1.50"
|
||||
assert _pct(float("nan")) == "—"
|
||||
|
||||
def test_no_raw_metric_format_in_hot_prints(self) -> None:
|
||||
"""回测的进度/汇总打印不得直接对可能为 None 的指标做 :.2% 格式化。
|
||||
|
||||
实测:15 个交易日的回测里 ``sharpe`` 为 None,直接 ``:.2f`` 会抛
|
||||
``TypeError`` 并以完整 traceback 结束 —— 把「指标不可计算」这个正常状态
|
||||
说成了程序缺陷。engine 与 cli 的打印必须走 ``_pct`` / ``_num``。
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from hdiv import cli
|
||||
from hdiv.backtest import engine as eng
|
||||
|
||||
for fn in (eng.BacktestEngine.run, cli.cmd_backtest):
|
||||
src = inspect.getsource(fn)
|
||||
for bad in ("['sharpe']:.2f", "['cagr']:.2%",
|
||||
"['max_drawdown']:.2%", "['total_return']:.2%"):
|
||||
assert bad not in src, f"{fn.__qualname__} 仍在直接格式化 {bad}"
|
||||
|
||||
|
||||
@pytest.mark.db
|
||||
def test_pit_profile_caches_are_bounded() -> None:
|
||||
"""长区间每日回测**不能**把每个时点的面板都留在内存里(否则必然 OOM)。
|
||||
|
||||
每个 ``_AsOfContext`` 持有该 asof 可见的分红超集(约 4 MB)。1600 个决策日
|
||||
不设上限 ≈ 6 GB。这里用跨越一年的多个 asof 证明缓存是有界的,
|
||||
同时 ``distinct_asof`` 仍如实汇报**累计**涉及的时点数。
|
||||
"""
|
||||
from hdiv.profile.pit import _MAX_ASOF_CONTEXTS, PitProfileService
|
||||
from hdiv.universe.pit import PitRepo
|
||||
|
||||
try:
|
||||
rows = PitRepo().stock_master()
|
||||
syms = rows["symbol"].astype(str).head(3).tolist()
|
||||
except Exception as exc: # pragma: no cover
|
||||
pytest.skip(f"数据库不可用:{exc}")
|
||||
if not syms:
|
||||
pytest.skip("数据库无股票")
|
||||
svc = PitProfileService(window_years=5)
|
||||
svc.prepare(syms, date(2019, 1, 1), date(2024, 12, 31))
|
||||
svc.configure({"roe_avg"})
|
||||
days = [date(2024, m, 15) for m in range(1, 13)]
|
||||
for d in days:
|
||||
for s in syms:
|
||||
svc.snapshot(s, d)
|
||||
assert len(svc._ctx) <= _MAX_ASOF_CONTEXTS, "时点面板缓存无上限 —— 长回测会 OOM"
|
||||
assert len(svc._snapshots) <= len(syms) * 2, "画像快照缓存无上限"
|
||||
assert svc.stats()["distinct_asof"] == len(days), (
|
||||
"distinct_asof 必须是累计值,不能用当前缓存条数冒充"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 前端数据源:决策时点实时画像
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.db
|
||||
def test_stock_detail_exposes_decision_time_profile() -> None:
|
||||
"""成交个股的**决策时点实时画像**必须能从接口取到。
|
||||
|
||||
这是前端个股页「决策时点实时画像」卡片的数据源
|
||||
(``trades[].reason.profile``)。这条链路断了,页面那张卡片会静默变空 ——
|
||||
而「当时凭什么买」正是 daily 模式最该留下的证据。
|
||||
"""
|
||||
from hdiv.data import db
|
||||
from hdiv.web import analysis
|
||||
|
||||
cfg = load_config("datasource")
|
||||
try:
|
||||
run = db.read_sql(
|
||||
"SELECT run_id FROM hd_backtest_run WHERE mode = 'daily' "
|
||||
"ORDER BY created_at DESC LIMIT 1", cfg=cfg,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover
|
||||
pytest.skip(f"数据库不可用:{exc}")
|
||||
if run.empty:
|
||||
pytest.skip("库里还没有 mode=daily 的回测记录")
|
||||
run_id = str(run["run_id"].iloc[0])
|
||||
|
||||
tr = db.read_sql(
|
||||
"SELECT symbol FROM hd_backtest_trade WHERE run_id = :r "
|
||||
"AND JSON_EXTRACT(reason_json, '$.profile') IS NOT NULL LIMIT 1",
|
||||
{"r": run_id}, cfg=cfg,
|
||||
)
|
||||
if tr.empty:
|
||||
pytest.skip(f"{run_id} 的成交里没有画像留痕(可能关闭了 profile_on_trade)")
|
||||
symbol = str(tr["symbol"].iloc[0])
|
||||
|
||||
d = analysis.stock_detail(run_id, symbol, series=["close"])
|
||||
with_profile = [t for t in d["trades"] if (t.get("reason") or {}).get("profile")]
|
||||
assert with_profile, "接口未返回决策时点画像(前端卡片会变空)"
|
||||
prof = with_profile[0]["reason"]["profile"]
|
||||
# 快照必须带「当时的值」与「当时的窗口」,否则无法复核
|
||||
assert isinstance(prof.get("values"), dict) and prof["values"], prof.keys()
|
||||
assert "window_years" in prof and "asof" in prof
|
||||
# 至少含闸门用到的一项判据,证明与筛选/闸门是同一份画像口径
|
||||
assert {"dv_yield", "roe_avg", "dividend_continuity_years",
|
||||
"payout_ratio", "fcf_dividend_cover"} & set(prof["values"])
|
||||
|
||||
# 回测详情页的「成交个股的实时画像」表用的是**成交列表**接口,
|
||||
# 它也必须带 reason.profile(否则那张表会空)。
|
||||
from hdiv.web import service
|
||||
|
||||
tl = service.list_backtest_trades(run_id, size=200)
|
||||
assert any((it.get("reason") or {}).get("profile") for it in tl["items"]), (
|
||||
"list_backtest_trades 未返回画像 —— 回测详情页的画像表会空"
|
||||
)
|
||||
|
||||
|
||||
def test_cli_points_at_the_frontend() -> None:
|
||||
"""一条命令跑完必须告诉用户「去前端哪里看」,而不是让用户再跑第二条命令。
|
||||
|
||||
回归:曾把「看结果」写成需要手工传 run_id 去执行一个导出脚本 ——
|
||||
而 run_id 本来就在这条命令自己的输出里,前端也直接列出来。
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from hdiv import cli
|
||||
|
||||
src = inspect.getsource(cli)
|
||||
assert "_print_frontend_hint" in src
|
||||
assert "回测记录" in src, "提示里要给出前端位置"
|
||||
# daily 与 single 两条路径都必须给出该提示
|
||||
assert src.count('_print_frontend_hint(res["run_id"])') >= 2
|
||||
|
||||
|
||||
class TestSpeedLevers:
|
||||
"""速度旋钮:`--every-n-days` / `--signal-every-n-days`。
|
||||
|
||||
两个频率**只改变「多久看一次」,不改变判定规则**。这里锁定三件事:
|
||||
参数真的传到了执行器、缓存键把重建频率算进去、耗时模型随频率正确下降。
|
||||
"""
|
||||
|
||||
def test_cli_flags_exist_and_parse(self) -> None:
|
||||
from hdiv.cli import build_parser
|
||||
|
||||
p = build_parser()
|
||||
a = p.parse_args(["backtest"])
|
||||
assert a.every_n_days is None and a.signal_every_n_days is None, (
|
||||
"默认必须是 None(= 用 config 里的值,通常为 1)"
|
||||
)
|
||||
a = p.parse_args(["backtest", "--mode", "daily", "--start", "2020-01-05",
|
||||
"--every-n-days", "5", "--signal-every-n-days", "10"])
|
||||
assert a.every_n_days == 5 and a.signal_every_n_days == 10
|
||||
|
||||
def test_runner_accepts_overrides(self) -> None:
|
||||
from hdiv.backtest.daily import DailyRunner
|
||||
|
||||
import inspect
|
||||
|
||||
sig = inspect.signature(DailyRunner.run)
|
||||
assert "every_n_days" in sig.parameters
|
||||
assert "signal_every_n_days" in sig.parameters
|
||||
assert sig.parameters["every_n_days"].default is None
|
||||
assert sig.parameters["signal_every_n_days"].default is None
|
||||
|
||||
def test_cache_key_depends_on_pool_cadence(self) -> None:
|
||||
"""1 日 / 5 日 筛出的池子是不同的输入,不能共用缓存。"""
|
||||
from hdiv.backtest.daily import DailyRunner
|
||||
from hdiv.strategy.registry import StrategyRegistry
|
||||
|
||||
reg = StrategyRegistry()
|
||||
s = reg.load("config/strategy/high_dividend_v1.yml")
|
||||
|
||||
class _Stub:
|
||||
"""只借 `_pools_cache_key`,不构造真 runner(那会连库)。"""
|
||||
|
||||
strategy = s
|
||||
registry = reg
|
||||
_pools_cache_key = DailyRunner._pools_cache_key
|
||||
|
||||
stub = _Stub()
|
||||
k1 = stub._pools_cache_key(date(2020, 1, 5), date(2026, 9, 30), 1)
|
||||
k5 = stub._pools_cache_key(date(2020, 1, 5), date(2026, 9, 30), 5)
|
||||
assert k1 != k5, "不同重建频率必须落在不同缓存文件"
|
||||
assert k1 == stub._pools_cache_key(date(2020, 1, 5), date(2026, 9, 30), 1), (
|
||||
"同样的输入必须得到同样的键(缓存要可复用)"
|
||||
)
|
||||
|
||||
def test_time_model_shrinks_with_coarser_cadence(self) -> None:
|
||||
"""耗时模型必须随频率下降(否则预计时长会骗人)。"""
|
||||
import hdiv.backtest.daily as D
|
||||
|
||||
def sim_seconds(days: int, signal_step: int) -> float:
|
||||
return (days * D._SEC_PER_SIM_DAY_BASE
|
||||
+ days / signal_step * D._SEC_PER_SIM_SIGNAL)
|
||||
|
||||
assert sim_seconds(1635, 1) > sim_seconds(1635, 5) > sim_seconds(1635, 21)
|
||||
# 逐日口径必须与实测同量级(43 日实测 131 秒)
|
||||
assert 100 < sim_seconds(43, 1) < 170, sim_seconds(43, 1)
|
||||
# 每 5 日口径也必须与实测同量级(约 28 秒)
|
||||
assert 15 < sim_seconds(43, 5) < 60, sim_seconds(43, 5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 性能优化的等价性(P1/P2/P4/P6)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ttm_dps_series_prefix_equals_full() -> None:
|
||||
"""P2 的前提:``ttm_dps_series`` 每一天的取值只依赖该日期与事件。
|
||||
|
||||
若这条不成立,「整段算一次再切片」就会与「每次按前缀重算」不同 ——
|
||||
P2(消除 O(交易日数²))正是建立在这条性质上。
|
||||
"""
|
||||
from hdiv.factor.dividend_yield import ttm_dps_series
|
||||
|
||||
idx = pd.bdate_range("2020-01-01", periods=800)
|
||||
# 事件间隔刻意覆盖三种情况:年内多次、略小于一年(重叠虚高)、略大于一年(断档虚低)
|
||||
ex = [idx[50], idx[300], idx[520], idx[640], idx[770]]
|
||||
ev = pd.DataFrame({
|
||||
"ex_date": ex,
|
||||
"imp_ann_date": [e - pd.Timedelta(days=12) for e in ex],
|
||||
"cash_div_tax": [0.5, 0.3, 0.6, 0.55, 0.7],
|
||||
})
|
||||
full = ttm_dps_series(idx, ev, ttm_days=365, grace_days=45, smooth_spikes=True)
|
||||
for i in (60, 310, 530, 650, 700, 799):
|
||||
pref = ttm_dps_series(idx[: i + 1], ev, ttm_days=365, grace_days=45,
|
||||
smooth_spikes=True)
|
||||
assert abs(float(full[i]) - float(pref[-1])) < 1e-12, (
|
||||
f"第 {i} 天:整段 {full[i]} != 前缀末值 {pref[-1]} —— P2 的前提不成立"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.db
|
||||
def test_pit_dividend_scope_is_exact_subset() -> None:
|
||||
"""P4:``restrict_to`` 只能把 ``dividend_records`` 收窄成精确子集。"""
|
||||
asof = date(2024, 6, 3)
|
||||
pit = _load_pit(date(2024, 1, 1), date(2024, 6, 28), ref_end=date(2024, 12, 31))
|
||||
full = pit.dividend_records(asof, years_back=8)
|
||||
assert not full.empty
|
||||
syms = sorted(full["symbol"].astype(str).unique())[:5]
|
||||
pit.restrict_to(syms)
|
||||
try:
|
||||
scoped = pit.dividend_records(asof, years_back=8)
|
||||
finally:
|
||||
pit.restrict_to(None)
|
||||
cols = list(full.columns)
|
||||
want = full[full["symbol"].isin(set(syms))].reset_index(drop=True)
|
||||
pd.testing.assert_frame_equal(
|
||||
scoped.sort_values(cols, ignore_index=True),
|
||||
want.sort_values(cols, ignore_index=True),
|
||||
check_dtype=False, rtol=1e-9,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.db
|
||||
def test_pit_dividend_events_matches_direct_repo(plain_repo) -> None:
|
||||
"""P6:内存版 ``dividend_events`` 必须与直连逐值一致(含 NULL 公告日边界)。"""
|
||||
|
||||
|
||||
ref_end = date(2026, 9, 30)
|
||||
pit = _load_pit(date(2025, 1, 1), ref_end, ref_end=ref_end)
|
||||
for s, e in ((date(2024, 1, 1), date(2024, 12, 31)),
|
||||
(date(2020, 1, 1), ref_end)):
|
||||
a = pit.dividend_events(s, e)
|
||||
b = plain_repo.dividend_events(s, e)
|
||||
cols = list(a.columns)
|
||||
pd.testing.assert_frame_equal(
|
||||
a.sort_values(cols, ignore_index=True),
|
||||
b.sort_values(cols, ignore_index=True),
|
||||
check_dtype=False, rtol=1e-9,
|
||||
obj=f"dividend_events {s}..{e}",
|
||||
)
|
||||
# 超出参照终点的区间必须退回直连,而不是返回少行的表
|
||||
narrow = _load_pit(date(2025, 1, 1), date(2024, 12, 31),
|
||||
ref_end=date(2024, 6, 3))
|
||||
a = narrow.dividend_events(date(2024, 1, 1), date(2024, 12, 31))
|
||||
b = plain_repo.dividend_events(date(2024, 1, 1), date(2024, 12, 31))
|
||||
assert len(a) == len(b), "超出参照终点的区间被静默截断了"
|
||||
|
||||
|
||||
@pytest.mark.db
|
||||
def test_profile_prepare_accepts_external_price(plain_repo) -> None:
|
||||
"""P6:引擎把已取好的价格交给画像复用,结果必须逐值不变。"""
|
||||
from hdiv.profile.pit import PitProfileService
|
||||
|
||||
syms = ["600036.SH", "000651.SZ", "601398.SH"]
|
||||
a, b = date(2015, 1, 1), date(2026, 9, 30)
|
||||
pit = _load_pit(a, b, ref_end=b)
|
||||
s1 = PitProfileService(window_years=5, repo=pit)
|
||||
s1.prepare(syms, a, b)
|
||||
price = plain_repo.price_history(syms, a, b, adjust="none")
|
||||
s2 = PitProfileService(window_years=5, repo=pit)
|
||||
s2.prepare(syms, a, b, price=price)
|
||||
s1.configure({"dv_yield", "pe_ttm", "pb", "roe_avg"})
|
||||
s2.configure({"dv_yield", "pe_ttm", "pb", "roe_avg"})
|
||||
for d in (date(2018, 5, 18), date(2024, 6, 3)):
|
||||
for y in syms:
|
||||
x, z = s1.snapshot(y, d), s2.snapshot(y, d)
|
||||
assert (x is None) == (z is None)
|
||||
if x is not None and z is not None:
|
||||
assert x.values == z.values and x.percentiles == z.percentiles
|
||||
|
||||
|
||||
@pytest.mark.db
|
||||
def test_dividend_scope_does_not_change_selection(pit_2024) -> None:
|
||||
"""P4 的端到端保证:收窄分红取数不改变每日入选成员。"""
|
||||
from hdiv.universe.pit import PitRepo
|
||||
from hdiv.universe.selector import UniverseSelector
|
||||
|
||||
pit = pit_2024
|
||||
cfg = load_config("universe")
|
||||
sel = UniverseSelector(cfg, repo=pit)
|
||||
days = [d for d in pit.trading_days(date(2024, 1, 1), date(2024, 6, 28))
|
||||
if d in (date(2024, 1, 2), date(2024, 3, 1), date(2024, 6, 3))]
|
||||
assert days
|
||||
|
||||
orig = PitRepo.dividend_records
|
||||
|
||||
def _unscoped(self, asof, **kw): # noqa: ANN001
|
||||
saved = self._scope
|
||||
self._scope = None
|
||||
try:
|
||||
return orig(self, asof, **kw)
|
||||
finally:
|
||||
self._scope = saved
|
||||
|
||||
# 关掉收窄(= 优化前的行为)
|
||||
PitRepo.dividend_records = _unscoped
|
||||
try:
|
||||
before = {d: set(sel.run(asof=d, persist=False, verbose=False)["selected"]["symbol"])
|
||||
for d in days}
|
||||
finally:
|
||||
PitRepo.dividend_records = orig
|
||||
# 打开收窄:走生产路径(on_stage 钩子会按阶段收窄)
|
||||
pit.set_candidate_scope(None)
|
||||
screener = DailyUniverseScreener(cfg, pit, verbose=False)
|
||||
after = {d: set(screener.screen_day(d).symbols) for d in days}
|
||||
for d in days:
|
||||
assert before[d] == after[d], (
|
||||
f"{d} 收窄分红取数改变了入选:多 {sorted(after[d] - before[d])} "
|
||||
f"少 {sorted(before[d] - after[d])}"
|
||||
)
|
||||
|
||||
|
||||
class TestPoolExitSemantics:
|
||||
"""动态池的语义核心:掉出当日池子的持仓怎么办。
|
||||
|
||||
用合成行情直接调 ``_evaluate``,不经数据库 —— 这样 hold / sell 两条分支
|
||||
都被真正执行到,而不是只靠「源码里有这个词」。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _setup(pool_exit_action: str):
|
||||
import pandas as pd
|
||||
|
||||
from hdiv.backtest.engine import BacktestEngine, Position
|
||||
from hdiv.core.config import load_config as _lc
|
||||
from hdiv.strategy.registry import StrategyRegistry
|
||||
|
||||
reg = StrategyRegistry()
|
||||
s = reg.load("config/strategy/high_dividend_v1.yml").model_copy(deep=True)
|
||||
# 闸门关闭:本用例考的是**池子语义**,不是画像闸门。
|
||||
s.entry.profile_gate.enabled = False
|
||||
bt = _lc("backtest")
|
||||
eng = BacktestEngine(
|
||||
s, backtest=bt, pool_exit_action=pool_exit_action,
|
||||
signal_frequency_days=1,
|
||||
)
|
||||
|
||||
# 400 个交易日的合成行情:收盘价从 40 线性跌到 10 → 股息率一路上行,
|
||||
# 当前值必然是窗口内的最高分位(≈100%),足以触发最高买入档。
|
||||
idx = pd.bdate_range(end="2024-06-28", periods=400)
|
||||
close = np.linspace(40.0, 10.0, len(idx))
|
||||
px = pd.DataFrame({"open": close, "close": close}, index=idx)
|
||||
day = idx[-1].date()
|
||||
ex = idx[200]
|
||||
events = {"X.SH": pd.DataFrame([{
|
||||
"ex_date": ex, "imp_ann_date": ex - pd.Timedelta(days=10),
|
||||
"cash_div_tax": 1.0,
|
||||
}])}
|
||||
px_by_sym = {"X.SH": px}
|
||||
ctx = {"px_by_sym": px_by_sym, "events": events,
|
||||
"yield_by_sym": build_yield_series(px_by_sym, events)}
|
||||
pos = {"X.SH": Position(symbol="X.SH", quantity=1000.0, avg_cost=20.0,
|
||||
first_buy_date=day - timedelta(days=100),
|
||||
last_buy_date=day - timedelta(days=100),
|
||||
cost_basis=20000.0)}
|
||||
return eng, day, ctx, pos
|
||||
|
||||
def test_high_percentile_triggers_buy_when_not_held(self) -> None:
|
||||
eng, day, ctx, _ = self._setup("hold")
|
||||
sigs = eng._evaluate(day, 1e6, {}, {"X.SH"}, ctx)
|
||||
assert [x.kind for x in sigs] == ["BUY"], sigs
|
||||
assert sigs[0].reason["in_universe"] is True
|
||||
|
||||
def test_in_pool_holding_can_add(self) -> None:
|
||||
eng, day, ctx, pos = self._setup("hold")
|
||||
sigs = eng._evaluate(day, 1e6, pos, {"X.SH"}, ctx)
|
||||
assert [x.kind for x in sigs] == ["ADD"], sigs
|
||||
|
||||
def test_out_of_pool_holding_blocks_add_but_not_liquidate(self) -> None:
|
||||
"""``hold``:掉出池子 → 停止加仓,但**不清仓**(留 HOLD 记录可追溯)。"""
|
||||
eng, day, ctx, pos = self._setup("hold")
|
||||
sigs = eng._evaluate(day, 1e6, pos, set(), ctx)
|
||||
assert [x.kind for x in sigs] == ["HOLD"], sigs
|
||||
assert sigs[0].reason["skip_reason"] == "OUT_OF_UNIVERSE"
|
||||
assert sigs[0].reason["executed"] is False
|
||||
assert sigs[0].reason["in_universe"] is False
|
||||
# 不进入待成交队列 ⇒ 不会被 _execute 清算
|
||||
assert sigs[0].kind not in {"BUY", "ADD", "SELL", "TRIM"}
|
||||
assert pos["X.SH"].quantity == 1000.0
|
||||
|
||||
def test_out_of_pool_holding_liquidates_when_configured(self) -> None:
|
||||
"""``sell``:掉出池子即清仓(可选项,不是默认)。"""
|
||||
eng, day, ctx, pos = self._setup("sell")
|
||||
sigs = eng._evaluate(day, 1e6, pos, set(), ctx)
|
||||
assert [x.kind for x in sigs] == ["SELL"], sigs
|
||||
assert sigs[0].target_weight == 0.0
|
||||
|
||||
def test_pool_exit_sell_branch_is_exercised_by_both_actions(self) -> None:
|
||||
"""两种配置必须给出**不同**的动作,否则 pool_exit_action 是死配置。"""
|
||||
a, day, ctx, pos_a = self._setup("hold")
|
||||
b, _, ctx_b, pos_b = self._setup("sell")
|
||||
ka = [x.kind for x in a._evaluate(day, 1e6, pos_a, set(), ctx)]
|
||||
kb = [x.kind for x in b._evaluate(day, 1e6, pos_b, set(), ctx_b)]
|
||||
assert ka != kb, f"pool_exit_action 未生效:{ka} == {kb}"
|
||||
|
||||
|
||||
class TestMemberRows:
|
||||
def _screen(self, symbols: list[str]) -> object:
|
||||
from hdiv.universe.daily import ScreenDay
|
||||
|
||||
m = pd.DataFrame({
|
||||
"symbol": symbols,
|
||||
"name": ["A", "B", "C"][: len(symbols)],
|
||||
"industry": ["银行"] * len(symbols),
|
||||
"dividend_yield": [0.06, 0.05, 0.04][: len(symbols)],
|
||||
"total_mv": [1e11, 2e11, 3e11][: len(symbols)],
|
||||
"roe_avg": [0.12, 0.11, 0.10][: len(symbols)],
|
||||
})
|
||||
return ScreenDay(
|
||||
trade_date=date(2024, 3, 1),
|
||||
candidate_count=5000,
|
||||
member_count=len(symbols),
|
||||
symbols=symbols,
|
||||
members=m,
|
||||
stats={"market": 10},
|
||||
)
|
||||
|
||||
def test_rows_shape(self) -> None:
|
||||
from datetime import datetime
|
||||
|
||||
from hdiv.universe.daily import DailyUniverseScreener
|
||||
|
||||
rows = DailyUniverseScreener.member_rows(
|
||||
"run1", [self._screen(["600036.SH", "601398.SH"])],
|
||||
created_at=datetime(2024, 3, 1, 15, 0, 0),
|
||||
)
|
||||
assert len(rows) == 2
|
||||
r = rows[0]
|
||||
assert r["run_id"] == "run1"
|
||||
assert r["trade_date"] == date(2024, 3, 1)
|
||||
assert r["symbol"] == "600036.SH"
|
||||
assert r["candidate_count"] == 5000
|
||||
assert r["dividend_yield"] == pytest.approx(0.06)
|
||||
# values_json 必须是可解析的 JSON(供「为什么是这只」复核)
|
||||
import json
|
||||
|
||||
v = json.loads(r["values_json"])
|
||||
assert v["dividend_yield"] == pytest.approx(0.06)
|
||||
assert v["roe_avg"] == pytest.approx(0.12)
|
||||
|
||||
def test_empty_members_yield_no_rows(self) -> None:
|
||||
from datetime import datetime
|
||||
|
||||
from hdiv.universe.daily import DailyUniverseScreener, ScreenDay
|
||||
|
||||
s = ScreenDay(date(2024, 3, 1), 5000, 0, [], pd.DataFrame())
|
||||
assert DailyUniverseScreener.member_rows(
|
||||
"r", [s], created_at=datetime(2024, 3, 1)
|
||||
) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB:PitRepo 与 Repo 逐值一致
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_pit(start: date, end: date, ref_end: date | None = None):
|
||||
"""载入一个 PitRepo(库不可用时 skip)。
|
||||
|
||||
**载入很贵**(参照数据约 17 秒 + 区块约 25 秒),因此这些用例共享
|
||||
module 级 fixture,而不是各自新建一个 —— 否则整个文件的耗时是分钟级的倍数。
|
||||
"""
|
||||
from hdiv.universe.pit import PitRepo
|
||||
|
||||
try:
|
||||
pit = PitRepo()
|
||||
pit.load_reference(end=ref_end or end)
|
||||
pit.load_range(start, end)
|
||||
except Exception as exc: # pragma: no cover - 环境相关
|
||||
pytest.skip(f"数据库不可用:{exc}")
|
||||
return pit
|
||||
|
||||
|
||||
#: 等价性 / 剪枝用例共用的区间(覆盖 2024 上半年,含年报季)
|
||||
_RANGE_START, _RANGE_END = date(2024, 1, 1), date(2024, 6, 28)
|
||||
_ASOF = date(2024, 6, 3)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pit_2024():
|
||||
"""共享的 PitRepo(2024 上半年)。用例只读,唯一例外是剪枝用例会改候选范围。"""
|
||||
from hdiv.universe.pit import PitRepo
|
||||
|
||||
try:
|
||||
pit = PitRepo()
|
||||
pit.load_reference(end=date(2024, 12, 31))
|
||||
pit.load_range(_RANGE_START, _RANGE_END)
|
||||
except Exception as exc: # pragma: no cover
|
||||
pytest.skip(f"数据库不可用:{exc}")
|
||||
yield pit
|
||||
pit.release_range()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def plain_repo():
|
||||
from hdiv.data.repo import Repo
|
||||
|
||||
try:
|
||||
repo = Repo()
|
||||
repo.trading_day(_ASOF) # 探活
|
||||
except Exception as exc: # pragma: no cover
|
||||
pytest.skip(f"数据库不可用:{exc}")
|
||||
return repo
|
||||
|
||||
|
||||
def _assert_same_multiset(name: str, a: pd.DataFrame, b: pd.DataFrame) -> None:
|
||||
"""按**全列排序**后逐值比较(行序不同不代表内容不同)。
|
||||
|
||||
用全部列排序而不是挑几列:财务表里同一 (symbol, 财年) 可能有多条重述公告,
|
||||
只按业务键排序会让等值行两两错配,产生假报警。
|
||||
"""
|
||||
assert list(a.columns) == list(b.columns), (
|
||||
f"{name} 列集合不一致:{list(a.columns)} vs {list(b.columns)}"
|
||||
)
|
||||
if a.empty and b.empty:
|
||||
return
|
||||
cols = list(a.columns)
|
||||
x = a.sort_values(cols, ignore_index=True)
|
||||
y = b.sort_values(cols, ignore_index=True)
|
||||
assert x.shape == y.shape, f"{name} 行数不一致:{x.shape} vs {y.shape}"
|
||||
pd.testing.assert_frame_equal(
|
||||
x, y, check_dtype=False, rtol=1e-9, atol=1e-12,
|
||||
obj=f"{name} 与直连口径不一致",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.db
|
||||
def test_pit_repo_matches_direct_repo(pit_2024, plain_repo) -> None:
|
||||
"""PitRepo 的每一个被覆盖的方法都必须与直连 Repo 逐值一致。"""
|
||||
asof = _ASOF
|
||||
pit, plain = pit_2024, plain_repo
|
||||
|
||||
_assert_same_multiset(
|
||||
"market_panel(lookback=5)",
|
||||
pit.market_panel(asof, lookback_days=5),
|
||||
plain.market_panel(asof, lookback_days=5),
|
||||
)
|
||||
_assert_same_multiset(
|
||||
"market_panel(lookback=0)",
|
||||
pit.market_panel(asof),
|
||||
plain.market_panel(asof),
|
||||
)
|
||||
_assert_same_multiset(
|
||||
"avg_amount(20)",
|
||||
pit.avg_amount(asof, window=20),
|
||||
plain.avg_amount(asof, window=20),
|
||||
)
|
||||
syms = ["600036.SH", "000651.SZ"]
|
||||
_assert_same_multiset(
|
||||
"avg_amount(20, symbols)",
|
||||
pit.avg_amount(asof, window=20, symbols=syms),
|
||||
plain.avg_amount(asof, window=20, symbols=syms),
|
||||
)
|
||||
_assert_same_multiset(
|
||||
"financial_panel",
|
||||
pit.financial_panel(asof),
|
||||
plain.financial_panel(asof),
|
||||
)
|
||||
_assert_same_multiset(
|
||||
"annual_financial_history(5)",
|
||||
pit.annual_financial_history(asof, years=5),
|
||||
plain.annual_financial_history(asof, years=5),
|
||||
)
|
||||
_assert_same_multiset(
|
||||
"annual_financial_history(11)",
|
||||
pit.annual_financial_history(asof, years=11),
|
||||
plain.annual_financial_history(asof, years=11),
|
||||
)
|
||||
_assert_same_multiset(
|
||||
"annual_financial_averages(5)",
|
||||
pit.annual_financial_averages(asof, years=5),
|
||||
plain.annual_financial_averages(asof, years=5),
|
||||
)
|
||||
_assert_same_multiset(
|
||||
"annual_financials(10)",
|
||||
pit.annual_financials(asof, years=10),
|
||||
plain.annual_financials(asof, years=10),
|
||||
)
|
||||
_assert_same_multiset(
|
||||
"dividend_records(8)",
|
||||
pit.dividend_records(asof, years_back=8),
|
||||
plain.dividend_records(asof, years_back=8),
|
||||
)
|
||||
_assert_same_multiset(
|
||||
"dividend_records(all)",
|
||||
pit.dividend_records(asof, years_back=8, implemented_only=False),
|
||||
plain.dividend_records(asof, years_back=8, implemented_only=False),
|
||||
)
|
||||
assert pit.trading_day(asof) == plain.trading_day(asof)
|
||||
assert pit.prev_trading_day(asof) == plain.prev_trading_day(asof)
|
||||
assert len(pit.trading_days(date(2024, 1, 1), asof)) == len(
|
||||
plain.trading_days(date(2024, 1, 1), asof)
|
||||
)
|
||||
assert pit.suspended_on(asof) == plain.suspended_on(asof)
|
||||
assert pit.st_symbols(asof) == plain.st_symbols(asof)
|
||||
|
||||
|
||||
@pytest.mark.db
|
||||
def test_pit_repo_symbols_subset_is_exact(pit_2024, plain_repo) -> None:
|
||||
"""``symbols`` 过滤只是全市场结果取子集:两者必须一致(含缓存命中路径)。"""
|
||||
asof, pit, plain = _ASOF, pit_2024, plain_repo
|
||||
syms = ["600036.SH", "601398.SH", "000651.SZ"]
|
||||
|
||||
full = pit.financial_panel(asof)
|
||||
sub = pit.financial_panel(asof, symbols=syms)
|
||||
_assert_same_multiset(
|
||||
"financial_panel(symbols) 子集",
|
||||
sub, full[full["symbol"].isin(syms)].reset_index(drop=True),
|
||||
)
|
||||
_assert_same_multiset(
|
||||
"financial_panel(symbols) vs 直连",
|
||||
sub, plain.financial_panel(asof, symbols=syms),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.db
|
||||
def test_pit_repo_caches_are_exact() -> None:
|
||||
"""可见性缓存必须**精确**:命中与不命中给出同一结果。
|
||||
|
||||
缓存键是「已公告财报条数」。这里把缓存清空强制重算一次,与命中结果比对 ——
|
||||
若不相等,说明键不是充分统计量(那就会静默用错的面板做决策)。
|
||||
"""
|
||||
asof = date(2024, 4, 25) # 年报季,可见集合天天变
|
||||
pit = _load_pit(date(2024, 3, 1), date(2024, 6, 28), ref_end=date(2024, 12, 31))
|
||||
hit = pit.financial_panel(asof)
|
||||
pit._memo.clear()
|
||||
cold = pit.financial_panel(asof)
|
||||
_assert_same_multiset("financial_panel 缓存命中 vs 冷算", hit, cold)
|
||||
|
||||
h1 = pit.annual_financial_history(asof, years=11)
|
||||
pit._memo.clear()
|
||||
h2 = pit.annual_financial_history(asof, years=11)
|
||||
_assert_same_multiset("annual_financial_history 命中 vs 冷算", h1, h2)
|
||||
|
||||
|
||||
@pytest.mark.db
|
||||
def test_pit_repo_out_of_range_raises() -> None:
|
||||
"""区间外必须**报错**,不能返回空表 —— 空表会被当成「当天没有股票」。"""
|
||||
from hdiv.core.errors import HdivError
|
||||
|
||||
pit = _load_pit(date(2024, 5, 1), date(2024, 6, 28))
|
||||
with pytest.raises(HdivError):
|
||||
pit.market_panel(date(2019, 1, 2), lookback_days=5)
|
||||
with pytest.raises(HdivError):
|
||||
pit.avg_amount(date(2019, 1, 2), window=20) # 全市场查询必须报错
|
||||
|
||||
|
||||
@pytest.mark.db
|
||||
def test_pit_repo_avg_amount_falls_back_for_narrow_query(plain_repo) -> None:
|
||||
"""区块释放后,**带 symbol 的**均额查询仍可回答(画像路径依赖它)。"""
|
||||
pit = _load_pit(date(2024, 5, 1), date(2024, 6, 28))
|
||||
asof = date(2024, 6, 3)
|
||||
pit.release_range()
|
||||
got = pit.avg_amount(asof, window=20, symbols=["600036.SH"])
|
||||
want = plain_repo.avg_amount(asof, window=20, symbols=["600036.SH"])
|
||||
_assert_same_multiset("avg_amount 窄查询回退", got, want)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB:预剪枝等价性
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.db
|
||||
def test_prune_does_not_change_selection(pit_2024) -> None:
|
||||
"""开/关预剪枝必须选出完全相同的成员(否则它是近似,不是等价)。"""
|
||||
from hdiv.universe.selector import UniverseSelector
|
||||
|
||||
pit = pit_2024
|
||||
start, end = _RANGE_START, _RANGE_END
|
||||
cfg = load_config("universe")
|
||||
sel = UniverseSelector(cfg, repo=pit)
|
||||
|
||||
# 几天样本:月初、季报期、年报季、月末
|
||||
wanted = {date(2024, 1, 2), date(2024, 3, 1), date(2024, 4, 25),
|
||||
date(2024, 6, 3), date(2024, 6, 28)}
|
||||
days = [d for d in pit.trading_days(start, end) if d in wanted]
|
||||
assert days, "样本交易日为空"
|
||||
|
||||
# 先不剪枝
|
||||
pit.set_candidate_scope(None)
|
||||
base = {d: set(sel.run(asof=d, persist=False, verbose=False)["selected"]["symbol"])
|
||||
for d in days}
|
||||
|
||||
# 再剪枝
|
||||
DailyUniverseScreener(cfg, pit, verbose=False).build_prune_set(start, end)
|
||||
pruned = {d: set(sel.run(asof=d, persist=False, verbose=False)["selected"]["symbol"])
|
||||
for d in days}
|
||||
pit.set_candidate_scope(None) # 复位,避免影响其它用例
|
||||
|
||||
for d in days:
|
||||
assert base[d] == pruned[d], (
|
||||
f"{d} 预剪枝改变了最终入选:"
|
||||
f"多出 {sorted(pruned[d] - base[d])},丢失 {sorted(base[d] - pruned[d])}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.db
|
||||
def test_prune_set_keeps_every_actual_member(pit_2024) -> None:
|
||||
"""预剪枝的保留集合必须**包含**每天实际选出的成员(上界论证的实证)。"""
|
||||
from hdiv.universe.selector import UniverseSelector
|
||||
|
||||
pit = pit_2024
|
||||
start, end = _RANGE_START, _RANGE_END
|
||||
cfg = load_config("universe")
|
||||
allowed = DailyUniverseScreener(cfg, pit, verbose=False).build_prune_set(start, end)
|
||||
pit.set_candidate_scope(None) # 不剪枝地真筛一次
|
||||
sel = UniverseSelector(cfg, repo=pit)
|
||||
for d in [date(2024, 3, 1), date(2024, 6, 3)]:
|
||||
got = set(sel.run(asof=d, persist=False, verbose=False)["selected"]["symbol"])
|
||||
assert got <= allowed, f"{d} 有成员被预剪枝误剔:{sorted(got - allowed)}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB:PIT 纪律 —— 股票池不随「回测终点」变化
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.db
|
||||
def test_daily_pool_is_point_in_time() -> None:
|
||||
"""某一天的股票池只依赖该日及之前的数据,与回测终点无关。
|
||||
|
||||
这是「无未来函数」在每日选股上的直接检验:把参照数据的终点推后一年,
|
||||
同一天的选股结果必须**逐只相同**。
|
||||
"""
|
||||
from hdiv.universe.selector import UniverseSelector
|
||||
|
||||
asof = date(2023, 6, 1)
|
||||
# 只载入一个月,控制成本(这条检验比的是「两个终点是否给出同一答案」)
|
||||
a = _load_pit(date(2023, 5, 1), asof, ref_end=asof)
|
||||
b = _load_pit(date(2023, 5, 1), asof, ref_end=asof + timedelta(days=365))
|
||||
cfg = load_config("universe")
|
||||
ra = UniverseSelector(cfg, repo=a).run(asof=asof, persist=False, verbose=False)
|
||||
rb = UniverseSelector(cfg, repo=b).run(asof=asof, persist=False, verbose=False)
|
||||
assert set(ra["selected"]["symbol"]) == set(rb["selected"]["symbol"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB:端到端(短区间)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 回测 run 的从属表(顺序 = 删除顺序:先子后父)
|
||||
_BACKTEST_CHILD_TABLES = (
|
||||
"hd_daily_universe",
|
||||
"hd_backtest_equity",
|
||||
"hd_backtest_metric",
|
||||
"hd_backtest_position",
|
||||
"hd_backtest_signal",
|
||||
"hd_backtest_trade",
|
||||
)
|
||||
|
||||
|
||||
def _purge_daily_run(run_id: str) -> None:
|
||||
"""物理删除本次端到端测试自己写入的 run 及其从属行。
|
||||
|
||||
**测试不得在分析库里留垃圾**:每跑一次都会多出一条同名回测记录(run_id 指纹
|
||||
含 ``datetime.now()``,见 ``hdiv.backtest.engine``),跑几十次后前端「回测
|
||||
记录」就被测试产物淹掉,而它对外看起来和真实回测没有区别。
|
||||
|
||||
项目的 ``StatementGuard`` 有意禁止 DELETE(只增不删),所以这里自建一个
|
||||
**不装守卫**的连接,并严格按 ``run_id`` 精确回收刚刚写入的行。
|
||||
"""
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
from hdiv.data import db
|
||||
|
||||
cfg = load_config("datasource")
|
||||
db.load_dotenv_once()
|
||||
engine = create_engine(db.build_url(cfg), pool_pre_ping=True, future=True)
|
||||
try:
|
||||
with engine.begin() as conn:
|
||||
for table in (*_BACKTEST_CHILD_TABLES, "hd_backtest_run"):
|
||||
conn.execute(text(f"DELETE FROM {table} WHERE run_id = :r"),
|
||||
{"r": run_id})
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.db
|
||||
def test_daily_run_persists_members_and_run() -> None:
|
||||
"""daily 端到端:落库 run(mode=daily)与每日选股成员(跑完自清理)。"""
|
||||
from hdiv.backtest.daily import DailyRunner
|
||||
from hdiv.data import db
|
||||
|
||||
try:
|
||||
runner = DailyRunner.from_strategy("config/strategy/high_dividend_v1.yml")
|
||||
except Exception as exc: # pragma: no cover
|
||||
pytest.skip(f"数据库不可用:{exc}")
|
||||
start, end = date(2024, 3, 1), date(2024, 3, 15)
|
||||
try:
|
||||
res = runner.run(start=start, end=end, persist=True, verbose=False)
|
||||
except Exception as exc: # pragma: no cover
|
||||
pytest.skip(f"数据不足,跳过端到端:{exc}")
|
||||
|
||||
run_id = res["run_id"]
|
||||
try:
|
||||
assert res["mode"] == "daily"
|
||||
cfg = load_config("datasource")
|
||||
assert db.table_exists("hd_daily_universe", cfg)
|
||||
got = db.read_sql(
|
||||
"SELECT COUNT(*) AS n, COUNT(DISTINCT trade_date) AS d "
|
||||
"FROM hd_daily_universe WHERE run_id = :r",
|
||||
{"r": run_id}, cfg=cfg,
|
||||
)
|
||||
assert int(got["n"].iloc[0]) > 0, "未落库任何每日选股成员"
|
||||
assert int(got["d"].iloc[0]) >= 5, "落库的决策时点太少"
|
||||
run = db.read_sql(
|
||||
"SELECT mode, universe_run_id FROM hd_backtest_run WHERE run_id = :r",
|
||||
{"r": run_id}, cfg=cfg,
|
||||
)
|
||||
assert len(run) == 1
|
||||
assert run["mode"].iloc[0] == "daily"
|
||||
# daily 是动态池:不得关联任何冻结股票池
|
||||
assert pd.isna(run["universe_run_id"].iloc[0]) or run["universe_run_id"].iloc[0] is None
|
||||
finally:
|
||||
# 无论断言成功还是失败,都不能把测试产物留在分析库里
|
||||
_purge_daily_run(run_id)
|
||||
@@ -25,8 +25,8 @@ _PLAIN_TESTS_NEED_DB = pytest.mark.db
|
||||
|
||||
|
||||
def test_table_count() -> None:
|
||||
assert len(ALL_TABLES) == 30
|
||||
assert len(set(TABLE_NAMES)) == 30
|
||||
assert len(ALL_TABLES) == 31
|
||||
assert len(set(TABLE_NAMES)) == 31
|
||||
|
||||
|
||||
def test_all_tables_use_own_prefix() -> None:
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
"""每日增量同步(``hdiv sync daily``)测试。
|
||||
|
||||
要锁定的三条性质:
|
||||
|
||||
1. **缺口口径与断点续传一致** —— 判定「这天已同步」用的是 ``price.fetched_days``
|
||||
的按年份规模阈值,而不是「当天有没有行」。否则只填了几百只的半成品日会被
|
||||
当成已完成,形成难以察觉的数据空洞(历史上真的踩过这个坑)。
|
||||
|
||||
2. **只抓缺口,不重拉** —— 计划里出现的交易日必须**恰好**是缺失的那些;
|
||||
已完整的日、已最新的指数、已有数据的股票都不能出现在待抓清单里。
|
||||
|
||||
3. **财报水位按披露截止日推算** —— 年报/一季报 4-30、半年报 8-31、三季报 10-31。
|
||||
水位算错会让财报季的队列要么永远排不空、要么漏掉整季新披露。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import plistlib
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from hdiv.core.paths import project_root
|
||||
from hdiv.data.sync import daily as daily_sync
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 财报报告期水位
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFinancialWatermark:
|
||||
@pytest.mark.parametrize(
|
||||
"asof,expected",
|
||||
[
|
||||
# 三季报 10-31 截止 → 11-01 起水位是当年三季报
|
||||
(date(2026, 11, 1), date(2026, 9, 30)),
|
||||
(date(2026, 12, 31), date(2026, 9, 30)),
|
||||
# 半年报 8-31 截止 → 9-01 起水位是当年半年报
|
||||
(date(2026, 9, 1), date(2026, 6, 30)),
|
||||
(date(2026, 10, 31), date(2026, 6, 30)),
|
||||
# 一季报 4-30 截止 → 5-01 起水位是当年一季报
|
||||
(date(2026, 5, 1), date(2026, 3, 31)),
|
||||
(date(2026, 8, 31), date(2026, 3, 31)),
|
||||
# 1~4 月是上一年年报季
|
||||
(date(2026, 4, 30), date(2025, 12, 31)),
|
||||
(date(2026, 1, 1), date(2025, 12, 31)),
|
||||
],
|
||||
)
|
||||
def test_watermark(self, asof: date, expected: date) -> None:
|
||||
assert daily_sync.financial_watermark(asof) == expected
|
||||
|
||||
def test_watermark_boundaries_are_monotonic(self) -> None:
|
||||
"""水位只能随时间前进,不能回退 —— 否则已补的股票会反复进队列。"""
|
||||
days = [date(2025, m, d) for m in range(1, 13) for d in (1, 15, 28)]
|
||||
days += [date(2026, m, d) for m in range(1, 13) for d in (1, 15, 28)]
|
||||
wm = [daily_sync.financial_watermark(d) for d in sorted(days)]
|
||||
assert wm == sorted(wm)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 目标选择
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResolveOnly:
|
||||
def test_default_is_all_groups(self) -> None:
|
||||
assert daily_sync._resolve_only(None) == set(daily_sync.GROUPS)
|
||||
assert daily_sync._resolve_only([]) == set(daily_sync.GROUPS)
|
||||
|
||||
def test_group_names(self) -> None:
|
||||
assert daily_sync._resolve_only(["price"]) == {"price"}
|
||||
assert daily_sync._resolve_only(["price", "dividend"]) == {"price", "dividend"}
|
||||
|
||||
def test_single_target_maps_to_its_group(self) -> None:
|
||||
assert daily_sync._resolve_only(["daily_basic"]) == {"price"}
|
||||
assert daily_sync._resolve_only(["suspend"]) == {"trading"}
|
||||
assert daily_sync._resolve_only(["limit"]) == {"trading"}
|
||||
assert daily_sync._resolve_only(["index_weight"]) == {"index"}
|
||||
|
||||
def test_unknown_target_is_rejected(self) -> None:
|
||||
with pytest.raises(ValueError, match="未知的同步目标"):
|
||||
daily_sync._resolve_only(["nope"])
|
||||
|
||||
def test_blank_entries_are_ignored(self) -> None:
|
||||
assert daily_sync._resolve_only(["", " ", "index"]) == {"index"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 缺口判定(隔离数据库)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMissingDays:
|
||||
def test_only_incomplete_days_are_returned(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""4 个交易日里只有 2 天完整 → 待抓恰好是另外 2 天。"""
|
||||
days = [date(2026, 9, 1), date(2026, 9, 2), date(2026, 9, 3), date(2026, 9, 4)]
|
||||
monkeypatch.setattr(daily_sync, "_trading_days", lambda s, e, cfg: days)
|
||||
monkeypatch.setattr(
|
||||
daily_sync.price_sync, "fetched_days", lambda *a, **k: {days[0], days[2]}
|
||||
)
|
||||
out = daily_sync.missing_days("stock_daily", days[0], days[-1], cfg=None)
|
||||
assert out == [days[1], days[3]]
|
||||
|
||||
def test_min_symbols_is_forwarded(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""停牌表必须按「有行即算」传 min_symbols=1,否则每天都判为未完成。"""
|
||||
seen: dict = {}
|
||||
|
||||
def fake_fetched(table, start, end, cfg, *, min_symbols=None): # noqa: ANN001
|
||||
seen["min_symbols"] = min_symbols
|
||||
return set()
|
||||
|
||||
monkeypatch.setattr(daily_sync, "_trading_days", lambda s, e, cfg: [date(2026, 9, 1)])
|
||||
monkeypatch.setattr(daily_sync.price_sync, "fetched_days", fake_fetched)
|
||||
daily_sync.missing_days("hd_suspend", date(2026, 9, 1), date(2026, 9, 1), cfg=None, min_symbols=1)
|
||||
assert seen["min_symbols"] == 1
|
||||
|
||||
def test_no_trading_days_means_no_gap(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(daily_sync, "_trading_days", lambda s, e, cfg: [])
|
||||
assert daily_sync.missing_days("stock_daily", date(2026, 10, 1), date(2026, 10, 5), cfg=None) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 分红按日期补
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDividendGaps:
|
||||
def test_future_ex_date_does_not_push_window_forward(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""已公告但尚未除权的记录会把 MAX(ex_date) 推到未来。
|
||||
|
||||
若直接拿它当锚点,窗口会落在未来、一个交易日都查不到 ——
|
||||
新公告的分红就永远补不进来。锚点必须被夹到 asof。
|
||||
"""
|
||||
import pandas as pd
|
||||
|
||||
asof = date(2026, 10, 5)
|
||||
calls: list[tuple] = []
|
||||
|
||||
def fake_read_sql(sql, params=None, *, cfg=None): # noqa: ANN001
|
||||
if "MAX(ann_date)" in sql:
|
||||
return pd.DataFrame({"m": [date(2026, 10, 23)]}) # 未来除权日
|
||||
calls.append((params["s"], params["e"]))
|
||||
return pd.DataFrame({"d": []})
|
||||
|
||||
monkeypatch.setattr(daily_sync.db, "read_sql", fake_read_sql)
|
||||
monkeypatch.setattr(
|
||||
daily_sync,
|
||||
"_trading_days",
|
||||
lambda s, e, cfg: [date(2026, 9, 28), date(2026, 9, 29), date(2026, 9, 30)],
|
||||
)
|
||||
days, anchor = daily_sync.dividend_gaps(date(2026, 8, 21), asof, asof, cfg=None)
|
||||
assert anchor == asof
|
||||
assert days == [date(2026, 9, 28), date(2026, 9, 29), date(2026, 9, 30)]
|
||||
start, _ = calls[0]
|
||||
assert start == date(2026, 9, 28), "窗口起点应是 asof 往前 DIVIDEND_OVERLAP_DAYS 天"
|
||||
|
||||
def test_days_already_queried_are_not_repeated(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import pandas as pd
|
||||
|
||||
asof = date(2026, 10, 5)
|
||||
seen_day = date(2026, 9, 30)
|
||||
|
||||
def fake_read_sql(sql, params=None, *, cfg=None): # noqa: ANN001
|
||||
if "MAX(ann_date)" in sql:
|
||||
return pd.DataFrame({"m": [seen_day]})
|
||||
return pd.DataFrame({"d": [seen_day]})
|
||||
|
||||
monkeypatch.setattr(daily_sync.db, "read_sql", fake_read_sql)
|
||||
monkeypatch.setattr(
|
||||
daily_sync, "_trading_days", lambda s, e, cfg: [seen_day, date(2026, 10, 1)]
|
||||
)
|
||||
days, _ = daily_sync.dividend_gaps(date(2026, 8, 21), asof, asof, cfg=None)
|
||||
assert days == [date(2026, 10, 1)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 指数行情 / 成分权重窗口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIndexGaps:
|
||||
def test_per_index_gap_starts_after_its_last_day(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
import pandas as pd
|
||||
|
||||
days = [date(2026, 9, 29), date(2026, 9, 30)]
|
||||
monkeypatch.setattr(daily_sync, "_trading_days", lambda s, e, cfg: days)
|
||||
monkeypatch.setattr(
|
||||
daily_sync.index_sync,
|
||||
"default_indices",
|
||||
lambda cfg: [{"code": "000300.SH", "name": "沪深300"}, {"code": "399006.SZ"}],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daily_sync.db,
|
||||
"read_sql",
|
||||
lambda *a, **k: pd.DataFrame(
|
||||
{"c": ["000300.SH"], "m": [date(2026, 9, 29)]}
|
||||
),
|
||||
)
|
||||
out = daily_sync.index_daily_gaps(date(2026, 9, 1), date(2026, 9, 30), cfg=None)
|
||||
assert out == {"000300.SH": [date(2026, 9, 30)], "399006.SZ": days}
|
||||
|
||||
def test_up_to_date_table_has_no_window(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import pandas as pd
|
||||
|
||||
monkeypatch.setattr(
|
||||
daily_sync, "_trading_days", lambda s, e, cfg: [date(2026, 9, 29), date(2026, 9, 30)]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
daily_sync.db, "read_sql", lambda *a, **k: pd.DataFrame({"m": [date(2026, 9, 30)]})
|
||||
)
|
||||
assert daily_sync._index_weight_window(date(2026, 8, 21), date(2026, 9, 30), None) is None
|
||||
|
||||
def test_window_end_is_clamped_to_last_trading_day(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""右端点收到最近交易日:否则长假里每天都会重查一段空区间。"""
|
||||
import pandas as pd
|
||||
|
||||
monkeypatch.setattr(
|
||||
daily_sync, "_trading_days", lambda s, e, cfg: [date(2026, 9, 29), date(2026, 9, 30)]
|
||||
)
|
||||
monkeypatch.setattr(daily_sync.db, "read_sql", lambda *a, **k: pd.DataFrame({"m": [None]}))
|
||||
got = daily_sync._index_weight_window(date(2026, 8, 21), date(2026, 10, 5), None)
|
||||
assert got == (date(2026, 8, 21), date(2026, 9, 30))
|
||||
|
||||
def test_holiday_only_window_is_skipped(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""整段区间都是假期 → 没有可补的权重日,一次调用都不该发。"""
|
||||
monkeypatch.setattr(daily_sync, "_trading_days", lambda s, e, cfg: [])
|
||||
assert daily_sync._index_weight_window(date(2026, 10, 1), date(2026, 10, 5), None) is None
|
||||
|
||||
def test_incremental_window_starts_after_last_weight(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import pandas as pd
|
||||
|
||||
monkeypatch.setattr(daily_sync, "_trading_days", lambda s, e, cfg: [date(2026, 9, 30)])
|
||||
monkeypatch.setattr(
|
||||
daily_sync.db, "read_sql", lambda *a, **k: pd.DataFrame({"m": [date(2026, 8, 31)]})
|
||||
)
|
||||
got = daily_sync._index_weight_window(date(2026, 8, 21), date(2026, 10, 5), None)
|
||||
assert got == (date(2026, 9, 1), date(2026, 9, 30))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 计划对象与摘要
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPlanObject:
|
||||
def test_empty_plan_detects_nothing_to_do(self) -> None:
|
||||
p = daily_sync.DailyPlan(asof=date(2026, 10, 5), window_start=date(2026, 8, 21))
|
||||
assert p.empty
|
||||
assert p.total_days == 0
|
||||
|
||||
def test_any_gap_makes_plan_non_empty(self) -> None:
|
||||
p = daily_sync.DailyPlan(
|
||||
asof=date(2026, 10, 5),
|
||||
window_start=date(2026, 8, 21),
|
||||
day_gaps={"daily": [date(2026, 9, 30)]},
|
||||
)
|
||||
assert not p.empty
|
||||
assert p.total_days == 1
|
||||
|
||||
def test_plan_does_not_share_default_dicts(self) -> None:
|
||||
"""两个计划的默认容器必须互相独立(dataclass 默认值的经典坑)。"""
|
||||
a = daily_sync.DailyPlan(asof=date(2026, 10, 5), window_start=date(2026, 8, 21))
|
||||
b = daily_sync.DailyPlan(asof=date(2026, 10, 5), window_start=date(2026, 8, 21))
|
||||
a.day_gaps["daily"] = [date(2026, 9, 30)]
|
||||
a.notes.append("x")
|
||||
assert b.day_gaps == {}
|
||||
assert b.notes == []
|
||||
|
||||
|
||||
class TestFormatSummary:
|
||||
def test_success(self) -> None:
|
||||
assert "成功" in daily_sync.format_summary({"targets": {}, "errors": [], "ok": True})
|
||||
|
||||
def test_failure_lists_reasons(self) -> None:
|
||||
s = daily_sync.format_summary({"errors": ["daily: SyncError: 限频"], "ok": False})
|
||||
assert "失败 1 项" in s
|
||||
assert "限频" in s
|
||||
|
||||
def test_dry_run_reports_plan(self) -> None:
|
||||
s = daily_sync.format_summary(
|
||||
{
|
||||
"dry_run": True,
|
||||
"planned": {"day_gaps": {"daily": 3}, "index_days": {"000300.SH": 2},
|
||||
"dividend_days": 1},
|
||||
}
|
||||
)
|
||||
assert "计划" in s
|
||||
assert "5" in s
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 部署契约:每天 17:00
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestScheduleContract:
|
||||
"""「每天下午 5 点定时抓取」是本次的硬需求,必须有可执行的证据。
|
||||
|
||||
定时任务最容易在交付后悄悄失效:plist 写成 KeepAlive 会把一次性任务变成
|
||||
常驻进程、小时写错会让它在收盘前跑到空数据。这两点用测试钉死。
|
||||
"""
|
||||
|
||||
def test_plist_triggers_at_17_00(self) -> None:
|
||||
path = project_root() / "deploy" / "com.hddiv.sync.plist.example"
|
||||
assert path.is_file(), "缺少 launchd 模板"
|
||||
data = plistlib.loads(path.read_bytes())
|
||||
assert data["Label"] == "com.hddiv.sync"
|
||||
assert data["StartCalendarInterval"] == {"Hour": 17, "Minute": 0}, (
|
||||
"触发时间必须是 17:00(收盘 15:00 后,当日数据已出)"
|
||||
)
|
||||
assert data["KeepAlive"] is False, "定时任务不能 KeepAlive —— 否则会被无限拉起"
|
||||
assert data["RunAtLoad"] is False, "RunAtLoad 会让每次登录都多抓一次"
|
||||
|
||||
def test_runner_script_is_wired(self) -> None:
|
||||
root = project_root()
|
||||
runner = root / "deploy" / "daily-sync.sh"
|
||||
assert runner.is_file(), "缺少执行包装脚本"
|
||||
assert os.access(runner, os.X_OK), "daily-sync.sh 必须可执行"
|
||||
text = runner.read_text(encoding="utf-8")
|
||||
assert "sync daily" in text, "包装脚本必须调用 hdiv sync daily"
|
||||
assert "LOCK_DIR" in text, "包装脚本必须有单实例锁(避免重叠运行互相抢限频额度)"
|
||||
installer = root / "deploy" / "install-sync-schedule.sh"
|
||||
assert installer.is_file(), "缺少安装脚本"
|
||||
itext = installer.read_text(encoding="utf-8")
|
||||
assert "com.hddiv.sync.plist.example" in itext
|
||||
assert "StartCalendarInterval" in (
|
||||
root / "deploy" / "com.hddiv.sync.plist.example"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 真实数据库:计划与执行的一致性
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.db
|
||||
def test_plan_matches_database_state() -> None:
|
||||
"""计划里的缺口必须与「库里实际缺的日子」逐日一致。
|
||||
|
||||
这是本模块唯一不能靠 mock 保证的性质:``fetched_days`` 的阈值口径、
|
||||
``trading_calendar`` 的交易日、各表的日期列含义都来自真实库。
|
||||
"""
|
||||
from hdiv.core.config import load_config
|
||||
from hdiv.data import db
|
||||
from hdiv.data.sync import price as price_sync
|
||||
|
||||
try:
|
||||
db.load_dotenv_once()
|
||||
cfg = load_config("datasource")
|
||||
db.row_count("stock_daily", cfg)
|
||||
except Exception as exc: # pragma: no cover
|
||||
pytest.skip(f"数据库不可用:{exc}")
|
||||
|
||||
p = daily_sync.plan(cfg, asof=date(2026, 9, 30), lookback_days=45, include_financial=False)
|
||||
# 窗口内每一天:要么在待抓清单里,要么在「已完整」集合里,二者互斥且完备
|
||||
window_days = price_sync.open_days(date(2026, 8, 16), date(2026, 9, 30), cfg)
|
||||
done = price_sync.fetched_days("stock_daily", date(2026, 8, 16), date(2026, 9, 30), cfg)
|
||||
planned = set(p.day_gaps["daily"])
|
||||
for d in window_days:
|
||||
assert (d in planned) != (d in done), f"{d} 的缺口判定与 fetched_days 不一致"
|
||||
assert planned == {d for d in window_days if d not in done}
|
||||
|
||||
# 计划不包含窗口之外的日期(增量只补近期空洞)
|
||||
assert all(d >= p.window_start for days in p.day_gaps.values() for d in days)
|
||||
|
||||
|
||||
@pytest.mark.db
|
||||
def test_financial_queue_excludes_long_delisted() -> None:
|
||||
"""已退市多年的标的不能再进每日队列。
|
||||
|
||||
回归:实测「报告期滞后」的股票里 200+ 只早已退市(最后一份财报停在退市前),
|
||||
不过滤的话每天都要为它们发 800 次调用、拉回同一批旧数据 —— 实测一次全量
|
||||
重拉耗时约 2 分钟、返回 6.5 万行,全部无功而返。
|
||||
"""
|
||||
from hdiv.core.config import load_config
|
||||
from hdiv.data import db
|
||||
|
||||
asof = date(2026, 9, 30)
|
||||
try:
|
||||
db.load_dotenv_once()
|
||||
cfg = load_config("datasource")
|
||||
db.row_count("stock", cfg)
|
||||
except Exception as exc: # pragma: no cover
|
||||
pytest.skip(f"数据库不可用:{exc}")
|
||||
|
||||
p = daily_sync.plan(cfg, asof=asof)
|
||||
assert len(p.financial_symbols) < 50, (
|
||||
f"每日财报队列 {len(p.financial_symbols)} 只,退市过滤可能失效"
|
||||
)
|
||||
cutoff = daily_sync._active_since(asof)
|
||||
dead = db.read_sql(
|
||||
"SELECT symbol FROM stock WHERE delist_date IS NOT NULL AND delist_date <= :c",
|
||||
{"c": cutoff},
|
||||
cfg=cfg,
|
||||
)
|
||||
dead_set = set(dead["symbol"].astype(str)) if not dead.empty else set()
|
||||
assert not (set(p.financial_symbols) & dead_set), "长期退市标的混进了每日财报队列"
|
||||
|
||||
|
||||
@pytest.mark.db
|
||||
def test_json_mode_writes_no_progress_text(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""``--json`` 的 stdout 必须是**纯 JSON**。
|
||||
|
||||
回归:进度与计划文字混进 stdout 会让 ``| jq`` 之类的消费者直接解析失败,
|
||||
而这类问题在交互式运行时完全看不出来(人眼只看到 JSON 在前面或后面)。
|
||||
"""
|
||||
from hdiv.core.config import load_config
|
||||
from hdiv.data import db
|
||||
|
||||
try:
|
||||
db.load_dotenv_once()
|
||||
cfg = load_config("datasource")
|
||||
db.row_count("stock", cfg)
|
||||
except Exception as exc: # pragma: no cover
|
||||
pytest.skip(f"数据库不可用:{exc}")
|
||||
|
||||
summary = daily_sync.run(
|
||||
cfg, asof=date(2026, 9, 30), dry_run=True, include_financial=False, verbose=False
|
||||
)
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == "", f"verbose=False 仍有 stdout 输出:{captured.out[:200]}"
|
||||
assert summary["dry_run"] is True
|
||||
|
||||
|
||||
@pytest.mark.db
|
||||
def test_dry_run_writes_nothing() -> None:
|
||||
"""``--dry-run`` 不得写库,也不得调用 Tushare。"""
|
||||
from hdiv.core.config import load_config
|
||||
from hdiv.data import db
|
||||
|
||||
try:
|
||||
db.load_dotenv_once()
|
||||
cfg = load_config("datasource")
|
||||
logs_before = db.read_sql("SELECT MAX(id) AS m FROM hd_sync_log", cfg=cfg)["m"].iloc[0]
|
||||
except Exception as exc: # pragma: no cover
|
||||
pytest.skip(f"数据库不可用:{exc}")
|
||||
|
||||
summary = daily_sync.run(cfg, asof=date(2026, 9, 30), dry_run=True, include_financial=False)
|
||||
assert summary["dry_run"] is True
|
||||
assert summary["errors"] == []
|
||||
after_logs = db.read_sql("SELECT MAX(id) AS m FROM hd_sync_log", cfg=cfg)["m"].iloc[0]
|
||||
assert after_logs == logs_before, "dry-run 不应产生新的 hd_sync_log 记录"
|
||||
@@ -43,12 +43,16 @@ FRONTEND_CALLS: list[tuple[str, str]] = [
|
||||
# 净值曲线右轴可叠加的基准指数
|
||||
("GET", "/api/indices"),
|
||||
("GET", "/api/backtests/abc123/signals"),
|
||||
# 每日动态股票池(--mode daily):时间线 / 某日成员
|
||||
("GET", "/api/backtests/abc123/daily-universe"),
|
||||
("PATCH", "/api/backtests/abc123"),
|
||||
# 回测内分析:任意日持仓 + 个股买卖点
|
||||
("GET", "/api/backtests/abc123/portfolio"),
|
||||
("GET", "/api/backtests/abc123/position-dates"),
|
||||
("GET", "/api/backtests/abc123/stocks"),
|
||||
("GET", "/api/backtests/abc123/stocks/600519.SH"),
|
||||
# 已清仓了结清单(含清仓后至今涨跌)
|
||||
("GET", "/api/backtests/abc123/closed-positions"),
|
||||
# Walk-forward 样本外
|
||||
("GET", "/api/walkforwards"),
|
||||
("GET", "/api/walkforwards/abc123"),
|
||||
@@ -179,6 +183,78 @@ def test_frontend_uses_hash_routing_only() -> None:
|
||||
assert "pushState" not in js
|
||||
|
||||
|
||||
def test_router_sentinel_is_not_the_home_path() -> None:
|
||||
"""路由的「已渲染路径」哨兵不能是空串 —— 首页路径本身就是 ''。
|
||||
|
||||
历史 bug:``let currentPath = ''`` 且所有强制重渲染都写 ``currentPath = ''``。
|
||||
首页(hash 为空)解析出的 path 恰好也是 '',于是 render() 一进门就命中
|
||||
``path === currentPath`` 提前返回:index.html 里那句「加载中…」永远不被替换,
|
||||
概览页整页打不开(其他页面因为有非空路径,反而正常)。哨兵改用 null 后,
|
||||
'' 才能被当作一个正常的、需要渲染的路径。
|
||||
"""
|
||||
js = (project_root() / "web" / "app.js").read_text(encoding="utf-8")
|
||||
# 只看赋值(排除 === 比较)
|
||||
assigns = [a.strip() for a in re.findall(r"currentPath\s*=(?!=)\s*([^;\n]+)", js)]
|
||||
assert assigns, "未在 app.js 中找到 currentPath 赋值,解析逻辑需更新"
|
||||
bad = [a for a in assigns if a in {"''", '""'}]
|
||||
assert not bad, f"currentPath 不能用空串作哨兵(与首页路径 '' 冲突):{bad}"
|
||||
assert "let currentPath = null" in js
|
||||
|
||||
|
||||
def test_profile_gate_is_exposed_for_display() -> None:
|
||||
"""画像闸门是买入判据的一部分,必须能在回测页看到。
|
||||
|
||||
看不到就会出现「股息率分位到了却没买」无从解释的情况 ——
|
||||
闸门规则是**第二道**买入条件,和 run 一起要能复现。
|
||||
"""
|
||||
from hdiv.web.service import describe_strategy
|
||||
|
||||
cfg = {
|
||||
"strategy": {"id": "S", "name": "n", "version": "1", "status": "DRAFT",
|
||||
"description": ""},
|
||||
"entry": {
|
||||
"yield_percentile": 75,
|
||||
"profile_gate": {
|
||||
"enabled": True, "window_years": 5, "on_unverifiable": "reject",
|
||||
"min_window_coverage": 0.0,
|
||||
"rules": [
|
||||
{"metric": "payout_ratio", "stat": "current_value",
|
||||
"op": "<=", "value": 1.0},
|
||||
# 没写 stat:应默认当日值,且不能把规则丢掉
|
||||
{"metric": "roe_avg", "op": ">=", "value": 0.08},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
g = describe_strategy(cfg)["profile_gate"]
|
||||
assert g["enabled"] is True and g["window_years"] == 5.0
|
||||
assert g["on_unverifiable"] == "reject"
|
||||
assert [r["metric"] for r in g["rules"]] == ["payout_ratio", "roe_avg"]
|
||||
assert g["rules"][0]["op"] == "<=" and g["rules"][0]["value"] == 1.0
|
||||
assert g["rules"][1]["stat"] == "current_value"
|
||||
json.dumps(g, ensure_ascii=False, allow_nan=False)
|
||||
|
||||
# 老配置没有这一段 → None,前端据此不显示卡片
|
||||
assert describe_strategy({"strategy": {}, "entry": {}})["profile_gate"] is None
|
||||
# 脏数据不能把整页带崩,也不能造出假规则
|
||||
dirty = describe_strategy({"strategy": {},
|
||||
"entry": {"profile_gate": {"enabled": True,
|
||||
"rules": [None, {}, "x"]}}})
|
||||
assert dirty["profile_gate"]["rules"] == []
|
||||
|
||||
|
||||
def test_profile_gate_card_is_wired_into_backtest_page() -> None:
|
||||
"""回测页必须真的把画像闸门渲染出来(接口有字段≠页面显示)。"""
|
||||
js = (project_root() / "web" / "app.js").read_text(encoding="utf-8")
|
||||
assert "profileGateCard" in js, "缺少画像闸门卡片渲染函数"
|
||||
assert "个股画像筛选条件" in js, "缺少画像闸门卡片标题"
|
||||
assert "profile_gate" in js, "未把接口字段接到卡片上"
|
||||
# 卡片要挂在「回测条件」之后
|
||||
cond = js.index(">回测条件<")
|
||||
gate = js.index("profileGateCard(b.strategy.profile_gate)")
|
||||
assert cond < gate, "画像闸门卡片必须在「回测条件」之后"
|
||||
|
||||
|
||||
def test_frontend_escapes_html() -> None:
|
||||
"""用户可输入记录名称/备注,必须转义以避免 XSS。"""
|
||||
js = (project_root() / "web" / "app.js").read_text(encoding="utf-8")
|
||||
@@ -383,6 +459,139 @@ def test_equity_index_overlay_is_date_aligned() -> None:
|
||||
service.get_backtest_equity(rid, index_code="999999.XX")
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_stock_detail_default_range_reaches_latest_data() -> None:
|
||||
"""回归:默认区间要到**该股最新行情**,而不是持仓结束(卖出)当天。
|
||||
|
||||
老实现默认用「持仓区间」,卖出之后曲线就断了,
|
||||
「卖飞了没有」这个最该回答的问题在图上无从回答。
|
||||
这里特意挑「已清仓、且清仓日之后还有行情」的样本 —— 正是老实现会断线的场景。
|
||||
"""
|
||||
from hdiv.core.config import load_config
|
||||
from hdiv.data import db
|
||||
from hdiv.web import analysis
|
||||
|
||||
cfg = load_config("datasource")
|
||||
df = db.read_sql(
|
||||
"SELECT p.run_id, p.symbol, p.hold_start, p.hold_end, d.avail_end "
|
||||
"FROM (SELECT run_id, symbol, MIN(trade_date) AS hold_start, "
|
||||
" MAX(trade_date) AS hold_end "
|
||||
" FROM hd_backtest_position GROUP BY run_id, symbol) p "
|
||||
"JOIN (SELECT symbol, MAX(trade_date) AS avail_end "
|
||||
" FROM daily_basic GROUP BY symbol) d ON d.symbol = p.symbol "
|
||||
"WHERE p.hold_end < d.avail_end "
|
||||
"ORDER BY p.hold_end LIMIT 1",
|
||||
cfg=cfg,
|
||||
)
|
||||
if df.empty:
|
||||
pytest.skip("库里没有「已清仓且之后仍有行情」的样本")
|
||||
row = df.iloc[0]
|
||||
rid, sym = str(row["run_id"]), str(row["symbol"])
|
||||
hold_start, hold_end, avail_end = (str(row["hold_start"]), str(row["hold_end"]),
|
||||
str(row["avail_end"]))
|
||||
|
||||
r = analysis.stock_detail(rid, sym, series=["close"])["range"]
|
||||
assert r["available_start"] and r["available_end"], "应返回该股行情边界供日期选择器用"
|
||||
assert r["end"] == avail_end, \
|
||||
f"默认区间止于 {r['end']},而行情已到 {avail_end}(又回到「卖出即断线」)"
|
||||
assert r["start"] <= hold_start, "默认起点不应晚于持仓起点(判据数据要在图上)"
|
||||
assert r["end"] > hold_end, f"默认区间不应停在清仓日 {hold_end}"
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_stock_detail_default_range_includes_judgement_lookback() -> None:
|
||||
"""默认区间要含**首笔成交之前**的判据数据。
|
||||
|
||||
买入依据是股息率的历史分位(滚动窗口,config: percentile_reference
|
||||
.lookback_years = 5 年);只画持仓期等于把「当时凭什么买」的判据裁掉了。
|
||||
"""
|
||||
import datetime as _dt
|
||||
|
||||
from hdiv.web import analysis
|
||||
|
||||
rid = _sample_backtest_run()
|
||||
if not rid:
|
||||
pytest.skip("没有可用的回测")
|
||||
stocks = [s for s in analysis.run_stocks(rid) if (s.get("trade_count") or 0) > 0]
|
||||
if not stocks:
|
||||
pytest.skip("该回测没有成交")
|
||||
sym = stocks[0]["symbol"]
|
||||
|
||||
d = analysis.stock_detail(rid, sym, series=["close"])
|
||||
r = d["range"]
|
||||
first = min(t["execution_date"] for t in d["trades"])
|
||||
need = _dt.date.fromisoformat(first) - _dt.timedelta(days=int(365.25 * 5))
|
||||
assert r["start"] <= need.isoformat(), \
|
||||
f"默认起点 {r['start']} 未覆盖首笔成交({first})之前 5 年的判据数据"
|
||||
# 但不该早于该股行情本身(否则日期选择器会给出选不到的日期)
|
||||
assert r["start"] >= r["available_start"]
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_downsampling_keeps_trade_dates() -> None:
|
||||
"""回归:降采样不能把成交日丢掉。
|
||||
|
||||
前端按**日期**把买卖点落到横轴上,横轴里没有那一天,
|
||||
这笔成交就会从图上消失(还会被前端误报成「不在所选区间内」)。
|
||||
实测:默认区间放宽到「5 年判据 + 至今」后,13 只降采样股票里有 7 只会丢成交日。
|
||||
"""
|
||||
from hdiv.web import analysis
|
||||
|
||||
rid = _sample_backtest_run()
|
||||
if not rid:
|
||||
pytest.skip("没有可用的回测")
|
||||
checked = 0
|
||||
for s in analysis.run_stocks(rid):
|
||||
d = analysis.stock_detail(rid, s["symbol"], series=["close"])
|
||||
if not d["range"]["downsampled"]:
|
||||
continue
|
||||
checked += 1
|
||||
axis = set(d["dates"])
|
||||
missing = [t["execution_date"] for t in d["trades"]
|
||||
if t["execution_date"] not in axis]
|
||||
assert not missing, \
|
||||
f"{s['symbol']} 降采样后丢了成交日 {missing},图上会少标这几笔"
|
||||
assert d["dates"] == sorted(d["dates"]), "横轴仍须按时间升序"
|
||||
if not checked:
|
||||
pytest.skip("该回测没有触发降采样的个股")
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_stock_detail_date_params_and_validation() -> None:
|
||||
"""区间参数:能收窄、非法输入报可读错误、区间外成交仍要返回。"""
|
||||
import datetime as _dt
|
||||
|
||||
from hdiv.core.errors import HdivError
|
||||
from hdiv.web import analysis
|
||||
|
||||
rid = _sample_backtest_run()
|
||||
if not rid:
|
||||
pytest.skip("没有可用的回测")
|
||||
stocks = analysis.run_stocks(rid)
|
||||
if not stocks:
|
||||
pytest.skip("该回测没有持仓股票")
|
||||
sym = stocks[0]["symbol"]
|
||||
|
||||
base = analysis.stock_detail(rid, sym, series=["close"])
|
||||
a = _dt.date.fromisoformat(base["range"]["start"])
|
||||
s, e = a.isoformat(), (a + _dt.timedelta(days=180)).isoformat()
|
||||
|
||||
d = analysis.stock_detail(rid, sym, start=s, end=e, series=["close"])
|
||||
assert d["range"]["requested_start"] == s and d["range"]["requested_end"] == e
|
||||
assert s <= d["range"]["start"] and d["range"]["end"] <= e
|
||||
assert d["range"]["points"] < base["range"]["points"], "收窄区间应真的少取数据"
|
||||
assert d["range"]["default_end"] == base["range"]["default_end"], \
|
||||
"default_* 应是「重置」用的缺省区间,不随本次请求变化"
|
||||
# 区间外的成交仍要返回:前端靠它提示「有 N 笔不在所选区间内」
|
||||
assert len(d["trades"]) == len(base["trades"])
|
||||
|
||||
with pytest.raises(HdivError):
|
||||
analysis.stock_detail(rid, sym, start=e, end=s, series=["close"])
|
||||
for bad in ("2024-13-45", "not-a-date"):
|
||||
with pytest.raises(HdivError):
|
||||
analysis.stock_detail(rid, sym, start=bad, series=["close"])
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_reason_text_is_human_readable() -> None:
|
||||
"""成交理由必须渲染成人话,而不是丢一坨 JSON 给前端。"""
|
||||
@@ -632,6 +841,22 @@ def test_site_build_does_not_clobber_spa() -> None:
|
||||
assert "app/app.js" in html
|
||||
|
||||
|
||||
def test_published_site_is_world_readable() -> None:
|
||||
"""发布产物必须 world-readable:nginx worker 以 nobody 运行,不是文件属主。
|
||||
|
||||
``shutil.copy2`` 会保留源文件权限,所以一个 umask 077 存下来的 600 文件
|
||||
会让线上 CSS/JS 直接 403(HTML 打得开、页面裸奔)。发布时统一收敛权限。
|
||||
"""
|
||||
from hdiv.web import site
|
||||
|
||||
site.sync_frontend(verbose=False)
|
||||
out = project_root() / "output"
|
||||
unreadable = [p for p in out.rglob("*") if p.is_file() and not p.stat().st_mode & 0o044]
|
||||
assert not unreadable, f"这些发布文件 nginx(nobody)读不到:{unreadable[:5]}"
|
||||
untraversable = [p for p in out.rglob("*") if p.is_dir() and not p.stat().st_mode & 0o011]
|
||||
assert not untraversable, f"这些目录 nginx(nobody)进不去:{untraversable[:5]}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 回测内分析:任意日持仓 + 个股买卖点
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -651,6 +876,80 @@ def _sample_backtest_run() -> str | None:
|
||||
return None if df.empty else str(df["run_id"].iloc[0])
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_closed_positions_definition_and_math() -> None:
|
||||
"""已清仓清单:定义(期末不再持有)+ 口径(收益率、清仓后涨跌)都要对得上。
|
||||
|
||||
「已清仓」若按成交净额判断会漏掉「卖了又买回、期末仍持有」的票,
|
||||
这里同时用两套口径交叉验证,并要求金额/盈亏与成交表逐笔汇总一致。
|
||||
"""
|
||||
from hdiv.core.config import load_config
|
||||
from hdiv.data import db
|
||||
from hdiv.core.errors import HdivError
|
||||
from hdiv.web import analysis
|
||||
|
||||
cfg = load_config("datasource")
|
||||
# 找一只有已清仓个股的回测(期末持仓数 < 曾持有数)
|
||||
df = db.read_sql(
|
||||
"SELECT run_id FROM hd_backtest_position GROUP BY run_id "
|
||||
"HAVING COUNT(DISTINCT symbol) > "
|
||||
" (SELECT COUNT(DISTINCT symbol) FROM hd_backtest_position p2 "
|
||||
" WHERE p2.run_id = hd_backtest_position.run_id "
|
||||
" AND p2.trade_date = (SELECT MAX(trade_date) FROM hd_backtest_position p3 "
|
||||
" WHERE p3.run_id = hd_backtest_position.run_id)) "
|
||||
"ORDER BY COUNT(DISTINCT symbol) DESC LIMIT 1",
|
||||
cfg=cfg,
|
||||
)
|
||||
if df.empty:
|
||||
pytest.skip("没有含已清仓个股的回测")
|
||||
rid = str(df["run_id"].iloc[0])
|
||||
|
||||
d = analysis.closed_positions(rid)
|
||||
items = d["items"]
|
||||
assert items, "该回测应当有已清仓个股"
|
||||
json.dumps(d, ensure_ascii=False, allow_nan=False) # NaN 不能漏到前端
|
||||
|
||||
last_day = str(db.read_sql(
|
||||
"SELECT MAX(trade_date) AS d FROM hd_backtest_position WHERE run_id = :r",
|
||||
{"r": rid}, cfg=cfg)["d"].iloc[0])
|
||||
still = set(db.read_sql(
|
||||
"SELECT DISTINCT symbol FROM hd_backtest_position "
|
||||
"WHERE run_id = :r AND trade_date = :d", {"r": rid, "d": last_day}, cfg=cfg)["symbol"])
|
||||
|
||||
tr = db.read_sql(
|
||||
"SELECT symbol, side, quantity, amount, realized_pnl, execution_date "
|
||||
"FROM hd_backtest_trade WHERE run_id = :r", {"r": rid}, cfg=cfg)
|
||||
for x in items:
|
||||
assert x["symbol"] not in still, f"{x['symbol']} 期末仍持有,不该出现在已清仓清单"
|
||||
mine = tr[tr["symbol"] == x["symbol"]]
|
||||
buys = mine[mine["side"] == "BUY"]
|
||||
sells = mine[mine["side"] == "SELL"]
|
||||
assert len(sells) > 0, "已清仓必然有卖出成交"
|
||||
# 刻意**不**校验「买入股数 == 卖出股数」:送股/转增会让持仓股数凭空增加
|
||||
# (实测 600188.SH 在 93fb7456 里买入 6000 股、卖出 11700 股)。
|
||||
# 所以「已清仓」只能以持仓表为准,不能用成交净额反推。
|
||||
assert x["last_sell"] == str(sells["execution_date"].max())
|
||||
assert abs((x["realized_pnl"] or 0) - float(sells["realized_pnl"].sum())) < 1e-6
|
||||
assert abs((x["buy_amount"] or 0) - float(buys["amount"].sum())) < 1e-6
|
||||
assert x["first_hold"] and x["last_hold"] and x["hold_days"] > 0
|
||||
if x["return_pct"] is not None: # 已清仓 ⇒ 收益率 = 已实现盈亏 / 买入金额
|
||||
assert abs(x["return_pct"] - x["realized_pnl"] / x["buy_amount"]) < 1e-9
|
||||
if x["since_sell_pct"] is not None: # 清仓后涨跌以清仓日收盘为基准
|
||||
assert abs(x["since_sell_pct"] -
|
||||
(x["close_latest"] / x["close_at_sell"] - 1.0)) < 1e-9
|
||||
|
||||
s = d["summary"]
|
||||
assert s["count"] == len(items)
|
||||
assert abs(s["realized_pnl"] - sum(x["realized_pnl"] or 0 for x in items)) < 1e-6
|
||||
assert s["since_sell_up"] + s["since_sell_down"] <= s["count"]
|
||||
# 明细按清仓日倒序(最近清仓的排在最前)
|
||||
dates = [x["last_sell"] or "" for x in items]
|
||||
assert dates == sorted(dates, reverse=True)
|
||||
|
||||
with pytest.raises(HdivError):
|
||||
analysis.closed_positions("不存在的runid")
|
||||
|
||||
|
||||
@requires_db
|
||||
def test_position_dates_is_compact_by_default() -> None:
|
||||
"""默认只返回日期字符串:带全字段会让响应从约 30KB 涨到 460KB。"""
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""股息率(TTM 每股分红)毛刺诊断脚本。
|
||||
|
||||
**用途**:定位「数据毛刺导致回测异常成交」这一类问题。
|
||||
它不改动任何数据,只读库、只打印。
|
||||
|
||||
用法::
|
||||
|
||||
export PYTHONPATH=src
|
||||
.venv/bin/python tools/diag_dividend_artifact.py --symbol 600690.SH \
|
||||
--start 2026-07-01 --end 2026-08-05
|
||||
|
||||
诊断三件事:
|
||||
|
||||
1. **同一除权日的重复分红记录**:``hd_dividend`` 写入侧刻意保留
|
||||
预案/股东大会通过/实施 全量记录(决策 D6),但查询侧把它们当成
|
||||
**多笔独立分红**,于是 ``cash_div_tax`` 被重复累加。
|
||||
2. **TTM 每股分红的时间线**:逐交易日打印去重前 / 去重后的取值,
|
||||
毛刺会表现为「无任何真实现金事件的一天突然跳变」。
|
||||
3. **异常成交反查**:给定回测 run_id,列出每一笔卖出当日 TTM 值的
|
||||
去重前/去重后差异,标出「去重后不再触发卖出」的笔数。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from hdiv.data import db # noqa: E402
|
||||
from hdiv.factor.dividend_yield import ( # noqa: E402
|
||||
build_dps_events,
|
||||
ttm_dps_series,
|
||||
ttm_params,
|
||||
)
|
||||
|
||||
|
||||
def _dedup(events: pd.DataFrame) -> pd.DataFrame:
|
||||
"""同一除权日只保留一笔(取金额最大者)。
|
||||
|
||||
这是「写入口径全量保留、查询口径按经济事件聚合」的最小实现。
|
||||
"""
|
||||
if events.empty:
|
||||
return events
|
||||
return (
|
||||
events.sort_values("cash_div_tax")
|
||||
.drop_duplicates("ex_date", keep="last")
|
||||
.reset_index(drop=True)
|
||||
)
|
||||
|
||||
|
||||
def diagnose_symbol(symbol: str, start: date, end: date) -> None:
|
||||
div = db.read_sql(
|
||||
"SELECT symbol, end_date, ann_date, imp_ann_date, div_proc, "
|
||||
" cash_div_tax, ex_date "
|
||||
"FROM hd_dividend WHERE symbol = :s AND cash_div_tax > 0 "
|
||||
" AND ex_date IS NOT NULL AND div_proc = '实施' "
|
||||
"ORDER BY ex_date",
|
||||
{"s": symbol},
|
||||
)
|
||||
events = build_dps_events(div).get(symbol)
|
||||
if events is None or events.empty:
|
||||
print(f"{symbol}: 无已实施现金分红记录")
|
||||
return
|
||||
|
||||
dups = events.groupby("ex_date").size()
|
||||
dups = dups[dups > 1]
|
||||
print(f"=== {symbol} 分红记录 ===")
|
||||
print(f"已实施现金分红行数:{len(events)},唯一除权日:{events['ex_date'].nunique()}")
|
||||
print(f"**同一除权日重复 {len(dups)} 组**(这些金额被重复累加):")
|
||||
for ex, n in dups.items():
|
||||
rows = events[events["ex_date"] == ex]
|
||||
print(
|
||||
f" {ex.date()} x{n} 金额={rows['cash_div_tax'].tolist()} "
|
||||
f"实施日={[str(pd.Timestamp(x).date()) for x in rows['imp_ann_date']]}"
|
||||
)
|
||||
|
||||
px = db.read_sql(
|
||||
"SELECT trade_date, close FROM stock_daily "
|
||||
"WHERE symbol = :s AND trade_date BETWEEN :a AND :b ORDER BY trade_date",
|
||||
{"s": symbol, "a": start, "b": end},
|
||||
)
|
||||
if px.empty:
|
||||
print("区间内无行情")
|
||||
return
|
||||
px["trade_date"] = pd.to_datetime(px["trade_date"])
|
||||
idx = pd.DatetimeIndex(px["trade_date"])
|
||||
w, g, sm = ttm_params()
|
||||
raw = ttm_dps_series(idx, events, ttm_days=w, grace_days=g, smooth_spikes=sm)
|
||||
ded = ttm_dps_series(idx, _dedup(events), ttm_days=w, grace_days=g, smooth_spikes=sm)
|
||||
out = pd.DataFrame(
|
||||
{
|
||||
"trade_date": px["trade_date"].dt.date,
|
||||
"close": px["close"],
|
||||
"ttm_dps(去重前)": raw,
|
||||
"ttm_dps(去重后)": ded,
|
||||
}
|
||||
)
|
||||
out["股息率%(去重前)"] = (out["ttm_dps(去重前)"] / out["close"] * 100).round(3)
|
||||
out["股息率%(去重后)"] = (out["ttm_dps(去重后)"] / out["close"] * 100).round(3)
|
||||
print("\n=== 逐交易日 TTM 每股分红 ===")
|
||||
print(out.to_string(index=False))
|
||||
|
||||
jump = out[pd.Series(raw, index=range(len(out))).diff().abs() > 1e-9]
|
||||
print("\n出现跳变的交易日(应与真实分红除权日一一对应):")
|
||||
print(jump[["trade_date", "ttm_dps(去重前)", "ttm_dps(去重后)"]].to_string(index=False))
|
||||
|
||||
|
||||
def diagnose_run(run_id: str) -> None:
|
||||
"""反查某次回测的每一笔卖出:去重后是否仍会触发。"""
|
||||
from hdiv.data.repo import Repo
|
||||
|
||||
repo = Repo()
|
||||
trades = db.read_sql(
|
||||
"SELECT symbol, signal_date, execution_date, reason_json "
|
||||
"FROM hd_backtest_trade WHERE run_id = :r AND side = 'SELL' "
|
||||
"ORDER BY execution_date",
|
||||
{"r": run_id},
|
||||
)
|
||||
if trades.empty:
|
||||
print("该 run 无卖出成交")
|
||||
return
|
||||
w, g, sm = ttm_params()
|
||||
years = 5
|
||||
rows = []
|
||||
for _, t in trades.iterrows():
|
||||
sym = t["symbol"]
|
||||
d0 = pd.Timestamp(t["signal_date"]).date()
|
||||
div = repo.dividend_records(d0, years_back=years + 3)
|
||||
div = div[div["symbol"] == sym]
|
||||
ev = build_dps_events(div).get(sym)
|
||||
if ev is None or ev.empty:
|
||||
continue
|
||||
px = repo.price_history([sym], date(d0.year - years - 1, 1, 1), d0, adjust="none")
|
||||
px = px.sort_values("trade_date")
|
||||
px["trade_date"] = pd.to_datetime(px["trade_date"])
|
||||
s = px.set_index("trade_date")["close"]
|
||||
i = pd.DatetimeIndex(s.index)
|
||||
ref = (d0 - timedelta(days=int(365.25 * years)), d0)
|
||||
|
||||
def decision(events: pd.DataFrame) -> tuple[float, float, float]:
|
||||
dps = pd.Series(
|
||||
ttm_dps_series(i, events, ttm_days=w, grace_days=g, smooth_spikes=sm),
|
||||
index=i,
|
||||
)
|
||||
y = (dps / s).loc[: pd.Timestamp(d0)]
|
||||
cur = float(y.iloc[-1])
|
||||
rs = y.loc[pd.Timestamp(ref[0]) : pd.Timestamp(ref[1])]
|
||||
pct = float((rs <= cur).sum() / rs.size * 100) if rs.size else float("nan")
|
||||
return cur, pct, float(rs.quantile(0.25)) if rs.size else float("nan")
|
||||
|
||||
for label, evx in (("去重前", ev), ("去重后", _dedup(ev))):
|
||||
cur, pct, p25 = decision(evx)
|
||||
rows.append(
|
||||
{
|
||||
"symbol": sym,
|
||||
"signal_date": d0,
|
||||
"口径": label,
|
||||
"股息率%": round(cur * 100, 3),
|
||||
"分位%": round(pct, 2),
|
||||
"P25%": round(p25 * 100, 3),
|
||||
"触发卖出(P25)": pct <= 25.0,
|
||||
}
|
||||
)
|
||||
if not rows:
|
||||
return
|
||||
df = pd.DataFrame(rows)
|
||||
print(f"\n=== run {run_id} 卖出决策:去重前 vs 去重后 ===")
|
||||
print(df.pivot_table(
|
||||
index=["symbol", "signal_date"], columns="口径",
|
||||
values=["分位%", "触发卖出(P25)"], aggfunc="first",
|
||||
).to_string())
|
||||
before = df[df["口径"] == "去重前"].set_index(["symbol", "signal_date"])["触发卖出(P25)"]
|
||||
after = df[df["口径"] == "去重后"].set_index(["symbol", "signal_date"])["触发卖出(P25)"]
|
||||
flipped = before[before & ~after.reindex(before.index).fillna(False)]
|
||||
print(f"\n去重后**不再触发**卖出的成交:{len(flipped)} / {len(before)} 笔")
|
||||
for k in flipped.index:
|
||||
print(f" {k[0]} 信号日 {k[1]}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="股息率毛刺诊断")
|
||||
ap.add_argument("--symbol", help="股票代码,如 600690.SH")
|
||||
ap.add_argument("--start", help="起始日 YYYY-MM-DD")
|
||||
ap.add_argument("--end", help="结束日 YYYY-MM-DD")
|
||||
ap.add_argument("--run-id", help="反查某次回测的所有卖出成交")
|
||||
ap.add_argument("--duplicate-survey", action="store_true",
|
||||
help="全库统计:同一除权日重复记录的股票数")
|
||||
args = ap.parse_args()
|
||||
|
||||
db.load_dotenv_once()
|
||||
|
||||
if args.duplicate_survey:
|
||||
df = db.read_sql(
|
||||
"SELECT symbol, COUNT(*) AS rows_, COUNT(DISTINCT ex_date) AS ex_dates "
|
||||
"FROM hd_dividend WHERE cash_div_tax > 0 AND ex_date IS NOT NULL "
|
||||
" AND div_proc = '实施' GROUP BY symbol",
|
||||
{},
|
||||
)
|
||||
bad = df[df["rows_"] > df["ex_dates"]]
|
||||
print(f"有已实施现金分红的股票:{len(df)}")
|
||||
print(f"**存在同一除权日重复记录的股票:{len(bad)}**"
|
||||
f"(多出 {int((bad['rows_'] - bad['ex_dates']).sum())} 行被重复累加)")
|
||||
print(bad.sort_values("rows_", ascending=False).head(20).to_string(index=False))
|
||||
return 0
|
||||
|
||||
if args.run_id:
|
||||
diagnose_run(args.run_id)
|
||||
if args.symbol:
|
||||
start = date.fromisoformat(args.start) if args.start else date(2026, 1, 1)
|
||||
end = date.fromisoformat(args.end) if args.end else date(2026, 12, 31)
|
||||
diagnose_symbol(args.symbol, start, end)
|
||||
if not (args.run_id or args.symbol or args.duplicate_survey):
|
||||
ap.print_help()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -105,6 +105,8 @@ table.data th.l,table.data td.l{text-align:left}
|
||||
table.data td.num{font-family:var(--mono)}
|
||||
table.data tbody tr:hover{background:#F1F5F9}
|
||||
table.data tbody tr:last-child td{border-bottom:none}
|
||||
/* 已清仓清单:理由很长,截断显示(悬停看全文),免得整张表横向滚动 */
|
||||
table.data td.reason{max-width:280px;overflow:hidden;text-overflow:ellipsis}
|
||||
.pager{display:flex;gap:10px;align-items:center;justify-content:flex-end;
|
||||
margin-top:12px;font-size:12px;color:var(--text-muted)}
|
||||
|
||||
|
||||
+518
-81
@@ -428,7 +428,19 @@ async function viewStock(symbol) {
|
||||
|
||||
<div class="card">
|
||||
<h2>K线 · 股息率 · PE · 回撤</h2>
|
||||
<div class="toolbar">
|
||||
<span class="small muted">区间</span>
|
||||
<input type="date" id="sp-from" style="width:auto">
|
||||
<span class="small muted">~</span>
|
||||
<input type="date" id="sp-to" style="width:auto">
|
||||
<button data-act="sp-apply">应用</button>
|
||||
<button data-act="sp-reset">重置</button>
|
||||
<span class="small muted" id="sp-hint"></span>
|
||||
</div>
|
||||
<div id="c_main" class="chart tall"></div>
|
||||
<div class="callout" style="margin-top:12px">
|
||||
区间只影响这张走势图;右侧分布统计与雷达图仍是<b>画像当时的全历史</b>口径。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid2">
|
||||
@@ -452,41 +464,23 @@ async function viewStock(symbol) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const spState = {data: null, from: '', to: ''};
|
||||
|
||||
function mountStock(d) {
|
||||
const ch = d.chart || {};
|
||||
if (!ch.dates || !ch.dates.length) return;
|
||||
const grid = (t,h) => ({left:62,right:62,top:t,height:h});
|
||||
chart('c_main', {
|
||||
tooltip:{trigger:'axis',axisPointer:{type:'cross'}},
|
||||
axisPointer:{link:[{xAxisIndex:'all'}]},
|
||||
legend:{top:0,textStyle:{color:'#64748B'}},
|
||||
grid:[grid(34,190),grid(262,120),grid(420,110)],
|
||||
xAxis:[
|
||||
{type:'category',data:ch.dates,gridIndex:0,axisLabel:{show:false},axisTick:{show:false}},
|
||||
{type:'category',data:ch.dates,gridIndex:1,axisLabel:{show:false},axisTick:{show:false}},
|
||||
{type:'category',data:ch.dates,gridIndex:2,
|
||||
axisLabel:{color:'#64748B',formatter:v=>String(v).slice(0,7)}}],
|
||||
yAxis:[
|
||||
{scale:true,gridIndex:0,name:'价格',axisLabel:{color:'#64748B'},splitLine:{lineStyle:{color:'#E9EEF6'}}},
|
||||
{scale:true,gridIndex:1,name:'股息率%',axisLabel:{color:'#64748B',formatter:v=>(v*100).toFixed(1)},
|
||||
splitLine:{lineStyle:{color:'#E9EEF6'}}},
|
||||
{scale:true,gridIndex:2,name:'PE',axisLabel:{color:'#64748B'},splitLine:{lineStyle:{color:'#E9EEF6'}}}],
|
||||
dataZoom:[{type:'inside'},{type:'slider',height:16,bottom:8}],
|
||||
series:[
|
||||
{name:'收盘价',type:'line',xAxisIndex:0,yAxisIndex:0,data:ch.close,showSymbol:false,
|
||||
lineStyle:{width:1.4,color:COLORS[0]}},
|
||||
{name:'股息率',type:'line',xAxisIndex:1,yAxisIndex:1,data:ch.dv_yield,showSymbol:false,
|
||||
lineStyle:{width:1.6,color:COLORS[1]},areaStyle:{opacity:.12},
|
||||
markLine:{silent:true,symbol:'none',data:[
|
||||
...(d.yield_summary && d.yield_summary.p75 != null ? [{yAxis:d.yield_summary.p75,
|
||||
lineStyle:{color:COLORS[4],type:'dashed'},
|
||||
label:{formatter:'买入 P75',color:COLORS[4],position:'insideEndTop'}}] : []),
|
||||
...(d.yield_summary && d.yield_summary.p25 != null ? [{yAxis:d.yield_summary.p25,
|
||||
lineStyle:{color:COLORS[3],type:'dashed'},
|
||||
label:{formatter:'卖出 P25',color:COLORS[3],position:'insideEndBottom'}}] : [])]}},
|
||||
{name:'PE(TTM)',type:'line',xAxisIndex:2,yAxisIndex:2,data:ch.pe_ttm,showSymbol:false,
|
||||
lineStyle:{width:1.2,color:COLORS[2]}}]
|
||||
});
|
||||
spState.data = d;
|
||||
spState.from = ''; spState.to = ''; // 换股票回到全区间
|
||||
const from = document.getElementById('sp-from');
|
||||
const to = document.getElementById('sp-to');
|
||||
if (from && to) {
|
||||
// 画像序列是快照,边界就是能选的全部;不设上限会让人以为能选到今天
|
||||
from.min = to.min = ch.dates[0];
|
||||
from.max = to.max = ch.dates[ch.dates.length - 1];
|
||||
from.value = ch.dates[0];
|
||||
to.value = ch.dates[ch.dates.length - 1];
|
||||
}
|
||||
paintStockMain();
|
||||
|
||||
// 直方图(用序列值前端分箱,属于纯呈现,不是业务计算)
|
||||
const vals = (ch.dv_yield || []).filter(v => v != null && v > 0);
|
||||
@@ -521,6 +515,63 @@ function mountStock(d) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 画画像走势图;区间是纯前端截取(画像序列已全量在手,不必再取数)。 */
|
||||
function paintStockMain() {
|
||||
const d = spState.data;
|
||||
const ch = d && d.chart;
|
||||
if (!ch || !ch.dates || !ch.dates.length) return;
|
||||
const all = ch.dates;
|
||||
let i0 = 0, i1 = all.length - 1;
|
||||
if (spState.from) { const k = all.findIndex(x => x >= spState.from); if (k >= 0) i0 = k; }
|
||||
if (spState.to) {
|
||||
for (let i = all.length - 1; i >= 0; i--) { if (all[i] <= spState.to) { i1 = i; break; } }
|
||||
}
|
||||
if (i1 < i0) { toast('开始日期不能晚于结束日期', true); return; }
|
||||
const dates = all.slice(i0, i1 + 1);
|
||||
const cut = k => ((ch[k] || []).length === all.length ? ch[k].slice(i0, i1 + 1) : []);
|
||||
|
||||
const hint = document.getElementById('sp-hint');
|
||||
if (hint) {
|
||||
const asof = (d.profile_run && d.profile_run.asof_date) || all[all.length - 1];
|
||||
hint.textContent = `${dates[0]} ~ ${dates[dates.length - 1]} · ${dates.length} 个交易日`
|
||||
+ ` · 画像快照止于 ${asof}`;
|
||||
}
|
||||
|
||||
disposeChart('c_main');
|
||||
const grid = (t,h) => ({left:62,right:62,top:t,height:h});
|
||||
chart('c_main', {
|
||||
tooltip:{trigger:'axis',axisPointer:{type:'cross'}},
|
||||
axisPointer:{link:[{xAxisIndex:'all'}]},
|
||||
legend:{top:0,textStyle:{color:'#64748B'}},
|
||||
grid:[grid(34,190),grid(262,120),grid(420,110)],
|
||||
xAxis:[
|
||||
{type:'category',data:dates,gridIndex:0,axisLabel:{show:false},axisTick:{show:false}},
|
||||
{type:'category',data:dates,gridIndex:1,axisLabel:{show:false},axisTick:{show:false}},
|
||||
{type:'category',data:dates,gridIndex:2,
|
||||
axisLabel:{color:'#64748B',formatter:v=>String(v).slice(0,7)}}],
|
||||
yAxis:[
|
||||
{scale:true,gridIndex:0,name:'价格',axisLabel:{color:'#64748B'},splitLine:{lineStyle:{color:'#E9EEF6'}}},
|
||||
{scale:true,gridIndex:1,name:'股息率%',axisLabel:{color:'#64748B',formatter:v=>(v*100).toFixed(1)},
|
||||
splitLine:{lineStyle:{color:'#E9EEF6'}}},
|
||||
{scale:true,gridIndex:2,name:'PE',axisLabel:{color:'#64748B'},splitLine:{lineStyle:{color:'#E9EEF6'}}}],
|
||||
dataZoom:[{type:'inside'},{type:'slider',height:16,bottom:8}],
|
||||
series:[
|
||||
{name:'收盘价',type:'line',xAxisIndex:0,yAxisIndex:0,data:cut('close'),showSymbol:false,
|
||||
lineStyle:{width:1.4,color:COLORS[0]},itemStyle:{color:COLORS[0]}},
|
||||
{name:'股息率',type:'line',xAxisIndex:1,yAxisIndex:1,data:cut('dv_yield'),showSymbol:false,
|
||||
lineStyle:{width:1.6,color:COLORS[1]},itemStyle:{color:COLORS[1]},areaStyle:{opacity:.12},
|
||||
markLine:{silent:true,symbol:'none',data:[
|
||||
...(d.yield_summary && d.yield_summary.p75 != null ? [{yAxis:d.yield_summary.p75,
|
||||
lineStyle:{color:COLORS[4],type:'dashed'},
|
||||
label:{formatter:'买入 P75',color:COLORS[4],position:'insideEndTop'}}] : []),
|
||||
...(d.yield_summary && d.yield_summary.p25 != null ? [{yAxis:d.yield_summary.p25,
|
||||
lineStyle:{color:COLORS[3],type:'dashed'},
|
||||
label:{formatter:'卖出 P25',color:COLORS[3],position:'insideEndBottom'}}] : [])]}},
|
||||
{name:'PE(TTM)',type:'line',xAxisIndex:2,yAxisIndex:2,data:cut('pe_ttm'),showSymbol:false,
|
||||
lineStyle:{width:1.2,color:COLORS[2]},itemStyle:{color:COLORS[2]}}]
|
||||
});
|
||||
}
|
||||
|
||||
const LABEL = {dv_yield:'股息率',pe_ttm:'PE(TTM)',pb:'PB',ps_ttm:'PS(TTM)',close:'收盘价',
|
||||
drawdown:'回撤',ttm_dps:'TTM 每股分红',dps:'每股分红',roe:'ROE',roic:'ROIC',
|
||||
gross_margin:'毛利率',net_margin:'净利率',debt_ratio:'资产负债率',
|
||||
@@ -609,6 +660,52 @@ async function viewBacktests(params) {
|
||||
}
|
||||
|
||||
/* ---------------- 视图:回测详情 ---------------- */
|
||||
/** 个股画像闸门:买入信号触发后,用当日可见数据重算画像再逐条核验。 */
|
||||
function profileGateCard(g) {
|
||||
const rules = (g && g.rules) || [];
|
||||
if (!rules.length) return ''; // 该回测没配画像闸门 → 不占版面
|
||||
const STAT = {current_value: '当日值',
|
||||
current_percentile: '当日值在窗口分布中的分位'};
|
||||
const OP = {'>=': '≥', '<=': '≤', '>': '>', '<': '<', '==': '='};
|
||||
const fmtVal = (metric, v) => {
|
||||
if (v == null) return '—';
|
||||
const u = UNIT[metric] || 'ratio';
|
||||
if (u === 'pct') return pct(v, 2);
|
||||
if (u === 'int') return num(v, 0) + (metric.endsWith('_years') ? ' 年' : '');
|
||||
if (u === 'price') return num(v, 2) + ' 元';
|
||||
return num(v, 2); // ratio:倍数/覆盖
|
||||
};
|
||||
const meta = [
|
||||
`画像窗口 ${num(g.window_years, 0)} 年`,
|
||||
g.on_unverifiable === 'pass'
|
||||
? '数据缺失/样本不足时:放行'
|
||||
: '数据缺失/样本不足时:保守不买(REJECT)',
|
||||
g.min_window_coverage ? `最小窗口覆盖率 ${pct(g.min_window_coverage, 0)}` : null,
|
||||
].filter(Boolean).join(' · ');
|
||||
return `
|
||||
<div class="card">
|
||||
<h2>个股画像筛选条件
|
||||
<span class="badge ${g.enabled ? 'info' : 'dim'}">${g.enabled ? '已启用' : '未启用'}</span></h2>
|
||||
<div class="callout">
|
||||
这是<b>买入的第二道闸门</b>:股息率分位触发买入之后,用<b>当日可见的数据</b>
|
||||
重算一次个股画像,再逐条核验,不通过的票直接剔除
|
||||
(信号类型 <code class="mono">REJECT</code>,每条规则当时的实际值与阈值可在下方
|
||||
「未成交信号与原因」里看到)。<b>取不到值不等于通过</b>,按下方「数据缺失」口径处理。
|
||||
</div>
|
||||
<div class="toolbar"><span class="small muted">${esc(meta)}</span></div>
|
||||
<div class="tw"><table class="data">
|
||||
<thead><tr><th class="l">#</th><th class="l">画像指标</th>
|
||||
<th class="l">核验口径</th><th class="l">必须满足</th></tr></thead>
|
||||
<tbody>${rules.map((r, i) => `<tr>
|
||||
<td class="num">${i + 1}</td>
|
||||
<td class="l">${esc(LABEL[r.metric] || r.metric)}</td>
|
||||
<td class="l small">${esc(STAT[r.stat] || r.stat || '—')}</td>
|
||||
<td class="l mono">${esc(OP[r.op] || r.op || '')} ${esc(fmtVal(r.metric, r.value))}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function viewBacktestDetail(runId) {
|
||||
const b = await api(`backtests/${runId}`);
|
||||
const m = {};
|
||||
@@ -681,6 +778,18 @@ async function viewBacktestDetail(runId) {
|
||||
<div id="pf-body"><div class="loading">加载中…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="closed-card">
|
||||
<h2>已清仓了结 <span class="badge info" id="closed-count">…</span></h2>
|
||||
<div class="callout">
|
||||
期末<b>不再持有</b>的个股(已全部卖出)。<b>卖出后至今</b>是清仓日收盘到最新收盘的涨跌:
|
||||
<b class="loss">跌</b>说明这笔卖对了,<b class="gain">涨</b>说明可能卖早了。
|
||||
<b>收益率</b> = 已实现盈亏 ÷ 买入金额,即这笔投资的累计收益率(不是年化;
|
||||
已清仓,所以全部盈亏都已落袋)。
|
||||
点代码可打开该股在本次回测中的走势图(默认已经画到最新行情)。
|
||||
</div>
|
||||
<div id="closed-body"><div class="loading">加载中…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>回测条件</h2>
|
||||
<div class="tw"><table class="data">
|
||||
@@ -691,6 +800,8 @@ async function viewBacktestDetail(runId) {
|
||||
${b.strategy.description ? `<div class="callout" style="margin-top:12px">${esc(b.strategy.description)}</div>` : ''}
|
||||
</div>
|
||||
|
||||
${profileGateCard(b.strategy.profile_gate)}
|
||||
|
||||
<div class="card">
|
||||
<h2>逐笔成交与理由 <span class="badge info" id="trade-count">…</span></h2>
|
||||
<div class="callout">
|
||||
@@ -700,6 +811,30 @@ async function viewBacktestDetail(runId) {
|
||||
<div id="trades"><div class="loading">加载中…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="du-card" style="display:none">
|
||||
<h2>每日动态股票池 <span class="badge info" id="du-badge">…</span></h2>
|
||||
<div class="callout">
|
||||
本模式<b>每个交易日</b>按当时可见数据重建股票池(PIT)。
|
||||
每个决策日的入选成员都留了痕,可回答「某天为什么是这些股票」。
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<span class="small muted">决策日</span>
|
||||
<select id="du-date" style="width:auto"></select>
|
||||
<span class="small muted" id="du-note"></span>
|
||||
</div>
|
||||
<div id="du-body"><div class="loading">加载中…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="tradeprof-card" style="display:none">
|
||||
<h2>成交个股的实时画像 <span class="badge info" id="tradeprof-badge">…</span></h2>
|
||||
<div class="callout">
|
||||
每一笔成交,都附上<b>该笔决策当天、只用当时可见数据</b>算出的个股画像(PIT)。
|
||||
这与「个股画像」页的批量画像同一定义,只是按决策日取值 ——
|
||||
它回答的是「当时凭什么买/卖」。点代码可进入个股页看完整曲线与全部画像指标。
|
||||
</div>
|
||||
<div id="tradeprof"><div class="loading">加载中…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="skips-card" style="display:none">
|
||||
<h2>未成交信号与原因</h2>
|
||||
<div class="callout warn">这些信号「想交易但没成交」,用于判断约束是否实质影响绩效。</div>
|
||||
@@ -824,6 +959,8 @@ async function mountBacktestDetail(runId) {
|
||||
|
||||
const t = await api(`backtests/${runId}/trades?size=200`);
|
||||
document.getElementById('trade-count').textContent = t.total + ' 笔';
|
||||
// 成交个股的实时画像(仅 daily 模式有留痕;无留痕时整张卡片不显示)
|
||||
renderTradedProfiles(runId, t.items);
|
||||
const host = document.getElementById('trades');
|
||||
if (!t.items.length) { host.innerHTML = '<div class="empty">无成交记录</div>'; }
|
||||
else {
|
||||
@@ -845,6 +982,14 @@ async function mountBacktestDetail(runId) {
|
||||
${t.total > t.items.length ? `<div class="pager">仅显示前 ${t.items.length} / ${t.total} 笔</div>` : ''}`;
|
||||
}
|
||||
|
||||
// 已清仓了结清单(期末不再持有的个股 + 清仓后至今涨跌)
|
||||
try {
|
||||
renderClosedPositions(runId, await api(`backtests/${runId}/closed-positions`));
|
||||
} catch (e) {
|
||||
const host = document.getElementById('closed-body');
|
||||
if (host) host.innerHTML = `<div class="callout fail">加载失败:${esc(e.message)}</div>`;
|
||||
}
|
||||
|
||||
try {
|
||||
const sk = await api(`backtests/${runId}/signals`);
|
||||
if (sk.items && sk.items.length) {
|
||||
@@ -860,6 +1005,178 @@ async function mountBacktestDetail(runId) {
|
||||
</tbody></table></div>`;
|
||||
}
|
||||
} catch (e) { /* 未成交信号是可选信息,失败不影响主流程 */ }
|
||||
|
||||
try { await mountDailyUniverse(runId); }
|
||||
catch (e) { /* 每日动态股票池只在 --mode daily 存在,失败不影响主流程 */ }
|
||||
}
|
||||
|
||||
/* ---------------- 回测详情:成交个股的实时画像(--mode daily) ---------------- */
|
||||
|
||||
/** 把每笔成交的 `reason.profile` 汇总成一张表。
|
||||
*
|
||||
* 数据源:`GET /api/backtests/{id}/trades` 的 `items[].reason.profile`
|
||||
* (由引擎在决策当日写入,不是事后重算)。非 daily 模式或关闭了
|
||||
* `daily.profile_on_trade` 时没有任何留痕,整张卡片不显示。
|
||||
*/
|
||||
/** 已清仓了结清单。清仓复盘真正要回答的是「这笔卖对了没有」。 */
|
||||
function renderClosedPositions(runId, cp) {
|
||||
const host = document.getElementById('closed-body');
|
||||
const badge = document.getElementById('closed-count');
|
||||
if (!host) return;
|
||||
const s = cp.summary || {};
|
||||
if (badge) badge.textContent = `${num(s.count, 0)} 只`;
|
||||
if (!cp.items || !cp.items.length) {
|
||||
host.innerHTML = '<div class="empty">本次回测没有已清仓的个股'
|
||||
+ '(期末仍持有的都在上方「持仓明细」里)</div>';
|
||||
return;
|
||||
}
|
||||
host.innerHTML = `
|
||||
<div class="toolbar">
|
||||
<span class="small muted">共 <b>${num(s.count, 0)}</b> 只 · 合计已实现
|
||||
<b class="${sign(s.realized_pnl)}">${yi(s.realized_pnl)}</b> ·
|
||||
清仓后<b class="gain">上涨 ${num(s.since_sell_up, 0)}</b> /
|
||||
<b class="loss">下跌 ${num(s.since_sell_down, 0)}</b> 只
|
||||
${s.asof ? `· 价格截至 <span class="mono">${esc(s.asof)}</span>` : ''}</span>
|
||||
</div>
|
||||
<div class="tw"><table class="data">
|
||||
<thead><tr><th class="l">代码</th><th class="l">名称</th><th class="l">行业</th>
|
||||
<th class="l">持仓区间</th><th>持有天数</th>
|
||||
<th>买入金额</th><th>卖出金额</th>
|
||||
<th>已实现盈亏</th><th>收益率</th>
|
||||
<th>清仓日</th><th>卖出后至今</th>
|
||||
<th class="l">清仓理由</th></tr></thead>
|
||||
<tbody>${cp.items.map(x => `<tr>
|
||||
<td class="l"><a href="#/backtests/${esc(runId)}/stocks/${esc(x.symbol)}"
|
||||
class="mono">${esc(x.symbol)}</a></td>
|
||||
<td class="l">${esc(x.name || '—')}</td>
|
||||
<td class="l small">${esc(x.industry || '—')}</td>
|
||||
<td class="l num small">${esc(x.first_hold || '—')} ~ ${esc(x.last_hold || '—')}</td>
|
||||
<td class="num">${num(x.hold_days, 0)}</td>
|
||||
<td class="num">${num(x.buy_amount, 0)}</td>
|
||||
<td class="num">${num(x.sell_amount, 0)}</td>
|
||||
<td class="num ${sign(x.realized_pnl)}">${num(x.realized_pnl, 0)}</td>
|
||||
<td class="num ${sign(x.return_pct)}">${pct(x.return_pct)}</td>
|
||||
<td class="num">${esc(x.last_sell || '—')}</td>
|
||||
<td class="num ${sign(x.since_sell_pct)}">${pct(x.since_sell_pct)}</td>
|
||||
<td class="l small reason" title="${esc(x.sell_reason || '')}">${esc(x.sell_reason || '—')}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
</table></div>`;
|
||||
}
|
||||
|
||||
function renderTradedProfiles(runId, items) {
|
||||
const card = document.getElementById('tradeprof-card');
|
||||
const host = document.getElementById('tradeprof');
|
||||
if (!card || !host) return;
|
||||
const rows = (items || []).filter(t => t.reason && t.reason.profile);
|
||||
if (!rows.length) return; // 无留痕 → 不显示该卡片
|
||||
card.style.display = '';
|
||||
const syms = new Set(rows.map(t => t.symbol));
|
||||
const badge = document.getElementById('tradeprof-badge');
|
||||
if (badge) badge.textContent = `${syms.size} 只 / ${rows.length} 笔成交`;
|
||||
|
||||
const numOr = (v, u) => (v === null || v === undefined ? '—' : fmtUnit(v, u));
|
||||
host.innerHTML = `<div class="tw"><table class="data">
|
||||
<thead><tr><th class="l">代码</th><th>信号日</th><th>成交日</th><th>方向</th>
|
||||
<th>股息率</th><th>股息率分位</th><th>PE(TTM)</th><th>PB</th>
|
||||
<th>ROE(5年均)</th><th>连续分红</th><th>支付率</th><th>FCF覆盖</th>
|
||||
<th>当日池内</th></tr></thead>
|
||||
<tbody>${rows.map(t => {
|
||||
const p = t.reason.profile || {};
|
||||
const v = p.values || {};
|
||||
const pc = p.percentiles || {};
|
||||
const inPool = t.reason.in_universe;
|
||||
return `<tr>
|
||||
<td class="l"><a class="mono"
|
||||
href="#/backtests/${esc(runId)}/stocks/${esc(t.symbol)}">${esc(t.symbol)}</a></td>
|
||||
<td class="num">${esc(t.signal_date)}</td>
|
||||
<td class="num">${esc(t.execution_date)}</td>
|
||||
<td><span class="badge ${t.side === 'BUY' ? 'gain' : 'loss'}">${t.side === 'BUY' ? '买入' : '卖出'}</span></td>
|
||||
<td class="num">${numOr(v.dv_yield, 'pct')}</td>
|
||||
<td class="num">${pc.dv_yield == null ? '—' : num(pc.dv_yield, 1) + '%'}</td>
|
||||
<td class="num">${numOr(v.pe_ttm, 'ratio')}</td>
|
||||
<td class="num">${numOr(v.pb, 'ratio')}</td>
|
||||
<td class="num">${numOr(v.roe_avg, 'pct')}</td>
|
||||
<td class="num">${numOr(v.dividend_continuity_years, 'int')} 年</td>
|
||||
<td class="num">${numOr(v.payout_ratio, 'pct')}</td>
|
||||
<td class="num">${v.fcf_dividend_cover == null ? '—'
|
||||
: num(v.fcf_dividend_cover, 2) + 'x'}</td>
|
||||
<td>${inPool === undefined || inPool === null ? '—'
|
||||
: (inPool ? '<span class="badge ok">在</span>'
|
||||
: '<span class="badge warn">已出</span>')}</td></tr>`;
|
||||
}).join('')}</tbody></table></div>
|
||||
<div class="callout" style="margin-top:12px">
|
||||
「当日池内 = 已出」表示该笔发生在持仓<b>掉出当日股票池之后</b>
|
||||
(<code>pool_exit_action: hold</code> 下只减不加 / 按分位卖出)。
|
||||
每行的数值都取自该笔成交的 <code>reason_json.profile</code>,可用 SQL 逐条复核。
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ---------------- 回测详情:每日动态股票池(--mode daily) ---------------- */
|
||||
|
||||
const duState = {runId: null, timeline: [], date: null};
|
||||
|
||||
async function mountDailyUniverse(runId) {
|
||||
const card = document.getElementById('du-card');
|
||||
if (!card) return;
|
||||
const tl = await api(`backtests/${runId}/daily-universe`);
|
||||
if (!tl.available || !tl.timeline || !tl.timeline.length) return; // 非 daily 模式
|
||||
duState.runId = runId;
|
||||
duState.timeline = tl.timeline;
|
||||
card.style.display = '';
|
||||
|
||||
const sizes = tl.timeline.map(x => x.member_count);
|
||||
const uniq = new Set();
|
||||
document.getElementById('du-badge').textContent =
|
||||
`${tl.timeline.length} 个有成员的决策日 · 池内 ${Math.min(...sizes)}~${Math.max(...sizes)} 只`;
|
||||
|
||||
const sel = document.getElementById('du-date');
|
||||
sel.innerHTML = tl.timeline.map(x =>
|
||||
`<option value="${esc(x.trade_date)}"${x.trade_date === tl.trade_date ? ' selected' : ''}>`
|
||||
+ `${esc(x.trade_date)}(${x.member_count} 只)</option>`).join('');
|
||||
sel.addEventListener('change', () => loadDailyUniverse(sel.value));
|
||||
duState.date = tl.trade_date;
|
||||
await loadDailyUniverse(duState.date);
|
||||
}
|
||||
|
||||
async function loadDailyUniverse(date) {
|
||||
const host = document.getElementById('du-body');
|
||||
const note = document.getElementById('du-note');
|
||||
if (!host) return;
|
||||
host.innerHTML = '<div class="loading">加载中…</div>';
|
||||
const d = await api(`backtests/${duState.runId}/daily-universe?date=${encodeURIComponent(date)}`);
|
||||
const items = d.members || [];
|
||||
if (note) {
|
||||
const row = duState.timeline.find(x => x.trade_date === date) || {};
|
||||
note.textContent = row.listed_count
|
||||
? `市场候选 ${row.listed_count} 只 → 实际筛选 ${row.candidate_count ?? '—'} 只 → 入选 ${items.length} 只`
|
||||
: '';
|
||||
}
|
||||
if (!items.length) { host.innerHTML = '<div class="empty">该日无入选成员</div>'; return; }
|
||||
host.innerHTML = `<div class="tw"><table class="data">
|
||||
<thead><tr><th class="l">代码</th><th class="l">名称</th><th class="l">行业</th>
|
||||
<th>股息率</th><th>PE(TTM)</th><th>PB</th><th>总市值</th>
|
||||
<th>5年ROE</th><th>连续分红</th><th>支付率</th><th>FCF覆盖</th></tr></thead>
|
||||
<tbody>${items.map(x => {
|
||||
const v = x.values || {};
|
||||
return `<tr>
|
||||
<td class="l mono">${esc(x.symbol)}</td>
|
||||
<td class="l">${esc(x.name || '—')}</td>
|
||||
<td class="l small">${esc(x.industry || '—')}</td>
|
||||
<td class="num">${pct(x.dividend_yield)}</td>
|
||||
<td class="num">${num(v.pe_ttm)}</td>
|
||||
<td class="num">${num(v.pb)}</td>
|
||||
<td class="num">${yi(x.total_mv)}</td>
|
||||
<td class="num">${pct(x.roe_avg)}</td>
|
||||
<td class="num">${num(v.dividend_continuity_years,0)} 年</td>
|
||||
<td class="num">${pct(v.payout_ratio)}</td>
|
||||
<td class="num">${num(v.fcf_dividend_cover)}x</td></tr>`;
|
||||
}).join('')}
|
||||
</tbody></table></div>
|
||||
<div class="callout" style="margin-top:12px">
|
||||
「股息率」是<b>筛选口径</b>(<code>universe.yml: yield_source</code>,默认自算 TTM);
|
||||
PE/PB 来自 <code>daily_basic</code>;5 年 ROE、支付率、FCF 覆盖来自年报(PIT:
|
||||
只用当时已公告的财报)。
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ---------------- 回测详情:持仓明细 ---------------- */
|
||||
@@ -958,7 +1275,8 @@ function shiftPortfolio(delta) {
|
||||
|
||||
/* ---------------- 视图:回测内个股买卖点 ---------------- */
|
||||
|
||||
const stState = {runId: null, symbol: null, data: null, shown: {}};
|
||||
const stState = {runId: null, symbol: null, data: null, shown: {},
|
||||
from: '', to: '', seq: 0, rtDone: false};
|
||||
|
||||
async function viewBacktestStock(runId, symbol) {
|
||||
let d;
|
||||
@@ -994,25 +1312,47 @@ async function viewBacktestStock(runId, symbol) {
|
||||
|
||||
<div class="card">
|
||||
<h2>趋势与买卖点</h2>
|
||||
<div class="toolbar">
|
||||
<span class="small muted">区间</span>
|
||||
<input type="date" id="st-from" style="width:auto">
|
||||
<span class="small muted">~</span>
|
||||
<input type="date" id="st-to" style="width:auto">
|
||||
<button data-act="st-apply">应用</button>
|
||||
<button data-act="st-reset">重置</button>
|
||||
<span class="small muted" id="st-range-hint"></span>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<span class="small muted">显示指标(勾几项就是几联图,自上而下排列):</span>
|
||||
${d.available_series.map(k => `<label class="check">
|
||||
<input type="checkbox" class="st-ser" value="${k}"
|
||||
${['close','dv_yield','pe_ttm','roe'].includes(k) ? 'checked' : ''}>
|
||||
${esc(SERIES_LABEL[k] || k)}</label>`).join('')}
|
||||
<button data-act="st-apply" style="margin-left:auto">应用</button>
|
||||
</div>
|
||||
<div id="c_stock" class="chart"></div>
|
||||
<div id="c_stock_note"></div>
|
||||
<div class="callout" style="margin-top:12px">
|
||||
每个指标<b>独占一个面板</b>(N 联图):时间轴、缩放与十字光标上下联动,
|
||||
便于对照同一时点的估值与质量读数。<b>▲ 买入 / ▼ 卖出</b> 同时标注在<b>每个面板</b>上,
|
||||
位置取该指标在成交日的取值;鼠标悬停可一次看全部指标与成交的价格、股数、金额。
|
||||
位置取该指标在成交日的取值;鼠标悬停可一次看全部指标与成交的价格、股数、金额。<br/>
|
||||
默认区间 = 首笔成交<b>往前 5 年</b>(股息率分位的判据窗口)→ <b>该股最新行情</b>:
|
||||
往前留够判据,才能回答「当时凭什么买」;一直画到今天,
|
||||
才能回答「卖飞了还是卖对了」。区间可自由改。
|
||||
股息率为 PIT-TTM 口径(TTM 每股分红 ÷ 不复权收盘价),
|
||||
ROE 按<b>公告日</b>对齐成阶梯线(不插值,避免未来函数)。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="rt-profile-card">
|
||||
<h2>决策时点实时画像 <span class="badge info" id="rt-badge">…</span></h2>
|
||||
<div class="callout">
|
||||
这是<b>每个买卖决策当天、只用当时可见数据</b>算出的个股画像(PIT)。
|
||||
与「个股画像」页的批量画像同一定义,但按<b>决策日</b>取值 ——
|
||||
它回答的是「当时凭什么买/卖」,而不是「今天回头看它长什么样」。
|
||||
数值来自成交记录的 <code>reason_json.profile</code>,可用 SQL 逐条复核。
|
||||
</div>
|
||||
<div id="rt-profile"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>逐笔成交明细 <span class="badge info">${st.trade_count}</span></h2>
|
||||
${st.trade_count ? `<div class="tw"><table class="data">
|
||||
@@ -1036,6 +1376,62 @@ async function viewBacktestStock(runId, symbol) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** 决策时点实时画像:把成交记录里的 reason_json.profile 渲染成表。
|
||||
*
|
||||
* 数据来自 `GET /api/backtests/{id}/stocks/{symbol}` 的 trades[].reason.profile,
|
||||
* 即引擎在决策当日算出的 PIT 画像快照 —— 不是事后重算。
|
||||
*/
|
||||
function renderRtProfile(trades) {
|
||||
const rows = (trades || []).filter(t => t.reason && t.reason.profile);
|
||||
const host = document.getElementById('rt-profile');
|
||||
const badge = document.getElementById('rt-badge');
|
||||
if (!host) return;
|
||||
if (badge) badge.textContent = rows.length ? rows.length + ' 个决策时点' : '无留痕';
|
||||
if (!rows.length) {
|
||||
host.innerHTML = '<div class="empty">本次回测的成交记录里没有画像留痕'
|
||||
+ '(仅 <code>--mode daily</code> 且 <code>daily.profile_on_trade: true</code> 时记录)</div>';
|
||||
return;
|
||||
}
|
||||
// 优先展示的判据指标(与闸门规则同一批),其余放在展开区
|
||||
const PRIMARY = ['dv_yield', 'dv_yield_pct', 'pe_ttm', 'pb', 'roe_avg',
|
||||
'dividend_continuity_years', 'payout_ratio', 'fcf_dividend_cover'];
|
||||
host.innerHTML = rows.map(t => {
|
||||
const p = t.reason.profile || {};
|
||||
const vals = p.values || {};
|
||||
const pcts = p.percentiles || {};
|
||||
const cells = PRIMARY.map(k => {
|
||||
if (k === 'dv_yield_pct') {
|
||||
return `<td class="num">${pcts.dv_yield == null ? '—' : num(pcts.dv_yield, 1) + '%'}</td>`;
|
||||
}
|
||||
const u = UNIT[k] || 'ratio';
|
||||
return `<td class="num">${fmtUnit(vals[k], u)}</td>`;
|
||||
}).join('');
|
||||
// 其余指标(全历史/各窗口的完整快照)折叠展示,避免默认刷屏
|
||||
const others = Object.keys(vals).filter(k => !PRIMARY.includes(k) && k !== 'dv_yield_pct')
|
||||
.sort().map(k => `<span class="mono small" style="margin-right:10px">`
|
||||
+ `${esc(LABEL[k] || k)}=${fmtUnit(vals[k], UNIT[k] || 'ratio')}</span>`).join('');
|
||||
const gate = t.reason.profile_gate;
|
||||
const gateTxt = gate
|
||||
? `<span class="badge ${gate.verdict === 'PASS' ? 'ok' : 'warn'}">闸门 ${esc(gate.verdict)}</span>`
|
||||
: '';
|
||||
return `<div style="border-top:1px solid var(--line);padding:10px 0">
|
||||
<div class="small" style="margin-bottom:6px">
|
||||
<b>${esc(t.signal_date)}</b>
|
||||
<span class="badge ${t.side === 'BUY' ? 'gain' : 'loss'}">${t.side === 'BUY' ? '买入' : '卖出'}</span>
|
||||
${gateTxt}
|
||||
<span class="muted">画像窗口 ${esc(String(p.window_years ?? '—'))} 年 · 决策日 ${esc(p.asof || '—')}</span>
|
||||
</div>
|
||||
<div class="tw"><table class="data"><thead><tr>
|
||||
${PRIMARY.map(k => `<th>${esc(k === 'dv_yield_pct' ? '股息率分位'
|
||||
: (LABEL[k] || k))}</th>`).join('')}
|
||||
</tr></thead><tbody><tr>${cells}</tr></tbody></table></div>
|
||||
${others ? `<details style="margin-top:6px"><summary class="small muted">
|
||||
展开该决策日的全部 ${Object.keys(vals).length} 项画像指标</summary>
|
||||
<div style="margin-top:6px">${others}</div></details>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const SERIES_LABEL = {close: '股价', dv_yield: '股息率', pe_ttm: 'PE(TTM)',
|
||||
pb: 'PB', roe: 'ROE', drawdown: '回撤'};
|
||||
// 面板排列顺序:股价永远在首位(买卖点以成交价标注),其余按 估值 → 质量 → 风险 排。
|
||||
@@ -1067,10 +1463,18 @@ function mountBacktestStock(runId, symbol) {
|
||||
return;
|
||||
}
|
||||
host.innerHTML = '<div class="loading">加载中…</div>';
|
||||
// 区间留空 = 用后端缺省区间(持仓起点 → 该股最新行情)
|
||||
const range = (stState.from ? `&start=${encodeURIComponent(stState.from)}` : '')
|
||||
+ (stState.to ? `&end=${encodeURIComponent(stState.to)}` : '');
|
||||
const seq = ++stState.seq;
|
||||
const d = await api(`backtests/${runId}/stocks/${encodeURIComponent(symbol)}`
|
||||
+ `?series=${shown.join(',')}`);
|
||||
+ `?series=${shown.join(',')}${range}`);
|
||||
if (seq !== stState.seq) return; // 连续改区间时丢掉过期响应
|
||||
stState.data = d;
|
||||
// 决策时点实时画像与 K 线无关(不随 series/区间变化),只在本次挂载渲染一次
|
||||
if (!stState.rtDone) { renderRtProfile(d.trades); stState.rtDone = true; }
|
||||
host.innerHTML = '';
|
||||
syncStockRange(d.range);
|
||||
paintStockPanels(host, d, shown);
|
||||
};
|
||||
render().catch(e => {
|
||||
@@ -1082,6 +1486,24 @@ function mountBacktestStock(runId, symbol) {
|
||||
return render;
|
||||
}
|
||||
|
||||
/** 把实际生效的区间写回日期框,并把该股行情边界设成 min/max。 */
|
||||
function syncStockRange(r) {
|
||||
const from = document.getElementById('st-from');
|
||||
const to = document.getElementById('st-to');
|
||||
if (from && to) {
|
||||
if (r.available_start) { from.min = r.available_start; to.min = r.available_start; }
|
||||
if (r.available_end) { from.max = r.available_end; to.max = r.available_end; }
|
||||
from.value = r.start || '';
|
||||
to.value = r.end || '';
|
||||
}
|
||||
const hint = document.getElementById('st-range-hint');
|
||||
if (hint) {
|
||||
hint.textContent = `${r.start} ~ ${r.end} · ${r.points} 个交易日`
|
||||
+ (r.downsampled ? '(已降采样显示)' : '')
|
||||
+ (r.available_end ? ` · 该股行情至 ${r.available_end}` : '');
|
||||
}
|
||||
}
|
||||
|
||||
/** 把选中的指标画成 N 联图:每个指标一个 grid,共用一个时间轴与缩放。 */
|
||||
function paintStockPanels(host, d, shown) {
|
||||
const N = shown.length;
|
||||
@@ -1100,20 +1522,9 @@ function paintStockPanels(host, d, shown) {
|
||||
tradesOn.get(t.execution_date).push(t);
|
||||
});
|
||||
|
||||
// 成交日可能落在价格区间之外(实测 000338.SZ 的卖出在区间最后一天之后一天),
|
||||
// 直接用日期当类目会把这笔成交整笔丢掉。这里把这些日期按序补进横轴,
|
||||
// 让股价面板仍能按成交价标出买卖点。
|
||||
const extraDates = [...new Set(d.trades.map(t => t.execution_date))]
|
||||
.filter(dt => !dateIndex.has(dt)).sort();
|
||||
const axisDates = d.dates.slice();
|
||||
extraDates.forEach(dt => {
|
||||
let lo = 0, hi = axisDates.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (axisDates[mid] < dt) lo = mid + 1; else hi = mid;
|
||||
}
|
||||
axisDates.splice(lo, 0, dt);
|
||||
});
|
||||
// 区间外的成交不占横轴类目(用户把区间收窄时,把 2021 年的买卖点塞进
|
||||
// 2024 年的横轴会把图彻底搞乱),只用下方文字提示它们的存在。
|
||||
const outside = d.trades.filter(t => !dateIndex.has(t.execution_date));
|
||||
|
||||
const titles = [], grids = [], xAxis = [], yAxis = [], series = [];
|
||||
shown.forEach((k, i) => {
|
||||
@@ -1122,7 +1533,7 @@ function paintStockPanels(host, d, shown) {
|
||||
const top = gridTop(i);
|
||||
grids.push({left: 76, right: 26, top, height: PANEL_H});
|
||||
xAxis.push({
|
||||
type: 'category', data: axisDates, gridIndex: i,
|
||||
type: 'category', data: d.dates, gridIndex: i,
|
||||
// 两端留 1% 空隙:首/末成交日的三角标不会被画到 grid 外面切掉
|
||||
boundaryGap: ['1%', '1%'],
|
||||
axisTick: {show: false},
|
||||
@@ -1153,28 +1564,22 @@ function paintStockPanels(host, d, shown) {
|
||||
});
|
||||
series.push({
|
||||
name: SERIES_LABEL[k] || k, type: 'line', xAxisIndex: i, yAxisIndex: i,
|
||||
// 指标序列按补过日期的横轴对齐(多出来的位置为 null,折线自然断开)
|
||||
data: k === 'close' && !extraDates.length
|
||||
? vals : axisDates.map(dt => {
|
||||
const j = dateIndex.get(dt);
|
||||
return j === undefined ? null : vals[j];
|
||||
}),
|
||||
showSymbol: false, sampling: 'lttb', z: 5,
|
||||
data: vals, showSymbol: false, sampling: 'lttb', z: 5,
|
||||
lineStyle: {width: k === 'close' ? 1.6 : 1.3, color},
|
||||
itemStyle: {color},
|
||||
...(k === 'dv_yield' ? {areaStyle: {opacity: 0.08, color}} : {}),
|
||||
});
|
||||
// 买卖点画在**每个**面板上:取该指标在成交日的取值,
|
||||
// 这样能直接看出「买在多少股息率 / 多少 PE」。
|
||||
// 股价面板用成交价(含滑点),与「▲▼ 标在成交价上」一致;
|
||||
// 区间外的成交日只有成交价、没有指标值,因此只画在股价面板。
|
||||
// 股价面板用成交价(含滑点),与「▲▼ 标在成交价上」一致。
|
||||
[['BUY', '买入', COLORS[4], 'triangle', k === 'close' ? 12 : 8],
|
||||
['SELL', '卖出', COLORS[3], 'diamond', k === 'close' ? 12 : 8]]
|
||||
.forEach(([side, cn, c, sym, size]) => {
|
||||
const pts = d.trades.filter(t => t.side === side).map(t => {
|
||||
if (k === 'close') return t.price == null ? null : [t.execution_date, t.price];
|
||||
const j = dateIndex.get(t.execution_date);
|
||||
const v = j === undefined ? null : vals[j];
|
||||
if (j === undefined) return null; // 区间外:见下方文字提示
|
||||
if (k === 'close') return t.price == null ? null : [t.execution_date, t.price];
|
||||
const v = vals[j];
|
||||
return v == null ? null : [t.execution_date, v];
|
||||
}).filter(Boolean);
|
||||
if (!pts.length) return;
|
||||
@@ -1229,17 +1634,16 @@ function paintStockPanels(host, d, shown) {
|
||||
series,
|
||||
});
|
||||
|
||||
// 区间外的成交只画得出股价面板,明确说明,避免读者以为图上没卖点就是没卖过
|
||||
// 区间外的成交画不出来(横轴上没有那一天),必须说明,
|
||||
// 否则读者会以为「图上没有卖点 = 这笔没卖过」
|
||||
const note = document.getElementById('c_stock_note');
|
||||
if (note) {
|
||||
const items = extraDates.map(dt => (tradesOn.get(dt) || []).map(t =>
|
||||
`${dt} ${t.side === 'BUY' ? '买入' : '卖出'} ${num(t.price, 3)} 元`).join('、'))
|
||||
.filter(Boolean);
|
||||
const items = outside.map(t =>
|
||||
`${t.execution_date} ${t.side === 'BUY' ? '买入' : '卖出'} ${num(t.price, 3)} 元`);
|
||||
note.innerHTML = items.length ? `<div class="callout warn" style="margin-top:12px">
|
||||
有 ${items.length} 笔成交发生在指标区间(${esc(d.range.start)} ~ ${esc(d.range.end)})之外:
|
||||
${esc(items.join(';'))}。<br/>
|
||||
这些成交只能按<b>成交价</b>标在「股价」面板上,
|
||||
股息率 / PE 等面板没有对应日期的取值,因此不标注(悬停对应日期仍可看到成交信息)。
|
||||
有 ${items.length} 笔成交不在所选区间(${esc(d.range.start)} ~ ${esc(d.range.end)})内,
|
||||
因此没有画在图上:${esc(items.join(';'))}。<br/>
|
||||
把区间放宽(或点「重置」)即可看到这些买卖点。
|
||||
</div>` : '';
|
||||
}
|
||||
}
|
||||
@@ -1453,7 +1857,11 @@ async function viewArchive() {
|
||||
}
|
||||
|
||||
/* ---------------- 路由 ---------------- */
|
||||
let currentPath = '';
|
||||
// 「已渲染路径」缓存。哨兵值必须是 null,**不能是空串**:首页(hash 为空)解析出的
|
||||
// path 正是 '',若用 '' 表示「还没渲染」,render() 会在首页一开始就
|
||||
// `path === currentPath` 提前返回 —— 概览页永远停在 index.html 里那句「加载中…」。
|
||||
// 同理,所有「强制重渲染」的地方一律写 currentPath = null。
|
||||
let currentPath = null;
|
||||
function parseHash() {
|
||||
const raw = location.hash.replace(/^#/, '') || '/';
|
||||
const [path, qs] = raw.split('?');
|
||||
@@ -1497,6 +1905,8 @@ async function render() {
|
||||
else if (parts[0] === 'backtests' && parts.length === 4 && parts[2] === 'stocks') {
|
||||
html = await viewBacktestStock(parts[1], parts[3]);
|
||||
after = () => { stState.runId = parts[1]; stState.symbol = parts[3];
|
||||
stState.from = ''; stState.to = ''; // 换股票回到默认区间
|
||||
stState.rtDone = false; // 换股票要重渲染画像留痕
|
||||
mountBacktestStock(parts[1], parts[3]); };
|
||||
}
|
||||
else if (parts[0] === 'backtests' && parts.length === 2) {
|
||||
@@ -1545,43 +1955,70 @@ document.addEventListener('click', async e => {
|
||||
if (scope === 'b' && cur.universe_run_id)
|
||||
location.hash = `#/universes/${cur.universe_run_id}`;
|
||||
else if (scope === 'u' && location.hash.includes(id))
|
||||
{ currentPath=''; render(); }
|
||||
else { currentPath=''; render(); }
|
||||
{ currentPath=null; render(); }
|
||||
else { currentPath=null; render(); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (act === 'archive') {
|
||||
await patch(`${base}/${id}`, {archived: btn.dataset.to === '1'});
|
||||
toast(btn.dataset.to === '1' ? '已归档' : '已取消归档');
|
||||
currentPath = ''; render(); return;
|
||||
currentPath = null; render(); return;
|
||||
}
|
||||
if (act === 'delete') {
|
||||
const del = btn.dataset.to === '1';
|
||||
if (del && !confirm('确认删除这条记录?\n\n(软删除:记录会被隐藏,但数据仍完整保留,可随时恢复)')) return;
|
||||
await patch(`${base}/${id}`, {deleted: del});
|
||||
toast(del ? '已删除(可从「归档」页恢复)' : '已恢复');
|
||||
currentPath = ''; render(); return;
|
||||
currentPath = null; render(); return;
|
||||
}
|
||||
if (act === 'pf-shift') {
|
||||
shiftPortfolio(parseInt(btn.dataset.d, 10));
|
||||
return;
|
||||
}
|
||||
if (act === 'st-apply') {
|
||||
if (act === 'st-apply' || act === 'st-reset') {
|
||||
const host = document.getElementById('c_stock');
|
||||
if (host) {
|
||||
if (!host) return;
|
||||
const shown = [...document.querySelectorAll('.st-ser:checked')].map(x => x.value);
|
||||
if (!shown.length) { toast('请至少勾选一个指标', true); return; }
|
||||
if (act === 'st-reset') {
|
||||
stState.from = ''; stState.to = '';
|
||||
} else {
|
||||
const from = (document.getElementById('st-from') || {}).value || '';
|
||||
const to = (document.getElementById('st-to') || {}).value || '';
|
||||
if (from && to && from > to) {
|
||||
toast('开始日期不能晚于结束日期', true); return;
|
||||
}
|
||||
stState.from = from; stState.to = to;
|
||||
}
|
||||
disposeCharts();
|
||||
const {parts} = parseHash();
|
||||
mountBacktestStock(parts[1], parts[3]);
|
||||
return;
|
||||
}
|
||||
if (act === 'sp-apply' || act === 'sp-reset') {
|
||||
if (!spState.data) return;
|
||||
if (act === 'sp-reset') {
|
||||
spState.from = ''; spState.to = '';
|
||||
const ch = (spState.data.chart || {});
|
||||
const from = document.getElementById('sp-from');
|
||||
const to = document.getElementById('sp-to');
|
||||
if (from && ch.dates) from.value = ch.dates[0];
|
||||
if (to && ch.dates) to.value = ch.dates[ch.dates.length - 1];
|
||||
} else {
|
||||
const from = (document.getElementById('sp-from') || {}).value || '';
|
||||
const to = (document.getElementById('sp-to') || {}).value || '';
|
||||
if (from && to && from > to) { toast('开始日期不能晚于结束日期', true); return; }
|
||||
spState.from = from; spState.to = to;
|
||||
}
|
||||
paintStockMain();
|
||||
return;
|
||||
}
|
||||
if (act === 'page') {
|
||||
const {parts, q} = parseHash();
|
||||
q.set('page', btn.dataset.p);
|
||||
location.hash = `#/${parts.join('/')}?${q.toString()}`;
|
||||
currentPath = ''; render(); return;
|
||||
currentPath = null; render(); return;
|
||||
}
|
||||
} catch (err) { toast(err.message, true); }
|
||||
return;
|
||||
@@ -1595,7 +2032,7 @@ document.addEventListener('click', async e => {
|
||||
const fEl = document.getElementById('mfilter');
|
||||
if (fEl) { q.set('passed', fEl.value); q.set('page', '1'); }
|
||||
location.hash = `#/${parts.join('/')}${q.toString() ? '?' + q.toString() : ''}`;
|
||||
currentPath = ''; render(); return;
|
||||
currentPath = null; render(); return;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1615,7 +2052,7 @@ document.addEventListener('change', e => {
|
||||
if (e.target.id === 'incArch') q.set('archived', e.target.checked ? '1' : '0');
|
||||
if (e.target.id === 'incDel') q.set('deleted', e.target.checked ? '1' : '0');
|
||||
location.hash = `#/${parts.join('/')}?${q.toString()}`;
|
||||
currentPath = ''; render();
|
||||
currentPath = null; render();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1625,7 +2062,7 @@ document.addEventListener('keydown', e => {
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('hashchange', () => { currentPath = ''; render(); });
|
||||
window.addEventListener('hashchange', () => { currentPath = null; render(); });
|
||||
|
||||
/* ---------------- 启动 ---------------- */
|
||||
(async function boot() {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="高股息回测系统">
|
||||
<rect width="64" height="64" rx="14" fill="#1E40AF"/>
|
||||
<g fill="#FFFFFF">
|
||||
<rect x="15" y="36" width="8" height="13" rx="2"/>
|
||||
<rect x="28" y="27" width="8" height="22" rx="2"/>
|
||||
<rect x="41" y="16" width="8" height="33" rx="2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 363 B |
@@ -4,6 +4,10 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>高股息回测系统</title>
|
||||
<!-- 相对路径:站点挂在 /ggx/ 下时解析为 /ggx/app/favicon.svg。
|
||||
不声明的话浏览器会自动去请求站点根的 /favicon.ico,而那个位置不归本项目
|
||||
的 nginx 配置管(每次打开页面都 404,并把错误写进 nginx error_log)。 -->
|
||||
<link rel="icon" type="image/svg+xml" href="app/favicon.svg">
|
||||
<link rel="stylesheet" href="app/app.css">
|
||||
<script src="assets/echarts.min.js"></script>
|
||||
</head>
|
||||
|
||||
Reference in New Issue
Block a user