项目

一般

简介

行为

0002-基金量化定投选基系统设计规约 » 历史记录 » 修订 3

« 上一页 | 修订 3/11 (差异) | 下一页 »
Huarui Lin, 2026-04-12 14:48



document_id: SPEC-FUND-DCA-001
version: 1.0.0
status: Frozen (Baseline)
created_date: 2026-04-11
approved_by: Henry Lin
redmine_link: https://redmine.persys.top/projects/fund-selection-system


基金量化定投选基系统 — 最终设计规约 (v1.0)

⚠️ 机器与人工强制红线声明
本文档为系统的唯一业务与技术真相源。AI 必须严格按此执行,研发人员任何代码实现若与本文档冲突,一律以本文档为准。严禁擅自修改任何参数、公式或逻辑。

变更日志

版本 日期 变更人 变更内容摘要 审批状态
v1.0.0 2026-04-11 Henry Lin 初始基线版本确立,锁定所有业务参数、引擎防火墙与工程质量红线。 ✅ Approved

〇、决策确认总表

编号 决策项 最终决策
D-1 类型表无时间维度 使用最新 fund_type 全局过滤,工程中预留接口扩展位
D-2 标签定义 DCA 简单平均成本法,20% 止盈,150 周窗口,未达则 0
D-3 label=0 样本保留 单基金上限 max_samples_per_fund × 0.3(默认 50×0.3=15 条)
D-4 无风险利率 年化 2.0%,复利折算至周频
D-5 增量更新 不采用增量,每周全量刷盘重训,MLflow 管理模型版本替换
D-6 止盈贴近度特征 不添加,依赖现有 dca_return 系列特征覆盖定投浮盈信息
D-7 IC 计算 严格限制在 Time-Series Split 训练折内局部计算

一、数据基础

1.1 数据源 Schema

净值表 fund_net_info(主表):

字段 类型 说明
id BigInteger PK 自增主键,清洗阶段丢弃
fund_id String(50) 基金代码
cumulative_net_value Numeric(12,4) 累计净值,读入后映射为 Float64
net_value_date Date 净值日期
基金信息表 fund_basic_info(辅助表):
字段 类型 说明
------ ------ ------
fund_id String PK 基金代码
fund_name String 基金名称
fund_type String 当前基金类型(仅快照值,无时间维度)
create_date Date 基金成立日期(仅作参考,系统以首条净值日期为实际 inception_date)
数据规模:约 3000 万行,周频,64GB 内存。

1.2 数据处理规则

环节 规则 说明
周频聚合 同一 fund_id + 同一自然周内若存在多条记录,取该周最后一个交易日的 cumulative_net_value,其余丢弃 防止重复计数
类型过滤 一次性 JOIN fund_basic_info,保留白名单类型基金,后续不再变更 白名单类型见下方
建仓期剔除 以每只基金第一条有效净值的 net_value_dateinception_date,硬删前 12 周数据 防止建仓期净值污染波动率特征
缺失值填充 单基金内部按时间排序,前向填充;连续缺失 > 4 周的区段直接截断,截断后各段独立参与后续计算 短缺用 ffill,长缺硬切
异常值 cumulative_net_value ≤ 0 → 剔除该行;单周涨跌幅绝对值 > 50% → 该值置 NULL(不删行) 保留时间序列连续性
存活期过滤 单基金(含所有截断段的总有效周数)< 52 周则整体剔除 避免信息量过少引入噪声
清盘基金 数据中断前所有有效数据完整保留,满足 52 周最低要求即纳入 控制幸存者偏差
类型白名单
['股票型', '混合型-偏股', '混合型-灵活', 'QDII-普通股票', 'QDII-混合偏股', 'QDII-混合灵活']

1.3 存储方案

  • 按年份分区存储:data/processed/net_value_YYYY.parquet
  • Parquet row_group_size = 100,000
  • 每次全量刷新时覆盖写入,不追加

二、计算引擎架构

2.1 DuckDB ↔ Polars 职责防火墙

graph TD subgraph 数据处理流水线 A[DuckDB] -->|SQL 下推过滤| B[Arrow 单年级 ≤300万行] B --> C[Polars 计算引擎] C --> D[覆盖写回 Parquet + MLflow] end subgraph DuckDB 职责 [DuckDB 职责边界] A1[按年份提取 Parquet] A2[JOIN 类型表] A3[WHERE 条件过滤] A4[COUNT/SUM 审计统计] end subgraph Polars 职责 [Polars 职责边界] C1[rolling 滚动窗口] C2[EWM 指数加权] C3[截面 Z-Score] C4[标签生成] C5[共线性筛选] end A --- A1 A --- A2 A --- A3 A --- A4 C --- C1 C --- C2 C --- C3 C --- C4 C --- C5

2.2 防火墙规则(铁律)

编号 规则
FW-1 DuckDB 仅负责 Parquet 读写、跨表 JOIN、行列过滤、聚合统计审计
FW-2 严禁 DuckDB 执行滚动窗口函数,该类计算全部由 Polars 原生表达式完成
FW-3 DuckDB → Polars 必须按年份拆分提取,每次 Arrow 转换 ≤ 单年数据量
FW-4 Polars 处理完毕后按年份分区覆盖写回 Parquet
FW-5 DuckDB 每次启动重新注册 Parquet 创建 VIEW,不维护持久化数据库状态

三、标签系统

3.1 标签定义

参数 说明
起始点 T 日 T 日特征截止,T+1 日开始模拟
定投方式 等额周定投,每周归一化投入 1 元
成本计算 简单平均成本法 avg_cost = Σ(各周买入净值) / 已买入周数
收益率计算 (当前净值 - avg_cost) / avg_cost
止盈触发 定投累计收益率 首次 ≥ 20% 触发即刻停止模拟
标签映射 150 - 耗费周数 值域 [1, 149]
未达标 标签 = 0 含:150 周内未达、基金清盘中断
手续费 暂不扣除 config.yaml 中预留 deduct_fee: false 开关

3.2 标签计算算法

核心约束:不使用全局 Python for 循环,采用 Polars group_by + NumPy 向量化并行策略。

对每只基金单次处理约 300-500 周的极小数组):
nav_array = [nav_0, nav_1, ..., nav_T]
对每个时间点 t作为"开始定投"的候选时刻):
     有效窗口 = nav_array[t+1 : t+151] numpy 切片自动截断于数据末尾
     若有效窗口长度 < 2  label = 0数据不足以定投
     累计成本序列cum_cost = np.cumsum(有效窗口) / np.arange(1, len+1)
     当前净值序列current = 有效窗口
     DCA 收益率序列dca_ret = (current - cum_cost) / cum_cost
     首次达标检测mask = dca_ret >= 0.20
       - any(mask)  True  weeks = np.argmax(mask) + 1  label = 150 - weeks
       - any(mask)  False  label = 0

清盘基金处理:NumPy 切片在数组越界时自动截断。清盘基金的有效窗口短于 150 周,若截断前未触发止盈则 any(mask) 为 False,标签自动为 0。无需额外逻辑。

3.3 样本平衡机制

样本类型 保留规则
label > 0(正样本) 单基金最多保留 max_samples_per_fund(默认 50)条,等距抽样
label = 0(负样本) 单基金最多保留 max_samples_per_fund × 0.3(默认 15)条,等距抽样
等距抽样方法 np.linspace(0, len(samples)-1, N, dtype=int),按时间均匀取点

四、特征系统

4.1 特征目录(共约 40+ 维)

A. 均值回归类

特征名 计算方式 窗口期
price_vs_ma_ratio nav / rolling_mean(nav, W) 12, 26, 52
price_vs_ma_ratio_ewm nav / ewm_mean(nav, span=W) 26, 52

B. 波动类

特征名 计算方式 窗口期
rolling_std rolling_std(weekly_return, W) 12, 26, 52
downside_vol rolling_std(min(weekly_return, 0), W) 26, 52
volatility_regime rolling_std(12w) / rolling_std(52w) (12 vs 52)

C. 趋势与动量类

特征名 计算方式 窗口期
momentum (nav_t / nav_{t-W}) - 1 4, 12, 26, 52
max_drawdown 1 - nav_t / rolling_max(nav, W) 26, 52
trend_slope log(nav) 做 OLS 线性回归斜率,再年化 26, 52
trend_r_squared 上述回归的 R² 26, 52
consecutive_down_weeks 从当前向前连续 weekly_return < 0 的周数

D. 定投特有类

特征名 计算方式 窗口期
dca_cost_ratio rolling_mean(nav, W) / nav(值 <1 表示浮盈) 12, 26, 52
dca_return (nav - rolling_mean(nav, W)) / rolling_mean(nav, W) 12, 26, 52
dca_return_vol rolling_std(dca_return 逐周序列, W) 26, 52
dca_win_rate rolling_mean(dca_return > 0 转为 0/1, W) 26, 52

E. 风险调整类

特征名 计算方式 窗口期
rolling_sharpe (rolling_mean(weekly_ret) - weekly_rf) / rolling_std(weekly_ret) × √52 26, 52
rolling_sortino (rolling_mean(weekly_ret) - weekly_rf) / rolling_std(min(weekly_ret, 0)) × √52 26, 52
calmar_ratio annualized_return(W) / max_drawdown(W) 52
无风险利率折算公式
weekly_rf = (1 + 0.02)^(1/52) - 1 ≈ 0.000381 (0.0381%)

4.2 窗口有效值判定

  • 对每个滚动窗口特征,计算窗口内非 NULL 值的数量
  • 有效值 < window_size × min_valid_ratio(默认 0.20) → 特征值输出 NULL
  • 示例:52 周窗口至少需要 11 个有效值(52 × 0.2 = 10.4,向上取整)

4.3 截面标准化(T-1 严格模式)

分四步严格执行:

  1. 计算统计量:计算每个日期截面的均值、标准差、中位数、MAD。
  2. 时间对齐:将统计量整体 shift(1),即 T 日的标准化参数使用 T-1 日截面的统计量。首日(数据集第一天)无 T-1 可用,该日所有标准化特征输出 NULL。
  3. Z-Score:将 shifted 统计量 Join 回主表,执行 z_score = (raw_feature - T1_mean) / T1_std
  4. MAD 去极值
    median = T1 截面中位数
    mad = median(|x - median|) × 1.4826
    clip z_score to [median - 3×mad, median + 3×mad]
    持久化:每个时间截面的标准化参数独立保存为 standardization_params.parquet
    | 字段 | 类型 | 说明 |
    |------|------|------|
    | date | Date | 截面日期(即统计量的来源日期) |
    | feature_name | String | 特征名 |
    | mean | Float64 | 截面均值 |
    | std | Float64 | 截面标准差 |
    | median | Float64 | 截面中位数 |
    | mad | Float64 | 截面 MAD |
    推理时加载该文件的最新行,对输入特征做同样的 Z-Score + MAD clip。

4.4 特征共线性剔除

步骤 说明
1 计算所有特征间的 Pearson 相关系数矩阵
2 找出 |r| > 0.90 的特征对
3 对每个高相关特征对,计算各自与标签的 Spearman Rank IC仅在 Time-Series Split 的训练折内计算,严禁跨时间窗口使用未来标签)
4 保留 |IC| 更高的特征,剔除另一个
5 输出最终保留的特征列表,持久化为 final_feature_list.json

五、模型训练

5.1 训练数据流

features.parquet (按年分区)
       │ Polars: 读取并合并
       ▼
labels.parquet
       │ Polars: LEFT JOIN (on fund_id + date)
       ▼
train_dataset.parquet
schema: fund_id | date | feature_1 | ... | feature_K | label

缺失值处理:特征 NULL 在 LightGBM 内部自动处理(设为缺失值,树模型原生支持),不做预填充。

5.2 交叉验证

  • 使用 TimeSeriesSplit(n_splits=5)
  • 严禁 随机 K-Fold
  • 每一折的训练集内部独立执行:截面标准化 → 共线性筛选
  • 验证集仅使用对应训练折产出的标准化参数

5.3 Optuna 超参搜索

参数 搜索范围 类型
num_leaves [31, 511] int
learning_rate [0.01, 0.3] log uniform
feature_fraction [0.5, 1.0] uniform
bagging_fraction [0.5, 1.0] uniform
lambda_l1 [0, 10] log uniform
lambda_l2 [0, 10] log uniform
min_child_samples [20, 100] int
max_depth [5, 12] int
停止条件:200 次试验 或 1 小时,先到者停。

5.4 评估指标体系

模型层

指标 说明
AUC 区分"能止盈"与"不能止盈"的能力
NDCG@20 Top-20 推荐的排序质量
业务层(回测评估)
在验证集的每个时间截面 T 上:
  1. 用模型对所有候选基金打分
  2. 按得分分四桶:Top 20 / Top 21-50 / Top 51-100 / 其余
  3. 每桶模拟等权周定投组合(每周投入 20 元均分到该桶基金)
  4. 追踪组合净值,计算:
    | 回测指标 | 说明 |
    |----------|------|
    | 年化收益率 | 定投组合的实际年化回报 |
    | 最大回撤 | 组合净值的最大峰谷跌幅 |
    | Sharpe Ratio | 风险调整后收益 |
    | Calmar Ratio | 年化收益 / 最大回撤 |
    | 止盈成功率 | 150 周内达到 20% 定投收益率的比例 |
  5. 基准对照:全标的池等权随机组合的相同指标

5.5 MLflow 实验记录

每次训练自动记录:

  • Optuna 最优超参数
  • 模型层指标(AUC、NDCG)+ 业务层回测指标
  • LightGBM 模型文件(.txt
  • config.yaml 快照
  • final_feature_list.json
  • standardization_params.parquet
  • SHAP summary plot(图片)
  • 各分桶回测净值曲线(图片)
    模型通过 MLflow Model Registry 管理,新模型打 Production 标签后直接替换推理服务加载的模型版本,旧版本归档至 Archived

六、推理服务

6.1 输入输出

场景 输入 输出
单基金查询 单只基金近期周频净值 CSV(≥52 周) 推荐得分(0-100)、Top5 正/负贡献特征、决策建议文本
批量推荐 基金代码列表 + 最新净值数据 排序后的推荐 DataFrame(fund_id, score, top_features, recommendation)

6.2 推理流程

输入净值 → Polars 读入
       ▼
特征计算(复用 Module3 逻辑,单基金精简版)
       ▼
加载 standardization_params.parquet(最新一周参数)
       ▼
T-1 参数做 Z-Score + MAD clip
       ▼
加载 LightGBM Production 模型 → 预测概率
       ▼
score = round(概率 × 100) → 0-100 整数分
       ▼
SHAP TreeExplainer → 单样本特征归因
       ▼
输出:得分 + Top5正向特征 + Top5负向特征 + 决策建议文本

6.3 决策建议文本生成规则

得分区间 建议等级 文案模板
80-100 强烈推荐定投 "该基金趋势强劲、波动适中,定投体验优异,建议立即开始定投"
60-79 推荐定投 "该基金整体特征良好,建议关注并适时开始定投"
40-59 中性观望 "该基金部分特征达标,建议持续关注,等待更好的定投入场时机"
0-39 不推荐 "该基金当前特征不佳(主要受XX拖累),建议暂缓定投"
具体文案需拼接该基金的 Top 贡献特征名。

七、工程质量规范

7.1 目录结构

project-root/
├── pyproject.toml
├── config/
│   └── config.yaml
├── data/
│   ├── raw/                # 原始 CSV / Parquet
│   ├── processed/          # 清洗后 Parquet(按年分区)
│   ├── features/           # 特征矩阵 Parquet(按年分区)
│   ├── models/             # 模型文件 & 标准化参数
│   └── metadata/           # SHA-256 hash 校验文件
├── src/
│   ├── __init__.py
│   ├── utils/
│   │   ├── __init__.py
│   │   ├── config.py       # YAML 配置加载器
│   │   ├── logging.py      # 结构化日志配置
│   │   └── hash.py         # SHA-256 数据血缘校验
│   ├── data_loader.py      # 数据清洗与入库
│   ├── feature_engineering.py # 特征计算引擎
│   ├── label_generator.py  # 标签生成器
│   ├── trainer.py          # 模型训练与评估
│   └── inference.py        # 推理服务
├── tests/
│   ├── test_label_generator.py
│   └── test_feature_engineering.py
├── mlruns/                 # MLflow 实验追踪
└── logs/                   # 运行日志

7.2 配置与代码分离

所有硬参数集中在 config.yaml,代码中严禁魔法数字。核心参数分区如下:

# === 标签参数 ===
label:
  target_return: 0.20
  max_weeks: 150
  deduction_method: "simple_avg" # 预留: "irr"
  deduct_fee: false
  purchase_fee_rate: 0.0012
# === 数据过滤 ===
filter:
  fund_type_whitelist:
    - "股票型"
    - "混合型-偏股"
    - "混合型-灵活"
    - "QDII-普通股票"
    - "QDII-混合偏股"
    - "QDII-混合灵活"
  min_net_value_weeks: 52
  building_period_weeks: 12
  max_gap_weeks: 4
  single_week_change_limit: 0.50
# === 特征参数 ===
feature:
  windows:
    mean_reversion: [12, 26, 52]
    volatility: [12, 26, 52]
    momentum: [4, 12, 26, 52]
    trend: [26, 52]
    dca_specific: [12, 26, 52]
    risk_adjusted: [26, 52]
  min_valid_ratio: 0.20
  outlier_method: "mad"
  outlier_bound: 3.0
# === 截面标准化 ===
standardization:
  method: "zscore"
  use_shift_1: true # 使用 T-1 截面统计量
# === 样本平衡 ===
sample_balance:
  max_samples_per_fund: 50
  zero_label_ratio: 0.3 # label=0 的上限比例
# === 模型训练 ===
model:
  time_series_n_splits: 5
  optuna_budget: 200
  optuna_timeout_hours: 1
# === 风险 ===
risk_free_rate:
  annual: 0.02

7.3 数据血缘

检查对象 算法 存储位置 触发时机
原始数据文件 SHA-256 data/metadata/raw_hash.json 数据入库前
清洗后 Parquet SHA-256(按文件) data/metadata/processed_hash.json 清洗完成后
特征矩阵 Parquet SHA-256(按文件) data/metadata/features_hash.json 特征计算完成后
config.yaml SHA-256 MLflow artifact 每次训练开始时
Pipeline 启动时自动校验上游 hash,不匹配则输出 WARNING 日志但不中断流程。

7.4 工程约束

约束 说明
禁止 Pandas 仅允许 LightGBM .fit() 内部隐式调用
类型提示 所有自定义函数必须有 Python Type Hints
日志 核心数据处理环节必须记录运行状态与耗时
异常处理 关键步骤 try-except 防止单点故障导致全流程崩溃
单元测试 label_generator 必须覆盖全部 7 个场景(见第九节)
特征存储 严禁 Pivot 成宽表,始终保持长表形态

八、实施路线图

按以下 6 个 Step 逐步交付,每步确认后再推进:

Step 模块 交付物 依赖
1 工程脚手架 pyproject.toml、目录树、config.yamlconfig.pylogging.pyhash.py
2 数据基建与入库 data_loader.py + 清洗后的按年 Parquet + DuckDB VIEW + 数据质量审计日志 Step 1
3 特征工程引擎 feature_engineering.py + features.parquet + standardization_params.parquet + final_feature_list.json Step 2
4 标签生成器 label_generator.py + labels.parquet + test_label_generator.py Step 2
5 模型训练与评估 trainer.py + 模型文件 + 回测报告 + SHAP 图 + MLflow 记录 Step 3 + Step 4
6 推理服务 inference.py(单基金 + 批量) Step 3 + Step 5

:Step 3 和 Step 4 无相互依赖,可并行开发。但在流水线执行中,Step 4 的标签需要 Join 到 Step 3 的特征表,所以最终训练集的生成依赖两者都完成。

九、单元测试必覆盖场景

tests/test_label_generator.py 必须完整覆盖以下 7 个场景:

编号 测试场景 输入条件 期望输出
1 正常止盈 净值从 1.0 经 N 周涨到触发 20% DCA 收益率 label = 150 - N
2 150周未达 净值缓慢增长,150 周内 DCA 收益率始终 < 20% label = 0
3 清盘截断 净值开始定投后,60 周时数据中断,期间未达 20% label = 0
4 清盘前止盈 净值在 30 周达标,第 50 周数据中断 label = 120
5 精确边界 第 1 周净值即满足 20% 收益(防御性测试) label = 149
6 全亏损路径 净值从 1.0 持续下跌到 0.5 label = 0
7 建仓期后首周 数据恰好从第 13 周开始(前 12 周已被 Module2 剔除) 正常生成标签,不报错

Huarui Lin 更新于 5 天 之前 · 3 修订 已锁定