Files
ProStock/docs/plans/2026-04-07-factorminer-local-integration.md
liaozhaorun d71f723602 refactor(factorminer): 将 110 个 PAPER_FACTORS 迁移到本地 snake_case DSL
- 新增一次性翻译脚本 src/scripts/translate_paper_factors.py
- 将 library_io.PAPER_FACTORS 中的 CamelCase DSL 公式替换为本地 DSL
- 对使用未实现算子(Decay、TsLinRegSlope、TsLinRegResid、Resid、
  Quantile、HMA、DEMA)的 16 个因子注释为 # TODO
- 新增 test_factorminer_paper_factors.py 验证所有翻译后公式的 DSL 解析
- 更新整合计划中 step1 的状态
2026-04-08 22:03:52 +08:00

425 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# FactorMiner 本地框架整合实施计划(修订版)
> 目标:将 `src/factorminer` 完全整合进 ProStock 项目。数据读取与因子计算全部复用本地 `FactorEngine`,不再引入 FactorMiner 原生的数据加载、DSL 计算与 `(M,T)` 矩阵缓存。仅在因子生成、落库、指标分析时保留 FactorMiner 代码。
>
> 本次修订核心变更:
> 1. **删除 Step 1LocalDataLoader**:本地 `FactorEngine.compute()` 已自带数据路由与读取能力,无需自行封装数据加载层。
> 2. **删除运行时 DSL 翻译器**:不再维护 `FmToLocalTranslator`。改为一次性脚本把 110 个 paper factors 的 CamelCase DSL 翻译成本地 DSL并直接回填到常量列表中LLM Prompt 同步改造为直接输出本地 DSL。
---
## 代码风格与本地框架融合规范(全局约束)
所有新增/修改代码必须遵循 ProStock 代码风格,严禁出现 FactorMiner 原生的松散风格或外部项目风格。
1. **命名规范**
- 函数/方法/变量:`snake_case`
- 类名:`PascalCase`
- 常量:`UPPER_CASE`
- 私有方法/属性:`_leading_underscore`
2. **类型提示**
- 所有公共函数必须标注参数类型和返回类型
- 可空类型使用 `Optional[X]``X | None`Python 3.10+
- 复杂类型从 `typing` 导入:`Dict`, `List`, `Callable`, `Tuple`, `Any`
3. **文档字符串**
- **中文** Google 风格
- 第一行为简短摘要
- 必须包含 `Args:``Returns:` 段落
4. **导入顺序**
```python
# 1. 标准库
import os
from typing import Optional, Dict, List
# 2. 第三方包
import numpy as np
import polars as pl
# 3. 本地模块(绝对导入)
from src.data.storage import Storage
from src.factors import FactorEngine
```
5. **错误处理**
- 禁止裸 `except:`
- 错误信息格式:`print(f"[ERROR] 上下文: {e}")`
- 记录上下文后重新抛出 `raise`
6. **日志与输出**
- 使用带前缀的 `print``print("[模块名] 消息")`
- 循环进度使用 `tqdm`
- **禁止 emoji**
7. **数据加载**
- 查询模式必须使用 `Storage(read_only=True)`
- 因子计算统一通过 `FactorEngine`
8. **测试**
- 所有新模块必须配套 `tests/test_*.py`
- 运行命令:`uv run pytest tests/test_xxx.py -v`
---
## Step 0: 统一模块引用风格为 `src.*`(已完成)
**状态:** [x] 已完成(通过脚本批量替换)
- 所有 `from factorminer.xxx` 已替换为 `from src.factorminer.factorminer.xxx`
- 所有字符串形式的模块引用(如 `"factorminer.xxx"`)已同步更新
---
## Step 1: 一次性 Paper Factors DSL 迁移脚本
**文件**
- 新建:`scripts/translate_paper_factors.py`
**目标**
- 将 `src/factorminer/core/library_io.py` 中硬编码的 110 个 `PAPER_FACTORS` 的 CamelCase DSL 公式,一次性翻译为本地 snake_case DSL 字符串。
- 翻译结果直接替换回原常量列表,后续 `import_from_paper()` 加载的公式已经是本地格式,无需运行时翻译。
**映射规则(核心算子)**
| FactorMiner | 本地 DSL |
|-------------|----------|
| `Neg(X)` | `-X` |
| `Add(A, B)` | `A + B` |
| `Sub(A, B)` | `A - B` |
| `Mul(A, B)` | `A * B` |
| `Div(A, B)` | `A / B` |
| `Greater(A, B)` | `A > B` |
| `Square(X)` | `X ** 2` |
| `CsRank(X)` | `cs_rank(X)` |
| `CsZscore(X)` | `cs_zscore(X)` |
| `TsMean(X, n)` | `ts_mean(X, n)` |
| `TsMax(X, n)` | `ts_max(X, n)` |
| `TsMin(X, n)` | `ts_min(X, n)` |
| `Std(X, n)` | `ts_std(X, n)` |
| `Delta(X, n)` | `ts_delta(X, n)` |
| `Delay(X, n)` | `ts_delay(X, n)` |
| `Corr(X, Y, n)` | `ts_corr(X, Y, n)` |
| `Cov(X, Y, n)` | `ts_cov(X, Y, n)` |
| `Sum(X, n)` | `ts_sum(X, n)` |
| `Return(X, n)` | `ts_pct_change(X, n)`(或 `X / ts_delay(X, n) - 1` |
| `EMA(X, n)` | `ts_ema(X, n)` |
| `WMA(X, n)` | `ts_wma(X, n)` |
| `SMA(X, n)` | `ts_mean(X, n)`FactorMiner 里的 SMA 即简单移动平均) |
| `Skew(X, n)` | `ts_skew(X, n)` |
| `Kurt(X, n)` | `ts_kurt(X, n)` |
| `Abs(X)` | `abs(X)` |
| `Sign(X)` | `sign(X)` |
| `Max(A, B)` | `max_(A, B)` |
| `Min(A, B)` | `min_(A, B)` |
| `IfElse(C, T, F)` | `if_(C, T, F)` |
| `$close` | `close` |
| `$volume` | `vol` |
| `$amt` | `amount` |
| `$vwap` | `amount / vol` |
| `$returns` | `close / ts_delay(close, 1) - 1` |
**未实现算子处理**
本地框架缺少以下 FactorMiner 算子,翻译时将其整条公式替换为 `# TODO: <原始公式>`
- `Decay(...)`
- `TsLinRegSlope(...)`
- `TsLinRegResid(...)`
- `Resid(...)`
- `Quantile(...)`
- `HMA(...)`
- `DEMA(...)`
**实现要点**
- 脚本解析 `PAPER_FACTORS` 中的字符串公式,使用括号递归栈做 AST 风格的拆分。
- 对 `LeafNode`(字段和数字常量)做直接映射;对 `OperatorNode` 做算子映射。
- 脚本输出新的 Python 列表代码,可直接复制并替换 `library_io.py` 中的 `PAPER_FACTORS`。
- 运行脚本后手动校验前 10 个公式的正确性,确保括号匹配。
**代码风格检查点**
- 脚本放 `scripts/` 目录,使用 `snake_case` 命名。
- 带中文 docstring打印统计`print("[translate] 成功 {n}/110TODO {m} 个")`。
---
## Step 2: 禁用 npz 并将库 I/O 对接本地 DSL
**文件**
- 修改:`src/factorminer/core/library_io.py`
- 修改:`src/factorminer/cli.py`(如有 `save_signals` 参数则改为始终 False
- 测试:`tests/test_factorminer_library_io.py`
**目标**
- 彻底禁止 `.npz` 信号缓存落盘。
- `PAPER_FACTORS` 中的公式已通过 Step 1 变为本地 DSL`import_from_paper()` 直接加载即可,不再做运行时翻译。
- 对于 Step 1 中标记为 `# TODO` 的公式,在构建 `FactorLibrary` 时设置 `factor.metadata["unsupported"] = True`。
**修改要点**
- `save_library(..., save_signals)`:无论传入什么,均忽略 `save_signals`,不写 `.npz`。
- `load_library(path)`:恢复 JSON 后,若公式以 `# TODO` 开头,标记 `unsupported=True`。
- `import_from_paper()`:由于 `PAPER_FACTORS` 已本地化,直接构建 `FactorLibrary`。
- 移除 `library_io.py` 中对 `ExpressionTree` / `canonicalizer` 的任何依赖(如果存在)。
**代码风格检查点**
- 废弃参数保留以兼容旧签名,但内部忽略。
- 打印日志说明 npz 已禁用:`print("[library_io] 信号缓存已禁用,仅保存 JSON 元数据")`。
---
## Step 3: LLM Prompt 改造(让 Agent 直接生成本地 DSL
**文件**
- 修改:`src/factorminer/factorminer/agent/prompt_builder.py`
- 修改:`src/factorminer/factorminer/agent/output_parser.py`
- 修改:`src/factorminer/factorminer/agent/factor_generator.py`(如有必要)
- 测试:`tests/test_factorminer_prompt.py`
**目标**
- 将 Prompt 中的 DSL 规范从 CamelCase + `$` 前缀改为本地 snake_case DSL。
- 所有示例公式替换为本地格式(如 `cs_rank(close / ts_delay(close, 5) - 1)`)。
- 明确可用字段:`open`, `high`, `low`, `close`, `vol`, `amount`, `vwap`(可用 `amount / vol` 计算)。
- LLM 输出直接是本地 DSL 字符串,解析层只需提取字符串,**不再**做 `$` 替换或 CamelCase 转换。
**修改要点**
- 重写 `SYSTEM_PROMPT` 中的 DSL 规则段落,列出现有函数名与字段名。
- 将所有 prompt 示例公式替换为本地 DSL。
- `OutputParser` 去掉 `$` 清洗逻辑;改为直接截取公式字符串(保留中文描述之外的纯公式部分)。
- `factor_generator.py` 中的 `generate` / `try_parse` 不再调用 FactorMiner 的 `ExpressionTree.from_string`,改为直接返回字符串(因为本地 DSL 由 `FactorEngine` 在计算时解析)。
**代码风格检查点**
- Prompt 内容易读、无 emoji。
- 单元测试验证 prompt 中包含的示例公式均为本地 DSL且 `OutputParser` 能正确提取。
---
## Step 4: `LocalFactorEvaluator`FactorEngine 执行封装)
**文件**
- 新建:`src/factorminer/factorminer/evaluation/local_engine.py`
- 测试:`tests/test_factorminer_local_engine.py`
**目标**
- 封装 `FactorEngine`,提供与 FactorMiner `compute_tree_signals` 兼容的输出接口。
- 输入:候选因子 DSL 列表(`(name, formula)`);输出:`{name: (M, T) np.ndarray}`。
- 无需外部数据加载器,直接利用 `FactorEngine` 内建的数据路由读取 `pro_bar` 表。
**类签名设计**
```python
class LocalFactorEvaluator:
def __init__(
self,
start_date: str,
end_date: str,
stock_codes: Optional[List[str]] = None,
) -> None:
"""初始化评估器。
Args:
start_date: 计算开始日期YYYYMMDD 格式
end_date: 计算结束日期YYYYMMDD 格式
stock_codes: 可选的股票代码列表None 表示全量
"""
...
def evaluate(
self,
specs: List[Tuple[str, str]],
) -> Dict[str, np.ndarray]:
"""批量计算并返回 {name: (M, T) 矩阵}。
Args:
specs: (因子名, 本地 DSL 公式) 列表
Returns:
每个因子对应的 (asset, time) numpy 矩阵,缺失值填充 np.nan
"""
...
def evaluate_single(
self,
name: str,
formula: str,
) -> np.ndarray:
"""计算单个因子。"""
...
def evaluate_returns(
self,
periods: int = 1,
) -> np.ndarray:
"""计算收益率矩阵,用于后续 IC / quintile 分析。
Returns:
(M, T) 的 forward returns 矩阵
"""
...
```
**实现要点**
- `evaluate` 中一次性注册所有 specs调用 `FactorEngine.compute(...)`。
- 返回的 Polars 长表按 `ts_code`(字母序)和 `trade_date`(时间序)`pivot` 为 numpy 矩阵。
- 缺失值填充 `np.nan`。
- 计算结束后调用 `engine.clear()`。
- `evaluate_returns` 计算 `ts_pct_change(close, periods)`(或 `close / ts_delay(close, periods) - 1`),同样 pivot 为矩阵。
**代码风格检查点**
- 严格的类型提示和中文 docstring。
- 日志打印:`print("[local_engine] 开始批量计算 {n} 个因子...")`。
---
## Step 5: 替换计算管线(`pipeline.py` / `runtime.py`
**文件**
- 修改:`src/factorminer/factorminer/evaluation/pipeline.py`
- 修改:`src/factorminer/factorminer/evaluation/runtime.py`
- 修改:`src/factorminer/factorminer/data/loader.py`(弃用标记,可选)
- 修改:`src/factorminer/factorminer/data/preprocessor.py`(弃用标记,可选)
- 修改:`src/factorminer/factorminer/data/tensor_builder.py`(弃用标记,可选)
- 测试:`tests/test_factorminer_pipeline_integration.py`
**目标**
- 移除 `compute_tree_signals(..., data_dict)` 及其对 FactorMiner 原生 `(M,T)` 数据面板的依赖。
- 所有信号计算统一通过 `LocalFactorEvaluator` 完成。
- 保留原有 IC、stats、quintile 分析逻辑。
**修改 `runtime.py` 要点**
- `EvaluationDataset` 不再持有 `data_dict` 和 `returns`。
- `evaluate_factors` 接收 `evaluator: LocalFactorEvaluator` 和 `returns: np.ndarray`。
- 不再需要 `load_runtime_dataset` 做面版预处理;改为由调用方直接构造 `evaluator`(指定日期范围)即可。
- 对每个 factor 调用 `evaluator.evaluate_single(name, formula)`;若 formula 以 `# TODO` 开头,标记为 `reject`。
- 保留 split-mask 和 stats 计算逻辑(它们只消费 `(M,T)` 矩阵,无需改动)。
**修改 `pipeline.py` 要点**
- `ValidationPipeline.__init__` 改为接收 `evaluator: LocalFactorEvaluator` 和 `returns: np.ndarray`。
- 删除 `compute_signals_fn` 参数(或保留为向后兼容的弃用参数)。
- `compute_tree_signals` 改为调用 `evaluator.evaluate_single(name, formula)`。
- `evaluate` 方法中,一次性批量计算所有候选因子的信号,再逐个进入 stats / correlation / replacement 阶段。
**代码风格检查点**
- 修改点精确定位,不改变评估函数的返回数据结构。
- 兼容测试通过后再提交。
---
## Step 6: 内存优化——库中因子按需重算
**文件**
- 修改:`src/factorminer/factorminer/core/factor_library.py`
- 测试:`tests/test_factorminer_library_memory.py`
**目标**
- 库内 `Factor` 对象不再长期持有 `(M, T)` numpy signals。
- 相关性检查改为按需调用 `LocalFactorEvaluator` 重算。
**修改要点**
- `admit()` 时不再保存 `signals` 到 `Factor` 对象。
- `compute_correlation` 签名改为接收 `evaluator: LocalFactorEvaluator, candidate_signals: np.ndarray`。
- 内部遍历库中因子,临时调用 `evaluator.evaluate_single(name, formula)` 计算信号,再与候选信号求相关。
- 若 formula 为 `# TODO` 则跳过(返回 `0.0`)。
- 删除 `_extend_correlation_matrix` / `_recompute_matrix_slot` 增量维护逻辑(改为动态求最大相关)。
**代码风格检查点**
- 废弃旧方法时保留空壳或私有方法,避免测试大面积报错。
- 中文注释说明为什么删除增量矩阵(本地引擎重算成本低,内存优先)。
---
## Step 7: 端到端集成测试110 Paper Factors
**文件**
- 新建:`tests/test_factorminer_e2e.py`
**目标**
- 验证迁移后的 110 个 paper factors 全部能在本地引擎上成功计算信号。
- 排除 `# TODO` 公式,统计实际可运行因子的成功率。
**测试逻辑**
1. 调用 `import_from_paper()` 加载因子库。
2. 实例化 `LocalFactorEvaluator(start_date="20200101", end_date="20201231")`。
3. 过滤掉 `unsupported=True` 的因子。
4. 批量计算剩余因子,断言输出形状为 `(M, T)` 且不含全 NaN。
5. 打印统计:`print("[e2e] 成功 {x}/110跳过 {y} 个未实现算子")`。
**代码风格检查点**
- 使用 `pytest.mark.slow` 标记(若运行时间 > 30 秒)。
- 不依赖外部 API Key。
---
## Step 8: 清理所有 checkpoint 和 demo 中的 npz 保存逻辑
**文件**
- 修改:`src/factorminer/factorminer/core/ralph_loop.py`
- 修改:`src/factorminer/factorminer/core/helix_loop.py`
- 修改:`src/factorminer/run_demo.py`
- 修改:`src/factorminer/run_phase2_benchmark.py`
- 修改:`src/factorminer/factorminer/benchmark/*.py`(如有 `save_signals` 调用)
**目标**
- 确保任何运行路径都不会意外触发 `.npz` 信号缓存落盘。
- 移除或注释掉所有 `library_io.save_library(..., save_signals=True)` 调用。
**修改要点**
- 搜索 `save_signals=True` 和 `.npz` 关键字,逐一处理。
- 改为 `save_signals=False` 或直接调用不带该参数的 `save_library`。
---
## Step 9: 代码风格审查、测试全量回归与提交
**执行清单**
1. 运行 `uv run pytest tests/test_factorminer_* -v`,确保全部通过。
2. 运行 `uv run pytest tests/test_factor_engine.py tests/test_factor_integration.py -v`,确保本地框架未受影响。
3. 检查新增代码中是否混入 emoji。
4. 检查新增代码的导入顺序和 docstring 完整性。
5. 提交前做一次 `git diff --stat`,确认没有误删或大规模重写无关文件。
**提交建议**
- 按模块分几个 commit而不是一个巨大的 commit。
- 使用 Conventional Commits 风格(`feat:` / `refactor:` / `perf:` / `test:`)。
---
## 风险与 TODO
| 风险 | 应对 |
|------|------|
| FactorMiner 某些算子本地框架没有实现 | 一次性脚本翻译时标记 `# TODO``unsupported=True` 的因子在评估阶段直接 reject |
| `FactorEngine` 在极宽表(>1000 列)时内存激增 | `LocalFactorEvaluator` 以 batch 为单位分批计算,并配合 `engine.clear()` |
| 本地 `pro_bar` 表数据不完整或缺少某些日期 | `FactorEngine` 本身有数据完整性校验;缺失率过高时会在计算阶段报错 |
| `OutputParser` 对本地 DSL 的括号/逗号解析不兼容 | 修改 `OutputParser` 的清洗正则,增加单元测试 |
| 110 个 paper factors 中有大量使用未实现算子 | 统计 TODO 比例,若 >30% 则优先在本地框架补充 `ts_linreg_slope`、`ts_decay` 等高频算子 |
---
## 附:核心模块依赖关系(修订后)
```
┌─────────────────────────────┐
│ scripts/translate_paper_ │ ← 一次性脚本(跑完即删除/保留归档)
│ factors.py │
└─────────────┬───────────────┘
│ 替换 PAPER_FACTORS
┌─────────────────────────────┐
│ library_io.py │ ← 禁用 npz公式已本地 DSL 化
└─────────────┬───────────────┘
│ 加载 FactorLibrary
┌─────────────────────────────┐
│ LocalFactorEvaluator │ ← FactorEngine (read_only 自动读取数据)
│ (local_engine.py) │
└─────────────┬───────────────┘
┌─────┴─────┐
▼ ▼
pipeline.py runtime.py ← 保留 FactorMiner 的 stats / metrics / admission 逻辑
factor_library.py ← 按需重算,不保存 signals
prompt_builder.py ← LLM 直接生成本地 DSL
```