"""因子引擎:因子**模板**(含可编辑参数)、实例注册表与计算(Phase 2 起,2026-10 参数化)。 ## 三层概念(这是本模块的核心约定) 1. **模板(FactorTemplate)**:算法的家族,如 `momentum`(动量)、`volatility`(波动率)。 模板声明「哪些参数可编辑、允许范围、默认值」以及计算函数 `fn(fields, params)`。 2. **参数(params)**:模板的可编辑取值,如 `window=90`、`direction=lower_is_better`。 参数约束是**受控范围**(整数区间 / 枚举),不允许自由值 —— 见 AGENT.md §24: 写不进去就报错,绝不静默接受一个引擎其实不支持的设置。 3. **因子实例(FactorDef)**:`(模板, 参数)` 的具体因子,**名字里带着全部参数**: momentum_60 ← 内置实例(代码里登记的历史名) momentum(window=90,direction=higher_is_better) ← 参数化实例(目录里创建) 实例名即身份:参数写进名字,任何地方(策略 JSON、归档 spec、组合组件、条件字段) 存下这个名字,就同时冻结了「用哪个模板 + 哪些参数」——**历史归档不会因为之后 改了什么参数而改变含义**。这也是为什么不把参数放在另一个字段里:那需要改动 所有已经存了因子名的地方(策略/归档/回放/信号/Agent 工具),而且容易漏。 ## 参数与默认值:为什么键里总是写全 direction 键里**不省略任何可编辑参数**(哪怕等于模板默认值)。若省略,`momentum(window=90)` 的含义就取决于「模板默认方向」这一代码事实:将来代码把默认方向一改,用户已经存下的 策略/归档会跟着变义 —— 与「单位」那次拒绝的做法同理。写全参数后,键自解释、 不依赖任何默认值,改默认值只影响新建实例。 ## 兼容性 `get_factor()` 现在能吃两种名字:注册表里的历史名(内置实例)与参数化键; `compute_factor()`、`list_factors()` 的行为保持不变,因此下游(选股/回测/组合/ 说明书/条件字段/Agent 工具)无需感知参数化的存在,也**不会绕过参数校验**。 数据形态:行情长表 DataFrame(列 symbol/trade_date/close/high/low/volume/amount, 以及经 ResearchService 并入的每日指标列如 dv_ratio/dv_ttm), 因子计算返回 面板 DataFrame(index=trade_date,columns=symbol)。 行情内置因子只用行情字段(无财务),天然规避未来函数;每日指标(daily_basic) 为逐日时点值,按 trade_date <= as_of 取值同样无未来函数; 财务因子接入时必须以 announce_date 控制可见性(见 domain.entities.market.FinancialIndicator)。 """ from __future__ import annotations import inspect import re from collections.abc import Callable, Mapping from dataclasses import dataclass, field from typing import Any import pandas as pd DIRECTION_HIGHER = "higher_is_better" DIRECTION_LOWER = "lower_is_better" DIRECTIONS = (DIRECTION_HIGHER, DIRECTION_LOWER) # 参数名常量(键里的字面量,改它等于改所有已存键的含义,别改) P_WINDOW = "window" P_FAST = "fast" P_SLOW = "slow" P_DIRECTION = "direction" # 窗口参数的允许范围:受控区间而非固定档(任意整数都能算,但要有边界)。 # 上限 500 个交易日 ≈ 两年,够长;下限 2 是因为 shift(1)/rolling(1) 的波动率无意义。 WINDOW_MIN = 2 WINDOW_MAX = 500 @dataclass(frozen=True) class ParamSpec: """一个可编辑参数的约束(受控范围,越界一律报错而不是截断/静默忽略)。""" name: str label: str kind: str # "int" | "enum" default: Any minimum: int | None = None maximum: int | None = None choices: tuple[str, ...] = () note: str = "" def describe(self) -> str: """人类可读的约束说明(用于错误文案与目录展示)。""" if self.kind == "enum": return "、".join(self.choices) if self.minimum is not None and self.maximum is not None: return f"{self.minimum} ~ {self.maximum} 的整数" return "整数" DIRECTION_SPEC = ParamSpec( name=P_DIRECTION, label="方向", kind="enum", default=DIRECTION_HIGHER, choices=DIRECTIONS, note="越高越好 / 越低越好:决定复合分里的排序方向(低为好自动取负)。", ) @dataclass(frozen=True) class FactorDef: """因子元数据(AGENT.md §22 要求逐项明确)。 新增字段都带默认值:历史代码用位置参数构造 FactorDef 的地方不受影响。 """ name: str description: str formula: str brief: str = "" # 一句话使用简介(面向用户:怎么用、什么时候有效) frequency: str = "daily" lookback: int = 20 direction: str = "higher_is_better" # | lower_is_better requires: tuple[str, ...] = ("close",) # ---- 参数化(2026-10)---- template: str = "" # 模板名,如 "momentum";空串 = 手工登记的老式因子 params: Mapping[str, Any] = field(default_factory=dict) # 冻结的参数取值 param_specs: tuple[ParamSpec, ...] = () # 可编辑参数与约束(供目录/界面) source: str = "builtin" # builtin(代码注册表)| custom(目录里创建的参数化实例) label: str = "" # 中文显示名(含参数),如「动量(窗口 90,越高越好)」 @property def display(self) -> str: """界面用显示名:没有 label 时退回 name(老因子/自定义登记行)。""" return self.label or self.name FactorFn = Callable[[dict[str, pd.DataFrame], Mapping[str, Any]], pd.DataFrame] @dataclass(frozen=True) class FactorTemplate: """算法家族 + 可编辑参数声明 + 内置实例(历史名)。""" name: str label: str # 中文家族名,如「动量」 description: str # 可含 {window} / {fast} / {slow} 占位 formula: str brief: str fn: FactorFn param_specs: tuple[ParamSpec, ...] = () requires: tuple[str, ...] = ("close",) frequency: str = "daily" direction_default: str = DIRECTION_HIGHER lookback_of: Callable[[Mapping[str, Any]], int] | None = None check: Callable[[Mapping[str, Any]], str | None] | None = None # 跨参数约束 instances: tuple[tuple[str, Mapping[str, Any]], ...] = () # ((历史名, 参数), ...) label_of: Callable[[Mapping[str, Any]], str] | None = None def specs(self) -> tuple[ParamSpec, ...]: """全部可编辑参数(模板自己的参数 + 方向,方向恒在最后)。""" direction = ParamSpec( name=DIRECTION_SPEC.name, label=DIRECTION_SPEC.label, kind=DIRECTION_SPEC.kind, default=self.direction_default, choices=DIRECTION_SPEC.choices, note=DIRECTION_SPEC.note, ) return (*self.param_specs, direction) def defaults(self) -> dict[str, Any]: return {s.name: s.default for s in self.specs()} class FactorError(ValueError): pass _REGISTRY: dict[str, tuple[FactorDef, FactorFn]] = {} _TEMPLATES: dict[str, FactorTemplate] = {} # 参数化键:template(k=v,k=v)。模板名与参数名限定为标识符,值限定为标识符/数字, # 避免出现靠运气才能解析的名字(宁可在创建时就被拒)。 _KEY_RE = re.compile(r"^(?P