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_date 为 inception_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职责边界
A1[按年份提取Parquet]
A2[JOIN类型表]
A3[WHERE条件过滤]
A4[COUNT/SUM审计统计]
end
subgraph Polars职责边界
C1[rolling滚动窗口]
C2[EWM指数加权]
C3[截面ZScore]
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 严格模式)¶
分四步严格执行:
-
计算统计量:计算每个日期截面的均值、标准差、中位数、MAD。
-
时间对齐:将统计量整体
shift(1),即 T 日的标准化参数使用 T-1 日截面的统计量。首日(数据集第一天)无 T-1 可用,该日所有标准化特征输出 NULL。
-
Z-Score:将 shifted 统计量 Join 回主表,执行
z_score = (raw_feature - T1_mean) / T1_std。
-
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 上: |
|
- 用模型对所有候选基金打分
- 按得分分四桶:Top 20 / Top 21-50 / Top 51-100 / 其余
- 每桶模拟等权周定投组合(每周投入 20 元均分到该桶基金)
- 追踪组合净值,计算:
| 回测指标 | 说明 |
|----------|------|
| 年化收益率 | 定投组合的实际年化回报 |
| 最大回撤 | 组合净值的最大峰谷跌幅 |
| Sharpe Ratio | 风险调整后收益 |
| Calmar Ratio | 年化收益 / 最大回撤 |
| 止盈成功率 | 150 周内达到 20% 定投收益率的比例 |
- 基准对照:全标的池等权随机组合的相同指标
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.yaml、config.py、logging.py、hash.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 剔除) |
正常生成标签,不报错 |