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

17 KiB
Raw Blame History

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 | NonePython 3.10+
    • 复杂类型从 typing 导入:Dict, List, Callable, Tuple, Any
  3. 文档字符串

    • 中文 Google 风格
    • 第一行为简短摘要
    • 必须包含 Args:Returns: 段落
  4. 导入顺序

    # 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. 日志与输出

    • 使用带前缀的 printprint("[模块名] 消息")
    • 循环进度使用 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 变为本地 DSLimport_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 中包含的示例公式均为本地 DSLOutputParser 能正确提取。

Step 4: LocalFactorEvaluatorFactorEngine 执行封装)

文件

  • 新建: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 表。

类签名设计

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_dictreturns
  • evaluate_factors 接收 evaluator: LocalFactorEvaluatorreturns: 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: LocalFactorEvaluatorreturns: 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() 时不再保存 signalsFactor 对象。
  • 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 某些算子本地框架没有实现 一次性脚本翻译时标记 # TODOunsupported=True 的因子在评估阶段直接 reject
FactorEngine 在极宽表(>1000 列)时内存激增 LocalFactorEvaluator 以 batch 为单位分批计算,并配合 engine.clear()
本地 pro_bar 表数据不完整或缺少某些日期 FactorEngine 本身有数据完整性校验;缺失率过高时会在计算阶段报错
OutputParser 对本地 DSL 的括号/逗号解析不兼容 修改 OutputParser 的清洗正则,增加单元测试
110 个 paper factors 中有大量使用未实现算子 统计 TODO 比例,若 >30% 则优先在本地框架补充 ts_linreg_slopets_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