# 高股息回测系统 · 开发计划 > 版本:V1.0 > 日期:2026-10-02 > 依据:`docs/plan.md`(A股投资策略制定与回测方案 V1.0)+ 用户补充要求(数据源、YAML 配置、DB 优先、HTML 图表) > 数据基座:`~/project/qlib`(本机 MariaDB `127.0.0.1:3306/qlib` + Tushare Pro) > **状态:关键决策 D1–D7 已于 2026-10-02 确认(见 §7)。** > **实施进展见 [`docs/implementation-status.md`](implementation-status.md)** —— > 系统已端到端可运行,`plan.md §50` 的 14 项验收能力全部具备,195 项自动化测试通过。 --- # 0. 结论摘要 1. **qlib 已提供可直接使用的数据基座**:行情、复权因子、每日指标(含 `dv_ttm` 股息率)、PIT 财务指标(带 `announce_date`)、股票基本信息(含 338 只退市股)、ST 改名历史、交易日历。共 20 张表、约 2300 万行。 2. **有 6 个数据缺口必须先补齐**(见 §1.3),其中「**无分红明细表**」与「**行情仅覆盖 2019 起**」是硬阻塞项,P0a/P0b 阶段解决。 3. **Tushare 权限已实测全部可用**:`dividend` / `fina_indicator` / `cashflow` / `balancesheet` / `income` / `daily` / `adj_factor` / `daily_basic` / `index_daily` / `index_weight` / `suspend_d` / `stk_limit` 均返回 `code=0`。 4. **架构口径**:新建独立 Python 项目,**只以 qlib 的数据库为共享数据层**,不 import qlib 后端代码,qlib 项目**零代码改动**;所有新表统一 `hd_` 前缀,**只增不删**。 5. **配置外置**:筛选条件、个股特性、策略、成本、回测、报告全部 YAML 化(`config/`),代码零硬编码阈值。 6. **DB 优先**:新增 30 张 `hd_*` 表承载股票池、画像、策略、回测、交易、绩效、敏感性、报告元数据;JSON 仅用于策略归档快照与报告上下文。 7. **图表固定格式**:Jinja2 模板 + 内置 ECharts(离线、无 CDN),输入一律是 `run_id` → DB 查询,输出固定命名 HTML。 8. **实施节奏**:先用**现有 2020 起数据**跑通端到端(P0a),历史回补 2015-2018(P0b)后台长跑不阻塞开发。 --- # 1. 数据资产勘察结论 ## 1.1 qlib 现有表盘点(实测) | 表 | 行数 | 时间范围 | 关键结论 | |---|---:|---|---| | `stock` | 6,041 | 上市 1990-12 ~ 2026-09 | 5,903 只,**含 338 只已退市**,有 industry/area/market/exchange | | `stock_daily` | 7,688,126 | **2019-01-02** ~ 2026-09-04 | 5,786 只;仅 2019 起 | | `adjust_factor` | 7,922,475 | **2019-01-02** ~ 2026-09-04 | 复权因子,与行情同区间 | | `daily_basic` | 7,717,819 | **2020-01-02** ~ 2026-09-18 | pe/pe_ttm/pb/ps/ps_ttm/**dv_ratio/dv_ttm**/total_mv/circ_mv | | `financial_indicator` | 327,138 | 1990-06 ~ 2026-06 | **仅 6 个指标**(eps/roe/total_revenue/net_profit/gross_margin),但带 `announce_date` → PIT 可用 | | `stock_name_history` | 13,962 | 1990 ~ | 含 **2,831 条 ST/\*ST 记录**,可按日期还原历史 ST 状态 | | `trading_calendar` | 10,227 | 2000-01 ~ 2027-12 | 6,787 个交易日;2015-2018 有 975 个交易日 | | `index_weight` | **0** | — | **空表**(成分股权重未同步) | | `sync_log` | 19,849 | — | qlib 自身同步台账 | | `strategy` / `experiment` / `selection_result` / `signal_event` / `job` / `global_config` | 少量 | — | qlib 自身研究平台表,**不复用、不写入** | ## 1.2 可复用资产 - **Point-in-Time 财务**:`financial_indicator.announce_date` 即公告日,可直接做 `announce_date <= asof` 纪律。 - **生存者偏差**:`stock.delist_date` + `stock_name_history` + 全量退市股,可还原任意历史交易日的真实股票池。 - **ST 过滤**:`stock_name_history` 的 `name` 含 `ST/*ST` 且带 `start_date/end_date`,支持历史时点判定(而非仅看当前名)。 - **估值面板**:`daily_basic` 已是宽表因子面板,PE/PB/PS/股息率/市值可直接取用。 - **交易日历**:覆盖 2015-2027,回测排期无需自建。 - **数据源**:`.env` 中 Tushare token 有效,且**用户已授权全权使用**。 ## 1.3 数据缺口(P0 阻塞项) | 编号 | 缺口 | 影响 | 解决方式 | |---|---|---|---| | **G1** | **无分红明细表**(现金分红/送转/除权除息/股权登记日/派息日) | plan §14/§30 分红质量、分红再投资、正确复权全部无法实现 | 新建 `hd_dividend`,Tushare `dividend` 全量回补 | | **G2** | 行情 `stock_daily`/`adjust_factor` 仅 2019 起 | 无法做 plan §22「10 年」与 §23「train 5~8 年」的 Walk-forward | 回补 2015-01-01 ~ 2018-12-31(约 975 交易日 × ~3,500 只 ≈ 340 万行) | | **G3** | `daily_basic` 仅 2020 起 | 2020 前无 PE/PB/股息率/市值 | 回补 2015-01-01 ~ 2019-12-31 | | **G4** | 财务指标仅 6 项 | 缺 ROIC / FCF / 资产负债率 / 经营现金流 / 分红支付率 / 利息保障 | 新建 `hd_fina_indicator`、`hd_cashflow`、`hd_balancesheet`、`hd_income`,全历史回补 | | **G5** | 无指数行情,`index_weight` 空 | plan §28.4 基准比较(沪深300/中证红利/上证)不可用 | 新建 `hd_index_daily`,回补 `000300.SH`/`000922.CSI`/`000001.SH` 等;写入 `index_weight` 成分股 | | **G6** | 无停牌 / 涨跌停表 | 交易可行性约束只能近似(plan §29 成本与成交现实性) | 新建 `hd_suspend`、`hd_limit`,回补 `suspend_d` / `stk_limit` | ## 1.4 一次实测的接口可用性(全部 `code=0`) ```text dividend(600036.SH) 75 条 ├─ 含 div_proc/ann_date/ex_date/pay_date/cash_div_tax fina_indicator 8 条 ├─ roe/roic/grossprofit_margin/debt_to_assets/current_ratio cashflow / balancesheet / income ok daily(2015) ok ├─ 可回补 G2 index_daily(2015) ok ├─ 可回补 G5 index_basic ok ├─ 中证红利 = 000922.CSI(2008-05-26 发布) suspend_d / stk_limit ok ├─ 可回补 G6 ``` --- # 2. 总体架构 ## 2.1 目录结构 ```text 高股息回测/ ├── docs/ │ ├── plan.md # 已有(需求原文) │ ├── development-plan.md # 本文件 │ ├── data-dictionary.md # 表结构 + 字段口径(随开发更新) │ ├── methodology.md # 因子/信号/成本口径说明 │ └── decisions/ADR-*.md # 关键决策记录 ├── config/ # ★ 全部可调参数(用户可直接改) │ ├── datasource.yml # 数据库 + Tushare │ ├── universe.yml # ★ 股票筛选条件 │ ├── profile.yml # ★ 个股特性配置 │ ├── cost.yml # 交易成本模型 │ ├── backtest.yml # 回测区间 / Walk-forward / 基准 │ ├── report.yml # 图表与输出命名 │ └── strategy/ │ ├── _schema.yml # 策略字段校验口径(文档用) │ └── high_dividend_v1.yml # ★ 策略定义(版本化) ├── src/hdiv/ │ ├── core/ # 配置加载 + pydantic 校验 + 日志 + 版本指纹 │ ├── data/ │ │ ├── db.py # 连接管理;只读表白名单 + 只写 hd_ 前缀双重校验 │ │ ├── models.py # hd_* SQLAlchemy 模型 │ │ ├── ddl.py # 建表 / 增量加列(幂等,无 DROP) │ │ ├── repo.py # Point-in-Time 查询层(唯一取数出口) │ │ └── sync/ # Tushare 同步器(按 API 一文件) │ ├── universe/ # filters/(market/risk/dividend/quality)+ selector │ ├── factor/ # dividend_yield / valuation / quality / 面板工具 │ ├── profile/ # dividend / valuation / quality / risk / score │ ├── strategy/ # base / registry / dsl / rules / high_dividend │ ├── backtest/ # engine / portfolio / execution / cost / dividend / benchmark / walk_forward │ ├── analysis/ # performance / risk / trade / sensitivity / attribution │ ├── report/ # renderer(Jinja2)+ 各报告装配器 │ └── cli.py # 统一命令行入口 ├── templates/ # HTML 模板(固定格式) │ ├── base.html partials/ charts/ reports/ ├── assets/ # 内置 echarts.min.js(离线) ├── output/ # 生成的 HTML 报告(固定命名) ├── sql/ # 建表 SQL(可读副本,便于人工审查) ├── tests/ ├── .env # MYSQL_PASSWORD / TUSHARE_TOKEN(不提交) ├── .env.example └── pyproject.toml ``` ## 2.2 分层与边界 ```text config/*.yml ──► core.config(Schema 校验)──► 全模块只读引用 │ Tushare ──► data.sync ──► MariaDB(qlib) ◄─────┘ │ ┌─────────────────────┼─────────────────────┐ ▼ ▼ ▼ data.repo(PIT) hd_* 结果表 hd_report 元数据 (唯一取数出口) ▲ │ │ │ │ universe ─► factor ─► profile ─► strategy ─► backtest ─► analysis │ report.renderer ▼ output/*.html ``` **三条硬边界** 1. **数据层 ≠ 策略层**:策略代码不允许写 SQL,只能调 `data.repo`。 2. **策略 ≠ 回测引擎**:策略只产出信号(含 `reason_json`),引擎只执行信号。 3. **图表 ≠ 业务**:`report` 只做「`run_id` → SQL → 渲染」,不参与任何计算。 ## 2.3 数据库策略 - 库:沿用本机 MariaDB `127.0.0.1:3306`,schema `qlib`。 - **只读表白名单**:`stock` / `stock_daily` / `adjust_factor` / `daily_basic` / `financial_indicator` / `stock_name_history` / `trading_calendar` / `index_weight`。 `db.py` 在 SQLAlchemy 事件层对白名单表**拦截 UPDATE/DELETE**(防误写)。 - **只写前缀**:项目自身只能创建/写入 `hd_` 前缀表;`ddl.py` 拒绝任何 `DROP`/`TRUNCATE`/无 `hd_` 前缀的 DDL。 - **禁止删除**:全区无 `DELETE` 语句(架构扫描 + 单测断言);同步采用 `INSERT ... ON DUPLICATE KEY UPDATE` 幂等 upsert。 - **例外(需确认,见 §7-D2)**:G2/G3 回补写入 qlib 现有 `stock_daily` / `daily_basic` / `adjust_factor`(**纯新增历史区间,不覆盖既有行**),`index_weight` 空表填充。 --- # 3. 数据库设计(新增 30 张 `hd_*` 表) ## 3.1 表清单 **A. 数据同步层(P0)** | 表 | 用途 | 主键/唯一键 | |---|---|---| | `hd_dividend` | 分红送转全明细(G1) | `(symbol, end_date, div_proc, ann_date)` | | `hd_fina_indicator` | 扩展财务指标(G4) | `(symbol, end_date, ann_date)` | | `hd_cashflow` | 经营/投资/筹资现金流、FCF(G4) | `(symbol, end_date, ann_date)` | | `hd_balancesheet` | 资产/负债/权益(G4) | `(symbol, end_date, ann_date)` | | `hd_income` | 营收/净利润/归母净利润(G4) | `(symbol, end_date, ann_date)` | | `hd_index_daily` | 基准指数行情(G5) | `(index_code, trade_date)` | | `hd_suspend` | 停牌记录(G6) | `(symbol, trade_date)` | | `hd_limit` | 涨跌停价(G6) | `(symbol, trade_date)` | | `hd_sync_log` | 本项目同步台账 | `id` | | `hd_data_audit` | 数据完整性问题清单 | `id` | **B. 股票池层(P1)** | 表 | 用途 | |---|---| | `hd_universe_run` | 一次筛选运行:配置快照 + hash + asof + 统计 | | `hd_universe_member` | 逐股逐滤网的通过位 + 失败原因 + 命中值(长表,可回溯「为什么没选上」) | **C. 因子与画像层(P2)** | 表 | 用途 | |---|---| | `hd_factor_snapshot` | **仅在决策时点**落因子值(避免 1.5 亿行日频面板,见 §3.2) | | `hd_profile_run` | 画像运行:窗口配置 + hash + asof | | `hd_profile_stat` | 逐股逐指标的 min/max/mean/median/std/**P10/P25/P50/P75/P90** + 当前值 + 当前分位 | | `hd_profile_series` | 逐股逐指标的时间序列(供 HTML 画图,仅筛选后股票,体积可控) | | `hd_profile_score` | 安全边际分项得分(股息率/估值/财务/资产负债/分红质量) | **D. 策略层(P3)** | 表 | 用途 | |---|---| | `hd_strategy` | 策略身份:id/name/version/status(YAML 生命周期枚举)/yaml_path/config_json/config_hash/parent_id | | `hd_strategy_param` | 参数扁平化(path → value),便于跨版本 diff 与敏感性扫描 | **E. 回测层(P4/P5)** | 表 | 用途 | |---|---| | `hd_backtest_run` | 运行头:strategy_id/version、config_hash、**data_version**、区间、mode、cost_json、code_version | | `hd_backtest_equity` | 逐日净值曲线:nav/cash/持仓市值/日收益/回撤/基准点位/基准净值 | | `hd_backtest_position` | 逐日持仓:数量/成本/市值/权重/持有天数 | | `hd_backtest_trade` | 逐笔成交:**signal_date / execution_date / side / price / quantity / commission / stamp_tax / transfer_fee / slippage_cost / realized_pnl / holding_days / reason_json** | | `hd_backtest_signal` | 逐信号:类型/目标权重/触发值/分位/`reason_json`/**executed + skip_reason** | | `hd_backtest_metric` | 指标长表:`scope`(all/train/test/window_k) × `metric_code` × `benchmark_code` | | `hd_walkforward_run` | WF 运行头 | | `hd_walkforward_window` | 每个窗口:train/test 区间 + 冻结参数 + 对应 train/test run_id | **F. 分析层(P6)** | 表 | 用途 | |---|---| | `hd_sensitivity_run` | 敏感性扫描头:基准策略 + 扫描网格 | | `hd_sensitivity_point` | 每点:param_path/param_value/run_id + CAGR/MDD/Sharpe/Calmar/Turnover | **G. 报告层(P7)** | 表 | 用途 | |---|---| | `hd_report` | 报告元数据:type/html_path/title/context_json/**sql_provenance_json**/source_run_ids/generated_at | ## 3.2 关键设计决策:为什么不做全量日频因子面板 若建 `hd_factor_daily(symbol, trade_date, factor_code, value)`: `5,874 × 2,700 交易日 × 12 因子 ≈ 1.9 亿行`,成本与收益不成比例——因为 `daily_basic` **本身已是宽表因子面板**。 **方案**: - 估值类因子(PE/PB/PS/股息率/市值)→ **不落库**,由 `data.repo` 从 `daily_basic` 直接取。 - 需要新算的因子(PIT-TTM 每股分红、修正股息率、分位、z-score)→ **只在决策时点计算**,落 `hd_factor_snapshot`(asof_date 粒度)。 - 需要在图上展示的长序列 → 只对**已筛选入池的股票**(数百只)落 `hd_profile_series`。 估算:`hd_factor_snapshot` 若按月 × 200 只 × 15 因子 × 10 年 ≈ 36 万行;`hd_profile_series` ≈ 200 × 2,500 × 6 ≈ 300 万行。均可接受。 ## 3.3 关键表 DDL 要点 ```sql -- 分红明细:PIT 语义核心表 CREATE TABLE hd_dividend ( id BIGINT AUTO_INCREMENT PRIMARY KEY, symbol VARCHAR(12) NOT NULL, end_date DATE NOT NULL COMMENT '分红对应报告期', ann_date DATE NULL COMMENT '公告日(预案)', imp_ann_date DATE NULL COMMENT '实施公告日 ← PIT 关键', div_proc VARCHAR(16) NOT NULL COMMENT '预案/股东大会通过/实施/取消', stk_div DECIMAL(16,6) NULL COMMENT '每股送转股', cash_div DECIMAL(16,6) NULL COMMENT '每股税后现金分红', cash_div_tax DECIMAL(16,6) NULL COMMENT '每股税前现金分红', record_date DATE NULL, ex_date DATE NULL, pay_date DATE NULL, div_listdate DATE NULL, base_share DECIMAL(24,4) NULL, source VARCHAR(16) NOT NULL DEFAULT 'tushare', created_at DATETIME NOT NULL, UNIQUE KEY uq_hd_div (symbol, end_date, div_proc, ann_date), KEY ix_hd_div_ex (symbol, ex_date), KEY ix_hd_div_pit (symbol, imp_ann_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 成交明细:reason_json 为 AI 可解释性的关键(plan §32) CREATE TABLE hd_backtest_trade ( id BIGINT AUTO_INCREMENT PRIMARY KEY, run_id VARCHAR(32) NOT NULL, trade_id VARCHAR(32) NOT NULL, symbol VARCHAR(12) NOT NULL, signal_date DATE NOT NULL, execution_date DATE NOT NULL, side VARCHAR(4) NOT NULL, price DECIMAL(16,4) NOT NULL, quantity DECIMAL(20,4) NOT NULL, amount DECIMAL(24,4) NOT NULL, commission DECIMAL(16,4) NOT NULL DEFAULT 0, stamp_tax DECIMAL(16,4) NOT NULL DEFAULT 0, transfer_fee DECIMAL(16,4) NOT NULL DEFAULT 0, slippage_cost DECIMAL(16,4) NOT NULL DEFAULT 0, realized_pnl DECIMAL(24,4) NULL, holding_days INT NULL, reason_json TEXT NULL, created_at DATETIME NOT NULL, UNIQUE KEY uq_hd_trade (run_id, trade_id), KEY ix_hd_trade_run (run_id, symbol, execution_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` ## 3.4 写入纪律(禁止删除) | 机制 | 实现 | |---|---| | 无 DELETE | 全代码库禁止 `DELETE FROM`、`session.delete`、`DROP`、`TRUNCATE`;CI 单测正则扫描源码断言 | | 幂等重复运行 | 结果表以 `run_id` 为分区键;**重跑生成新 run_id**,旧 run 保留 | | 同步更新 | `INSERT ... ON DUPLICATE KEY UPDATE`(仅更新数据列,不动主键) | | DDL | `ddl.py` 只做 `CREATE TABLE IF NOT EXISTS` 与 `ALTER TABLE ... ADD COLUMN`(幂等、可回滚) | | 回补保护 | 回补前先 `SELECT COUNT(*)` 记录基线,回补后写 `hd_sync_log`(前后行数差可审计) | --- # 4. 配置文件设计(全部可被你直接修改) > 约定:`config/*.yml` 不含任何密钥;密钥经 `*_env` 字段引用根目录 `.env`。 > 所有配置在加载时经 pydantic 严格校验,**字段名写错直接报错**,不静默取默认值。 ## 4.1 `config/datasource.yml` ```yaml version: 1 database: host: 127.0.0.1 port: 3306 db: qlib user: qlib password_env: MYSQL_PASSWORD # 密钥只在 .env charset: utf8mb4 pool_size: 8 read_only_tables: # 白名单:拦截 UPDATE/DELETE - stock - stock_daily - adjust_factor - daily_basic - financial_indicator - stock_name_history - trading_calendar - index_weight write_prefix: hd_ forbid_delete: true tushare: token_env: TUSHARE_TOKEN api_url: http://api.tushare.pro rate_limit_per_min: 400 retry: 3 retry_backoff_sec: 2 timeout_sec: 60 ``` ## 4.2 `config/universe.yml` ★ 股票筛选条件 ```yaml version: 1 name: high_dividend_universe evaluation: mode: point_in_time # point_in_time | latest asof: latest # mode=latest 时生效 market: # MarketFilter exchanges: [SZSE, SSE] # 需要北交所时加 BSE markets: [主板, 创业板, 科创板] min_listing_years: 10 min_market_cap: 50000000000 # 元(500 亿) min_float_market_cap: null min_avg_amount_20d: 20000000 # 元,流动性 require_trading_on_asof: true risk: # RiskFilter exclude_st: true st_lookback_from_history: true # 用 stock_name_history 判历史 ST exclude_delisting: true exclude_suspended: true exclude_negative_equity: true max_debt_to_assets: 0.80 max_pledge_ratio: null exclude_major_litigation: false # 需额外数据源,暂关 dividend: # DividendFilter yield_source: computed # computed(PIT 自算) | dv_ttm(daily_basic) min_dividend_yield: 0.03 min_continuous_years: 5 continuous_rule: cash_div_tax_gt_0 # 以税前现金分红>0 计一次 window_years: 6 min_dividend_years_in_window: 5 max_payout_ratio: 1.00 min_payout_ratio: null min_dps_cagr_5y: null require_positive_fcf: true min_fcf_dividend_cover: 1.0 quality: # FinancialQualityFilter min_roe_5y_avg: 0.08 min_roic_5y_avg: null min_gross_margin: null min_net_margin: null min_ocf_to_profit: 0.60 max_debt_to_assets: null # 覆盖 risk 段设置(null=不覆盖) pit_rule: announce_date_le_asof output: min_members: 10 # 少于则 WARN max_members: 200 warn_on_empty: true ``` ## 4.3 `config/profile.yml` ★ 个股特性配置 ```yaml version: 1 name: default_profile windows_years: [5, 8, 10] min_obs_days: 500 metrics: valuation: [dv_yield, pe_ttm, pb, ps_ttm] financial: [roe, roic, revenue, net_profit, ocf, fcf, debt_to_assets, gross_margin, net_margin, interest_cover] dividend: [dps, payout_ratio, fcf_dividend_cover, dividend_continuity_years, dps_cagr, dps_volatility] risk: [vol_60d, vol_250d, max_drawdown_3y, beta_300, avg_amount_20d] return: [ret_1y, ret_3y, ret_5y, cagr_5y] percentiles: [10, 25, 50, 75, 90] dividend_yield_volatility: daily_window: 250 monthly_window: 60 quarterly_window: 20 annual_window: 10 safety_margin: mode: separate # separate(分项展示,plan §15 第一版) | composite weights: dividend_yield: 0.30 valuation: 0.25 financial_quality: 0.25 balance_sheet: 0.10 dividend_quality: 0.10 score_bounds: [0, 100] ``` ## 4.4 `config/strategy/high_dividend_v1.yml` ★ 策略定义 ```yaml strategy: id: HD_MR_V1 name: high_dividend_mean_reversion version: "1.0" status: DRAFT # DRAFT|RESEARCH|BACKTEST|VALIDATED|PAPER|LIVE|ARCHIVED description: 高股息 + 安全边际 + 估值均值回归 universe: ref: config/universe.yml override: {} # 就地覆盖筛选条件,如 {dividend: {min_dividend_yield: 0.035}} entry: yield_percentile: 75 require_universe_pass: true require_risk_pass: true scale_in: # 分批建仓;留空 => 一次性建满 - { percentile: 75, weight: 0.25 } - { percentile: 80, weight: 0.50 } - { percentile: 85, weight: 0.75 } - { percentile: 90, weight: 1.00 } exit: yield_percentile: 25 scale_out: - { percentile: 50, weight: 0.50 } - { percentile: 25, weight: 0.00 } stop_loss_pct: null # 第一版不加复杂止损(plan §21) max_holding_days: null position: max_position: 0.10 min_position: 0.01 sector_max_position: 0.25 max_holdings: 20 weight_scheme: equal # equal | score | inverse_vol risk: max_portfolio_drawdown: 0.20 max_single_drawdown: 0.25 liquidity_limit_pct_adv: 0.05 execution: signal_to_execution: next_open # next_open | next_close | same_close limit_up_down_rule: skip suspended_rule: defer cost: ref: config/cost.yml ``` ## 4.5 `config/cost.yml` ```yaml version: 1 model: a_share_default commission: { rate: 0.00025, min: 5.0, side: both } stamp_duty: { rate: 0.0005, side: sell } transfer_fee: { rate: 0.00001, side: both } slippage: mode: bps # bps | fixed | tick value: 10 # 10 bps dividend_tax: enabled: true holding_based: true # 持股 > 1 年免税 rates: { "1m": 0.20, "1y": 0.10, "gt1y": 0.00 } ``` ## 4.6 `config/backtest.yml` ```yaml version: 1 capital: { initial: 1000000, currency: CNY } period: { start: 2015-01-01, end: latest } walk_forward: enabled: true scheme: rolling # rolling | expanding train_years: 5 test_years: 1 step_months: 12 min_train_years: 5 dividend: cash_mode: reinvest # reinvest | hold | cash_out reinvest_rule: same_stock_next_open apply_dividend_tax: true benchmark: - { code: 000300.SH, name: 沪深300 } - { code: 000922.CSI, name: 中证红利 } - { code: 000001.SH, name: 上证指数 } fill: price: next_open partial_fill: false max_volume_pct: 0.05 reproducibility: record_code_version: true record_data_version: true seed: 42 ``` ## 4.7 `config/report.yml` ```yaml version: 1 templates_dir: templates output_dir: output theme: light # light | dark | auto offline_assets: true # 内置 ECharts,禁止 CDN include_sql_provenance: true # HTML 内嵌数据来源 SQL + run_id charts: kline_signals: true equity_curve: true drawdown: true yield_percentile: true yearly_returns: true monthly_heatmap: true rolling_metrics: true sensitivity_heatmap: true sector_exposure: true naming: index: "index.html" backtest: "backtest_{run_id}.html" walkforward: "walkforward_{wf_id}.html" sensitivity: "sensitivity_{sens_id}.html" profile: "profile_{symbol}_{asof}.html" universe: "universe_{asof}.html" audit: "data_audit_{date}.html" ``` --- # 5. HTML 图表固定输出规范 ## 5.1 固定约定 | 项 | 规范 | |---|---| | 渲染 | Jinja2 模板 + 内置 `assets/echarts.min.js`,**单文件自包含**,断网可开 | | 输入 | 只接收 `run_id` / `symbol` / `asof` 等标识,内部走 `data.repo` 查询 | | 数据 | 页面内嵌 `window.__DATA__ = {...}` JSON,由服务端从 DB 查询注入 | | 命名 | 严格按 `report.yml:naming`,可预测路径 | | 溯源 | 页脚展示 `run_id / config_hash / data_version / code_version / 生成时间 / 数据截止日` | | 导航 | `output/index.html` 汇总所有报告卡片 | | 禁止 | 模板内不做任何指标计算(计算全在 `analysis/`,已落库) | ## 5.2 报告清单与图表 | 报告 | 布局 | 图表 | |---|---|---| | **数据审计** `data_audit_*.html` | 缺口矩阵 + 覆盖率表 | 覆盖率柱图、缺口时间轴 | | **股票池** `universe_*.html` | 成员表 + 逐股滤网命中 | 行业分布、市值分布、股息率分布、逐股滤网瀑布 | | **个股画像** `profile_*_*.html` | 头部指标卡 + 分位带 | **K线+股息率+PE+PB+回撤四联图**、股息率历史分布直方图、分位带、财务趋势 | | **回测** `backtest_*.html` | KPI 卡 + 净值 + 交易表 | 净值曲线(含基准)、回撤、持仓权重、月度热力图、**个股 K线+买卖点**、行业暴露 | | **Walk-forward** `walkforward_*.html` | 窗口表 | 样本内/外指标对比、窗口拼接净值、参数稳定性 | | **敏感性** `sensitivity_*.html` | 扫描表 | 参数热力图、CAGR/Sharpe 对参数曲线 | > **个股画像的「K线 + 买卖点」** 直接对齐 `plan.md §33` 的排版:K线 → 股息率 → PE → PB → 回撤,四联共享 X 轴。 --- # 6. 开发阶段计划 > 与 `plan.md §49` 的 P0–P11 对齐;难度/依赖并列。 ## P0a · 基础设施 + 最小可用数据集 > 决策 D3:先建立「2020 年起可端到端跑通」的数据条件,不等待历史回补。 **交付** 1. `pyproject.toml` + venv(Python 3.12、pandas、numpy、SQLAlchemy 2.0、pymysql、pydantic v2、PyYAML、Jinja2、pyarrow、tqdm) 2. `core/config.py`:YAML → pydantic 严格校验,生成 `config_hash` 3. `data/db.py`:连接池 + **只读表白名单拦截** + **只写 `hd_` 前缀断言** + 无 DELETE 保证 4. `data/ddl.py`:30 张 `hd_*` 表幂等建表(`CREATE TABLE IF NOT EXISTS`),**不注册进 qlib 的 Alembic** 5. `data/sync/`:与年份无关的全历史同步器(一次拉全,各 1 个文件) - `dividend`(G1,**最关键**)、`fina_indicator` + `cashflow` + `balancesheet` + `income`(G4) - `index_daily`(G5)、`index_weight` 填充(G5) - `suspend_d` + `stk_limit`(G6) 6. `hd_data_audit` + `data_audit_*.html` 7. `docs/data-dictionary.md` 初版 8. `config/backtest.yml: period.start = 2020-01-01`(P0b 完成后切 2015-01-01) **验收** - `hd_dividend` ≥ 5,000 只、覆盖 2010 至今;`div_proc='实施'` 且含 `ex_date` 的记录 ≥ 8 万条 - 三个基准指数(`000300.SH`/`000922.CSI`/`000001.SH`)2015 至今无缺口 - `hd_fina_indicator`/`hd_cashflow`/`hd_balancesheet`/`hd_income` 各覆盖 ≥ 5,000 只、2010 至今,且 `announce_date` 非空率 ≥ 99% - `data_audit_*.html` 中 G1/G4/G5/G6 全部 ✅(G2/G3 标注为「P0b 待回补」) - 源码扫描:0 处 `DELETE`/`DROP`/`TRUNCATE`;0 处 import qlib 代码 ## P0b · 历史回补(后台长跑,不阻塞 P1–P8) **交付** - `daily` + `adj_factor` 回补 **2015-01-01 ~ 2018-12-31**(约 975 交易日 × ~3,500 只 ≈ 340 万行) - `daily_basic` 回补 **2015-01-01 ~ 2019-12-31** - 分年分片 + `hd_sync_log` 断点续传;回补前后基线快照对比 - 完成后:`config/backtest.yml: period.start` 切至 `2015-01-01`,重跑 P4 复权一致性验收 **验收** - `stock_daily` 最小 `trade_date` ≤ 2015-01-02;2015-2018 每个交易日股票数 ≥ 2,700 - **既有数据零改动**:2019-01-02 之后的所有 (symbol, trade_date) 行内容与回补前逐行一致 - `data_audit_*.html` 中 **G1–G6 全部 ✅**(即 `plan.md §22` 的 10 年窗口可用) ## P1 · Point-in-Time 股票池 **交付** - `data/repo.py`:PIT 查询原语(`asof` 参数贯穿;财务 `announce_date <= asof`;ST 按 `stock_name_history` 区间判定;退市股按 `delist_date` 参与历史) - `universe/filters/`:`MarketFilter` / `RiskFilter` / `DividendFilter` / `FinancialQualityFilter`,各自从 YAML 读参 - `universe/selector.py`:组合执行 + 逐股逐滤网留痕 - 落库 `hd_universe_run` / `hd_universe_member` - CLI:`hdiv universe run -c config/universe.yml --asof 2018-06-29` - `universe_*.html` **验收** - **生存者偏差测试**:对 2018-06-29 快照,能筛出**此后已退市**的成员股(用例固化) - **未来函数测试**:把某股 2024 年报数据人为置入后,`asof=2018-06-29` 的筛选结果不变 - 同一 `asof` 两次运行结果完全一致(可复现) ## P2 · 股息率因子 + 个股画像 **交付** - `factor/dividend_yield.py`:PIT-TTM 每股分红(按 `imp_ann_date`/`ex_date` 归属)→ 股息率序列 → 历史分位 / z-score - `factor/valuation.py`、`factor/quality.py`:PE/PB/PS 分位、ROE/ROIC/FCF/负债率、分红质量(连续性/DPS CAGR/DPS 波动/支付率/FCF 覆盖) - `profile/`:五类画像 + 安全边际分项 - 落库 `hd_factor_snapshot` / `hd_profile_run` / `hd_profile_stat` / `hd_profile_series` / `hd_profile_score` - `profile_*.html`(含 `plan.md §35` 的贵州茅台式展示) **验收** - 抽 3 只股票(如 600036.SH、601088.SH、000895.SZ)的 P10/P25/P50/P75/P90 与独立手工核对一致(误差 < 1e-6) - 分位单调性断言:`P10 ≤ P25 ≤ P50 ≤ P75 ≤ P90` - 画像页可离线打开且四联图 X 轴对齐 ## P3 · 策略 DSL + 版本管理 **交付** - `strategy/dsl.py`:YAML → pydantic 严格校验(非法 percentile、`entry ≤ exit`、权重和 ≠ 1 直接报错) - `strategy/registry.py`:加载 / 注册 / 版本指纹(`config_hash`) - `strategy/base.py` + `high_dividend.py`:只产信号,不碰执行 - 落库 `hd_strategy` / `hd_strategy_param`;生命周期状态机 - CLI:`hdiv strategy validate|register|list|diff` **验收** - 篡改 YAML 任一阈值 → `config_hash` 变化且策略签名变化 - 非法配置 100% 被拒(负例测试集 ≥ 10 条) - `plan.md §26` 要求的 `Strategy Version / Parameter Version / Training Period / Test Period` 四元组可完整落库还原 ## P4 · 回测引擎 **交付** - `backtest/engine.py`:事件驱动逐日循环;`signal → execution` 分离 - `backtest/execution.py`:`next_open` 成交、涨跌停跳过、停牌顺延、成交量占比上限 - `backtest/cost.py`:佣金(含最低 5 元)/ 印花税 / 过户费 / 滑点 - `backtest/dividend.py`:现金分红 + 送转 + 配股 → 持仓数量与现金调整;分红再投资两种模式(`plan.md §31` 模式 A/B) - `backtest/portfolio.py`:单股上限 / 行业上限 / 权重方案 - `backtest/benchmark.py`:基准对齐 - 落库 `hd_backtest_run` / `_equity` / `_position` / `_trade` / `_signal` / `_metric` - CLI:`hdiv backtest run -s config/strategy/high_dividend_v1.yml` **验收** - **复权一致性**:单只股票「买入并持有」回测收益 vs 由 `adjust_factor` 直接推导的收益,误差 < 0.5% - **成本守恒**:期初资金 = 期末资金 − Σ(买入金额) + Σ(卖出金额) + Σ(分红) − Σ(费用)(逐日对账通过) - **信号分离**:`hd_backtest_signal` 中 `executed=false` 的记录均有 `skip_reason`(涨跌停/停牌/资金不足) - 每笔交易的 `reason_json` 含 `plan.md §32` 要求的全部字段 ## P5 · Walk-forward **交付** - `backtest/walk_forward.py`:rolling/expanding 窗口切片;train 段定阈值 → test 段**冻结** - 落库 `hd_walkforward_run` / `hd_walkforward_window` - `walkforward_*.html` **验收** - test 段代码路径**无法**读取 train 之外的数据(接口层断言:test 期间传入的 `params` 为冻结对象) - 三窗口(如 2015-2019/2020、2016-2020/2021、2017-2021/2022)样本外指标独立落库 - train/test 的 `hd_backtest_metric.scope` 可分别聚合 ## P6 · 绩效与风险分析 **交付** - `analysis/performance.py`:`plan.md §28.1` 收益指标(Initial/Final/Total Return/CAGR/Annual) - `analysis/risk.py`:§28.2(MDD/Vol/Downside Vol/Sharpe/Sortino/Calmar) - `analysis/trade.py`:§28.3(Trades/Win Rate/Avg Holding/Avg Profit/Avg Loss/Profit Factor/Turnover) - `analysis/attribution.py`:§28.4 基准比较 - `analysis/sensitivity.py`:参数扫描(买入 P70/75/80/85/90 × 卖出 P20/25/30) - 落库 `hd_backtest_metric` / `hd_sensitivity_run` / `hd_sensitivity_point` **验收** - 指标清单与 `plan.md §28` **逐项对齐**(自动化清单比对测试) - 敏感性结果能回答 `plan.md §27` 的判断:CAGR 对参数是否平滑(输出平滑度指标) - 单一指标(如 Sharpe)计算与独立实现(`numpy` 手算)一致 ## P7 · HTML 固定格式输出 **交付** - `report/renderer.py` + `templates/`(base / partials / charts / reports) - 8 类报告页 + `output/index.html` 导航 - `hd_report` 元数据落库(含 SQL 溯源) - CLI:`hdiv report build --run-id ` / `--all` **验收** - **断网打开**全部正常(无外部请求;用静态资源引用检查断言) - 页面所有数值均可由 `hd_report.sql_provenance_json` 中的 SQL 复现 - 文件路径与 `report.yml:naming` 完全一致 ## P8 · 策略解释与 AI Agent(`plan.md §36–37`) **交付** - `report/strategy_explanation.py`:自动生成「为什么买/卖/持有 + 历史表现 + 策略弱点」 - `ai/`:Data/Factor/Strategy/Backtest/Analysis Agent 骨架;Strategy Agent 输出必须过 Schema 校验才可运行;Backtest Agent 只调引擎不自算 - 落库:解释文本进 `hd_report.context_json` **验收** - 解释中的每个数字都能追溯到 `hd_backtest_*` 表 - 非法自然语言策略 100% 被 Schema 拦截 --- ## 6.1 优先级与工作量估算 | 阶段 | 关键交付 | 相对工作量 | 依赖 | |---|---|---:|---| | P0a | 建表 + 7 个全历史同步器 + 数据审计 | 中高 | — | | P0b | 2015-2018 行情回补(后台长跑) | 低(机器时间为主) | P0a | | P1 | PIT 股票池 | 中高 | P0a | | P2 | 股息率因子 + 画像 | 高 | P0a/P1 | | P3 | 策略 DSL | 中 | P1 | | P4 | 回测引擎 | **最高** | P0a–P3 | | P5 | Walk-forward | 中 | P4 | | P6 | 绩效与风险 | 中 | P4 | | P7 | HTML 输出 | 中高 | P4/P6 | | P8 | 解释 + Agent | 中 | P7 | **建议里程碑** - **M0(数据就绪)** = P0a → 具备 2020 起全链路数据条件,且分红/财务/基准/停牌涨跌停全部到位 - **M1(可用)** = P0a + P1 + P2 → 能回答「现在哪些股票符合高股息 + 安全边际」 - **M2(可回测)** = + P3 + P4 + P6 → 能跑出 CAGR/MDD/Sharpe 与交易明细(2020 起) - **M3(可研究)** = + P0b + P5 + P7 → 满足 `plan.md §50` 全部 14 项,回测窗口 2015 起 10 年 - **M4(可扩展)** = + P8 → 自然语言策略生成与自动研究报告 > **关键路径**:P0a → P1 → P2 → P4 → P7。P0b 与 P5 可并行后台推进,不占关键路径。 --- # 7. 关键决策(已确认) > 2026-10-02 已与用户确认,以下为**生效决策**,全部实现须遵守。 > 后续如需变更,须在 `docs/decisions/` 下新增 ADR 并同步修订本表。 | 编号 | 决策点 | **生效结论** | 影响 | |---|---|---|---| | **D1** | 新表放哪 | ✅ **`qlib` 库内加 `hd_*` 前缀表** | 可跨表 JOIN `stock`/`daily_basic`;30 张表**不注册进 qlib 的 Alembic 版本链**,qlib 升级不受影响 | | **D2** | 历史回补写到哪 | ✅ **追加进 qlib 现有 `stock_daily`/`daily_basic`/`adjust_factor`** | 纯新增历史区间,**不覆盖任何既有行**;回补前后行数写入 `hd_sync_log` 可审计;`index_weight` 空表填充成分股 | | **D3** | 回测起始年份 | ✅ **先按 2020 跑通端到端,再补 2015 数据** | P0 拆为 **P0a(最小可用数据集)** 与 **P0b(历史回补)**,见 §6 修订;P0b 可在后台长跑,不阻塞 P1–P8 | | **D4** | 图表技术栈 | ✅ **Python + Jinja2 + 内置 ECharts 静态 HTML** | 单文件自包含、断网可开、零运维;CSS/JS 全部内联或本地引用 | | **D5** | 是否复用 qlib 代码 | ✅ **完全独立实现,仅共享数据库** | 不 import `qlib/backend/app/**`;QLib 的口径由我阅读后在新代码中重新实现并加测试;qlib 项目代码零改动 | | **D6** | 分红数据口径 | ✅ **以税前现金分红 `cash_div_tax > 0` 计一次分红**,且只认 `div_proc = '实施'` 且存在 `ex_date` 或 `pay_date` | 与 Tushare `dv_ttm` 口径一致,避免「股东提议/预案/取消」污染连续性判定 | | **D7** | 股息率因子口径 | ✅ **自算 PIT-TTM 为回测主口径**(按 `imp_ann_date` 生效);`daily_basic.dv_ttm` 仅作交叉校验列落库 | 彻底规避未来函数;两口径差异 > 5% 时在 `hd_data_audit` 记 WARN | ## 7.1 决策带来的计划修订 1. **P0 拆分为两段**(因 D3): - **P0a · 最小可用数据集**:建全部 30 张 `hd_*` 表;同步 `dividend`/`fina_indicator`/`cashflow`/`balancesheet`/`income`/`index_daily`/`suspend_d`/`stk_limit`(这些与年份无关,一次拉全历史);`index_weight` 填充。**目标:立刻具备 2020 年起端到端可跑的数据条件。** - **P0b · 历史回补(后台长跑)**:`daily` + `adj_factor` 回补 2015-01-01 ~ 2018-12-31;`daily_basic` 回补 2015-01-01 ~ 2019-12-31。完成后 `backtest.yml: period.start` 从 `2020-01-01` 切到 `2015-01-01`,并重跑 P4 验收。 2. **P1–P8 的开发不被 P0b 阻塞**(因 D3),但 **M3 里程碑(对齐 `plan.md §50`)要求 P0b 完成**。 3. **qlib 项目零代码改动**(因 D5),仅在数据层面新增历史行(因 D2);回补脚本放在本项目 `src/hdiv/data/sync/` 内。 4. **`index_weight` 是唯一例外**:它是 qlib 的表但为空,按 D2 填充成分股权重(纯新增,无删除)。 ## 7.2 由决策派生的硬约束(写入单测) | 约束 | 断言方式 | |---|---| | 30 张表全部 `hd_` 前缀 | 扫描 `information_schema.tables`,本项目创建的表 100% 匹配 `^hd_` | | 不注册进 qlib Alembic | 断言 `alembic_version` 表内容不被本项目修改 | | 回补只增不删 | 回补前后对 `stock_daily`/`daily_basic`/`adjust_factor` 做行数 + 日期区间 + 校验和快照;断言「回补后行数 ≥ 回补前」且「既有 (symbol, trade_date) 的 close 未被修改」 | | 不 import qlib 代码 | 源码扫描:无 `from app.`/`import app.`/`qlib.backend` 引用 | | qlib 源码零改动 | 记录 `~/project/qlib` 的 git 工作区状态为 clean | --- # 8. 风险与对策 | 风险 | 影响 | 对策 | |---|---|---| | Tushare 积分/频率限制导致回补慢 | P0 延期 | 分年分片 + `hd_sync_log` 断点续传 + `rate_limit_per_min` 可调;回补可后台长跑 | | 2015-2018 部分股票 `daily_basic` 缺 `dv_ttm` | 早期股息率不可用 | 主口径为自算 PIT-TTM(D7),`dv_ttm` 仅交叉校验 | | 分红数据 `div_proc` 存在「取消」「股东提议」等非实施记录 | 分红连续性误判 | 只认 `div_proc = '实施'` 且存在 `ex_date`/`pay_date`(D6) | | 银行/保险等金融股 FCF 口径失真 | `require_positive_fcf` 误杀 | YAML 中按行业豁免(`quality.exempt_industries`),P2 阶段加入 | | 复权与分红重复计算 | 收益虚高 | 强制:用**不复权价 + 独立分红现金流**建仓,复权价仅用于口径校验;P4 设「复权一致性」验收 | | 回测过拟合 | 结论不可信 | 硬性要求 Walk-forward + 参数敏感性(`plan.md §26/§27`),并把 `config_hash`/`data_version` 写进每一次 run | | qlib 侧若升级 Alembic | 表冲突 | 只写 `hd_` 前缀,不注册进 qlib 的 Alembic 版本链(D1) | | 回补追加进 qlib 现有表可能影响 qlib 平台行为 | 静默回归 | 回补仅新增 2015-2018 区间、绝不触碰 2019+ 既有行;回补前后基线快照 + 逐行一致性断言(D2) | --- # 9. 验收标准(对齐 `plan.md §50`) 第一版完成后,输入: ```yaml 最低市值: 500亿 上市年限: 10年 连续分红: 5年 最低股息率: 3% 买入: 历史P75 卖出: 历史P25 ``` 系统应自动完成 14 项: | # | 能力 | 落地阶段 | 落库位置 | |---|---|---|---| | ① | 找出符合条件的股票 | P1 | `hd_universe_member` | | ② | 生成股票池(PIT) | P1 | `hd_universe_run` | | ③ | 每股历史股息率序列 | P2 | `hd_profile_series` | | ④ | 计算 P10/P25/P50/P75/P90 | P2 | `hd_profile_stat` | | ⑤ | 生成买卖信号 | P3 | `hd_backtest_signal` | | ⑥ | 执行历史回测 | P4 | `hd_backtest_run` | | ⑦ | 正确处理分红与除权 | P4 | `hd_dividend` + `hd_backtest_trade` | | ⑧ | 加入交易成本 | P4 | `hd_backtest_trade.*_cost` | | ⑨ | 输出 K线 + 买卖点 | P7 | `backtest_*.html` | | ⑩ | 收益/回撤/Sharpe 指标 | P6 | `hd_backtest_metric` | | ⑪ | 与 Benchmark 比较 | P6 | `hd_backtest_metric.benchmark_code` | | ⑫ | 执行 Walk-forward | P5 | `hd_walkforward_window` | | ⑬ | 样本内/外结果 | P5 | `hd_backtest_metric.scope` | | ⑭ | 保存完整参数与版本 | P3 | `hd_strategy` + `hd_strategy_param` | --- # 10. 附:本计划的四条纪律 1. **只增不删**:不执行任何 `DELETE`/`DROP`/`TRUNCATE`;重跑一律新建 `run_id`。 2. **配置零硬编码**:任何阈值只允许出现在 `config/*.yml`,代码中出现的常量只能是无业务含义的(如 0、1)。 3. **无未来函数**:所有取数必经 `data/repo`,且以 `announce_date`/`imp_ann_date`/`available_date <= asof` 为唯一纪律;每个阶段都有对应的负例测试。 4. **可复现**:每次回测的 `code_version + data_version + config_hash + seed` 全量落库,同输入必同输出。