用户要求:「所有买卖点详细说明买卖理由,用数据说话」「回测图上增加因子相关曲线
(买卖依据是股息率,就加股息率曲线)」「所有曲线能弹出新页面放大」。
一、买卖理由(后端产出结构化数据,前端只展示)
- 新增 `quant/trade_reasons.py`:封闭词表 + 文案构造器,组合引擎与单策略引擎共用,
避免两个引擎对同一件事写出两种说法。理由里带**引擎当时的真实数字**:
综合分名次/候选数/综合分/各因子原始值/持有交易日/预算与最低佣金/涨停比值等。
- 买入:按名次建仓、顺延成交、涨停未买、停牌未买、现金不足、不足最低佣金;
卖出:跌出 TopN(含第几名掉出)、被股票池过滤(与「跌出 TopN」分开写)、
超 Tmax 强制了结、Tmin 保护暂留、停牌/跌停顺延。
- `ActionRecord.reason` 覆盖**成交与未成交**全部买卖点(原 `reject_reason` 保留不动,
老归档仍可读);`Trade.entry_reason / exit_reason` 跟着成交记录走。
- 名次来自调仓日完整排名(新增 `_ranked_by_day`),拿不到名次时如实写「未给出名次」,
绝不编造一个名次填进去。
- 未成交明细不再只写执行层原因:把「为什么选中它、当时各因子多少」一并给出。
二、因子曲线
- `FactorCurve`:每个策略因子一条曲线,值为**当日持仓按市值加权平均的原始值**
(不做 z-score、不按方向取反,空仓日不落点、不插值、不用 0 填充),并带
label/direction/unit 供界面说明口径;`FactorDef/FactorTemplate` 新增 `unit`
(股息率 %、量比/接近新高 倍数、动量等 小数),11 个内置因子实例已逐一核对。
- 归档体积预算照旧按整包计量,无需改迁移。
三、界面
- 结果页新增「买卖说明」区块:全部买卖点 + 理由 + 数字标签,支持方向/成交状态/关键字
筛选与日期排序;成交明细表加「为什么买 / 为什么卖」两列;新增「因子曲线」区块,
每条曲线标出组合成交日,直接对照「买卖发生在什么水平」。
- 「新页面放大」:每条曲线(净值/回撤/因子/个股/月度)都能开 `/charts/{归档id}?s=...`
整页看大图;放大页是 Server Component,数据从归档直出,URL 可分享且与归档一致。
未归档的结果如实说明「未归档,无法放大」,不给坏链接。
- 数字格式与后端 `f"{v:.4f}"` 同规则(四舍六入五成双):修掉 0.03125 在理由原文里
显示 0.0312、旁边标签显示 0.0313 的不一致(17 组边界值与 Python 逐一比对一致)。
- `/factors/compose` 结果区改用同一个 `BacktestResultView`,两处口径不会再漂移。
验证:
- 新增 `tests/test_trade_reasons.py` 8 条(买入数字、跌出 TopN 名次、不在候选池、
Tmax、Tmin 暂留、涨停未成交、因子曲线加权值、空仓不落点);后端 510 条全过,ruff clean。
- 真实数据端到端:`/api/combos/run` 6 个月高股息组合(EXP-8EA2819B)13 个买卖点
100% 带理由与数字,因子曲线 dividend_yield 117 点、单位 %;
`scripts/verify_backtest_page_contract.py`(4 年、301 个买卖点、140 笔成交)扩展断言
理由词表/名次/因子值/曲线单调性后通过。
- 浏览器实测:归档详情页与放大页 `/charts/...?s=factor:dividend_yield` 等 5 种曲线
全部 200 渲染,截图确认表格与曲线数值正确。
678 lines
27 KiB
Python
678 lines
27 KiB
Python
"""因子引擎:因子**模板**(含可编辑参数)、实例注册表与计算(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,越高越好)」
|
||
unit: str | None = None # 因子值的量纲(% / 倍数 / 小数),供图表坐标轴与说明用
|
||
|
||
@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
|
||
# 因子值的量纲(由算法口径决定,不是可调参数):
|
||
# "%" = 数值本身就是百分数(股息率 5.2 读作 5.2%)
|
||
# "倍数" = 比值(量比 1.2 表示 1.2 倍)
|
||
# "小数" = 无单位比例,0.15 表示 15%(图上按小数显示,不做 ×100 换算)
|
||
unit: str | None = None
|
||
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<template>[A-Za-z_][A-Za-z0-9_]*)\((?P<args>[^()]*)\)$")
|
||
_ARG_RE = re.compile(r"^(?P<key>[A-Za-z_][A-Za-z0-9_]*)=(?P<value>[A-Za-z_][A-Za-z0-9_]*|-?\d+)$")
|
||
|
||
|
||
def _accepts_params(fn: Callable) -> bool:
|
||
"""判断计算函数是否声明了 params 形参(兼容老的一参数写法)。"""
|
||
try:
|
||
params = inspect.signature(fn).parameters
|
||
except (TypeError, ValueError): # 内建/C 实现,按老写法处理
|
||
return False
|
||
if any(p.kind is inspect.Parameter.VAR_POSITIONAL for p in params.values()):
|
||
return True
|
||
positional = [
|
||
p
|
||
for p in params.values()
|
||
if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
||
]
|
||
return len(positional) >= 2
|
||
|
||
|
||
def _bind(fn: Callable) -> FactorFn:
|
||
"""把计算函数统一成 (fields, params) 两参数调用。"""
|
||
if _accepts_params(fn):
|
||
|
||
def _bound(fields, params, _fn=fn):
|
||
return _fn(fields, params)
|
||
|
||
return _bound
|
||
|
||
def _legacy(fields, params, _fn=fn):
|
||
return _fn(fields)
|
||
|
||
return _legacy
|
||
|
||
|
||
def register_template(template: FactorTemplate) -> FactorTemplate:
|
||
"""注册模板,并把它的内置实例(历史名)登记进注册表。"""
|
||
if template.name in _TEMPLATES:
|
||
raise FactorError(f"模板 {template.name} 已注册")
|
||
_TEMPLATES[template.name] = template
|
||
for name, params in template.instances:
|
||
defn = build_factor_def(template, params, name=name, source="builtin")
|
||
_REGISTRY[name] = (defn, _bind(template.fn))
|
||
return template
|
||
|
||
|
||
def register(defn: FactorDef) -> Callable[[FactorFn], FactorFn]:
|
||
"""装饰器:注册**自定义因子**(老式登记,无参数化;测试与扩展用)。"""
|
||
|
||
def deco(fn: FactorFn) -> FactorFn:
|
||
if defn.name in _REGISTRY:
|
||
raise FactorError(f"因子 {defn.name} 已注册")
|
||
_REGISTRY[defn.name] = (defn, _bind(fn))
|
||
return fn
|
||
|
||
return deco
|
||
|
||
|
||
def list_templates() -> list[FactorTemplate]:
|
||
return [t for _n, t in sorted(_TEMPLATES.items())]
|
||
|
||
|
||
def get_template(name: str) -> FactorTemplate:
|
||
if name not in _TEMPLATES:
|
||
raise FactorError(f"未知因子模板:{name}(可用:{', '.join(sorted(_TEMPLATES))})")
|
||
return _TEMPLATES[name]
|
||
|
||
|
||
def _render(text: str, params: Mapping[str, Any]) -> str:
|
||
"""渲染带 {param} 占位的文案;没有占位就原样返回(不做 format,避免误伤花括号)。"""
|
||
if "{" not in text:
|
||
return text
|
||
try:
|
||
return text.format(**params)
|
||
except KeyError as exc: # 模板写错占位名 —— 宁可当场炸,也不要漏出半成品文案
|
||
raise FactorError(f"因子文案占位符缺少参数 {exc}:{text}") from None
|
||
|
||
|
||
def _param_summary(template: FactorTemplate, params: Mapping[str, Any]) -> str:
|
||
"""非方向参数的摘要,如「窗口 90」「快线 5、慢线 60」。"""
|
||
return "、".join(f"{spec.label} {params[spec.name]}" for spec in template.param_specs)
|
||
|
||
|
||
def _default_label(template: FactorTemplate, params: Mapping[str, Any]) -> str:
|
||
summary = _param_summary(template, params)
|
||
direction = "越高越好" if params[P_DIRECTION] == DIRECTION_HIGHER else "越低越好"
|
||
inner = ",".join([p for p in (summary, direction) if p])
|
||
return f"{template.label}({inner})"
|
||
|
||
|
||
def fill_params(template: FactorTemplate, params: Mapping[str, Any]) -> dict[str, Any]:
|
||
"""校验参数并补全缺省值(**创建路径**用:内置实例、目录里新建参数化因子)。
|
||
|
||
只认声明过的参数,类型/范围/枚举全部受控,跨参数约束另查 —— 缺的参数取模板默认值。
|
||
"""
|
||
specs = {s.name: s for s in template.specs()}
|
||
unknown = sorted(set(params) - set(specs))
|
||
if unknown:
|
||
raise FactorError(
|
||
f"因子模板 {template.name} 不支持的参数:{', '.join(unknown)};"
|
||
f"可编辑参数只有 {', '.join(specs)}。参数不能随便加 —— 引擎算不了的要当场拒绝。"
|
||
)
|
||
merged: dict[str, Any] = {**template.defaults(), **params}
|
||
return _check_all(template, merged)
|
||
|
||
|
||
def validate_params(template: FactorTemplate, params: Mapping[str, Any]) -> dict[str, Any]:
|
||
"""严格校验:**每个参数都必须显式给出**(解析已存的因子键用)。
|
||
|
||
为什么解析时不许省:省掉的参数只能靠「模板默认值」补,而默认值是会随代码改的
|
||
事实 —— 一旦改了,用户早就存下的策略/归档就会跟着变义(模块头详述)。
|
||
"""
|
||
specs = {s.name: s for s in template.specs()}
|
||
unknown = sorted(set(params) - set(specs))
|
||
if unknown:
|
||
raise FactorError(
|
||
f"因子模板 {template.name} 不支持的参数:{', '.join(unknown)};"
|
||
f"可编辑参数只有 {', '.join(specs)}。"
|
||
)
|
||
missing = sorted(set(specs) - set(params))
|
||
if missing:
|
||
canonical = canonical_key(template, params)
|
||
raise FactorError(
|
||
f"因子键缺少参数:{', '.join(missing)}。键里必须写全所有参数"
|
||
f"(否则含义会取决于模板默认值);规范写法:{canonical}"
|
||
)
|
||
return _check_all(template, params)
|
||
|
||
|
||
def _check_all(template: FactorTemplate, params: Mapping[str, Any]) -> dict[str, Any]:
|
||
out = {spec.name: _check_param(template, spec, params[spec.name]) for spec in template.specs()}
|
||
if template.check is not None:
|
||
problem = template.check(out)
|
||
if problem:
|
||
raise FactorError(f"因子模板 {template.name} 参数不合法:{problem}")
|
||
return out
|
||
|
||
|
||
def _check_param(template: FactorTemplate, spec: ParamSpec, value: Any) -> Any:
|
||
if spec.kind == "enum":
|
||
if not isinstance(value, str) or value not in spec.choices:
|
||
raise FactorError(
|
||
f"因子「{template.name}」的参数 {spec.name}={value!r} 不合法:"
|
||
f"只能是 {spec.describe()}。"
|
||
)
|
||
return value
|
||
if isinstance(value, str): # 键里解析出来的是字符串,数字要能转
|
||
try:
|
||
value = int(value)
|
||
except ValueError:
|
||
raise FactorError(
|
||
f"因子「{template.name}」的参数 {spec.name}={value!r} 不是整数:"
|
||
f"应为 {spec.describe()}。"
|
||
) from None
|
||
if not isinstance(value, int) or isinstance(value, bool):
|
||
raise FactorError(
|
||
f"因子「{template.name}」的参数 {spec.name}={value!r} 不是整数:"
|
||
f"应为 {spec.describe()}。"
|
||
)
|
||
if spec.minimum is not None and value < spec.minimum:
|
||
raise FactorError(
|
||
f"因子「{template.name}」的参数 {spec.name}={value} 太小:应为 {spec.describe()}。"
|
||
)
|
||
if spec.maximum is not None and value > spec.maximum:
|
||
raise FactorError(
|
||
f"因子「{template.name}」的参数 {spec.name}={value} 太大:应为 {spec.describe()}。"
|
||
)
|
||
return value
|
||
|
||
|
||
def build_factor_def(
|
||
template: FactorTemplate,
|
||
params: Mapping[str, Any],
|
||
*,
|
||
name: str,
|
||
source: str = "custom",
|
||
) -> FactorDef:
|
||
"""由模板 + 参数构造因子实例(未知/越界参数在此处被拒)。"""
|
||
checked = fill_params(template, params)
|
||
lookback = template.lookback_of(checked) if template.lookback_of else 0
|
||
label_of = template.label_of or (lambda p: _default_label(template, p))
|
||
return FactorDef(
|
||
name=name,
|
||
description=_render(template.description, checked),
|
||
formula=_render(template.formula, checked),
|
||
brief=_render(template.brief, checked),
|
||
frequency=template.frequency,
|
||
lookback=lookback,
|
||
direction=checked[P_DIRECTION],
|
||
requires=template.requires,
|
||
template=template.name,
|
||
params=dict(checked),
|
||
param_specs=template.specs(),
|
||
source=source,
|
||
label=label_of(checked),
|
||
unit=template.unit,
|
||
)
|
||
|
||
|
||
def canonical_key(template: FactorTemplate | str, params: Mapping[str, Any]) -> str:
|
||
"""参数化实例的规范名:参数按模板声明顺序写全(含方向),如
|
||
|
||
momentum(window=90,direction=higher_is_better)
|
||
|
||
写全的好处见模块头:键自解释,不依赖任何默认值。
|
||
"""
|
||
tpl = get_template(template) if isinstance(template, str) else template
|
||
checked = fill_params(tpl, params)
|
||
args = ",".join(f"{spec.name}={checked[spec.name]}" for spec in tpl.specs())
|
||
return f"{tpl.name}({args})"
|
||
|
||
|
||
def parse_factor_key(name: str) -> tuple[FactorTemplate, dict[str, Any]]:
|
||
"""解析参数化键 → (模板, 参数);不是参数化键或参数不合法都抛 FactorError。"""
|
||
m = _KEY_RE.match(name.strip())
|
||
if not m:
|
||
raise FactorError(
|
||
f"因子键格式不对:{name};内置因子用注册表名(如 momentum_60),"
|
||
"参数化因子用 template(k=v,...)(如 momentum(window=90,direction=higher_is_better))"
|
||
)
|
||
template_name = m.group("template")
|
||
try:
|
||
template = get_template(template_name)
|
||
except FactorError as exc:
|
||
if template_name in _REGISTRY:
|
||
raise FactorError(
|
||
f"{template_name} 是内置因子实例名,不能在它上面再带参数;"
|
||
"要参数化请用模板名,例如 momentum(window=20,direction=higher_is_better)"
|
||
) from None
|
||
raise exc
|
||
raw: dict[str, Any] = {}
|
||
args = m.group("args").strip()
|
||
if args:
|
||
for part in args.split(","):
|
||
am = _ARG_RE.match(part.strip())
|
||
if not am:
|
||
raise FactorError(
|
||
f"因子键里的参数写法不对:{part.strip()};应为 名=值(值只能是整数或标识符)"
|
||
)
|
||
key = am.group("key")
|
||
if key in raw:
|
||
raise FactorError(f"因子键里参数重复:{key}")
|
||
raw[key] = am.group("value")
|
||
return template, validate_params(template, raw)
|
||
|
||
|
||
def _derived(name: str) -> tuple[FactorDef, FactorFn]:
|
||
template, params = parse_factor_key(name)
|
||
key = canonical_key(template, params)
|
||
if key != name.strip():
|
||
raise FactorError(
|
||
f"因子键 {name} 不是规范写法:同样参数请写成 {key}"
|
||
"(参数顺序固定、值要写全,避免同一个因子出现多个名字)"
|
||
)
|
||
defn = build_factor_def(template, params, name=key, source="custom")
|
||
return defn, _bind(template.fn)
|
||
|
||
|
||
def resolve_factor(name: str) -> tuple[FactorDef, FactorFn]:
|
||
"""按名字取因子(注册表历史名 / 参数化键都行),取不到就抛可读错误。"""
|
||
if name in _REGISTRY:
|
||
return _REGISTRY[name]
|
||
return _derived(name)
|
||
|
||
|
||
def is_resolvable(name: str) -> bool:
|
||
try:
|
||
resolve_factor(name)
|
||
except FactorError:
|
||
return False
|
||
return True
|
||
|
||
|
||
# 兼容旧名:所有既有调用点(选股/回测/组合/说明书/条件字段/Agent 工具)自动支持参数化键。
|
||
get_factor = resolve_factor
|
||
|
||
|
||
def list_factors() -> list[FactorDef]:
|
||
"""注册表里的**内置实例**(历史名),按名字排序(目录 seed 用)。"""
|
||
return [d for d, _fn in sorted(_REGISTRY.values(), key=lambda x: x[0].name)]
|
||
|
||
|
||
def compute_factor(name: str, daily: pd.DataFrame) -> tuple[FactorDef, pd.DataFrame]:
|
||
"""计算因子:从行情长表提取所需字段的面板后调用因子函数。"""
|
||
defn, fn = resolve_factor(name)
|
||
fields: dict[str, pd.DataFrame] = {}
|
||
for col in defn.requires:
|
||
panel = daily.pivot(index="trade_date", columns="symbol", values=col).sort_index()
|
||
panel.index = pd.to_datetime(panel.index)
|
||
fields[col] = panel
|
||
return defn, fn(fields, defn.params)
|
||
|
||
|
||
# ---------- 内置因子模板 ----------
|
||
# 每个模板的 instances 是历史名 + 它的参数:这些名字已经存在于策略/归档/文档/测试里,
|
||
# 必须继续可解析,所以它们不是「参数化键」,而是代码登记的实例。
|
||
|
||
|
||
def _window_spec(label: str = "窗口") -> ParamSpec:
|
||
return ParamSpec(
|
||
name=P_WINDOW,
|
||
label=label,
|
||
kind="int",
|
||
default=20,
|
||
minimum=WINDOW_MIN,
|
||
maximum=WINDOW_MAX,
|
||
note=f"{WINDOW_MIN}~{WINDOW_MAX} 个交易日;改窗口 = 换一个因子身份(新键),"
|
||
"旧键仍按旧参数计算。",
|
||
)
|
||
|
||
|
||
def _rolling_return(prices: pd.DataFrame, lookback: int) -> pd.DataFrame:
|
||
return prices / prices.shift(lookback) - 1.0
|
||
|
||
|
||
def _rolling_vol(prices: pd.DataFrame, lookback: int) -> pd.DataFrame:
|
||
return prices.pct_change().rolling(lookback).std()
|
||
|
||
|
||
register_template(
|
||
FactorTemplate(
|
||
name="momentum",
|
||
label="动量",
|
||
description="过去 {window} 个交易日收益率",
|
||
formula="close / close.shift({window}) - 1",
|
||
brief="动量:强者延续,适合趋势延续环境;窗口越短越敏感、越长越稳。",
|
||
unit="小数",
|
||
fn=lambda fields, params: _rolling_return(fields["close"], params[P_WINDOW]),
|
||
param_specs=(_window_spec(),),
|
||
lookback_of=lambda params: params[P_WINDOW],
|
||
instances=(
|
||
("momentum_20", {P_WINDOW: 20}),
|
||
("momentum_60", {P_WINDOW: 60}),
|
||
("momentum_120", {P_WINDOW: 120}),
|
||
),
|
||
)
|
||
)
|
||
|
||
register_template(
|
||
FactorTemplate(
|
||
name="volatility",
|
||
label="波动率",
|
||
description="过去 {window} 个交易日收益率波动率",
|
||
formula="std(pct_change, {window})",
|
||
brief="低波动防御:近段波动小的股票抗跌,弱市/熊市阶段相对占优(方向越低越好)。",
|
||
unit="小数",
|
||
fn=lambda fields, params: _rolling_vol(fields["close"], params[P_WINDOW]),
|
||
param_specs=(_window_spec(),),
|
||
direction_default=DIRECTION_LOWER,
|
||
lookback_of=lambda params: params[P_WINDOW],
|
||
instances=(
|
||
("volatility_20", {P_WINDOW: 20}),
|
||
("volatility_60", {P_WINDOW: 60}),
|
||
),
|
||
)
|
||
)
|
||
|
||
register_template(
|
||
FactorTemplate(
|
||
name="close_to_high",
|
||
label="接近新高",
|
||
description="收盘价相对 {window} 日最高价的接近程度",
|
||
formula="close / rolling_max(high, {window})",
|
||
brief="贴近 n 日高点(接近新高):趋势确认型强势股,常与动量互补;需配合市场热度判断。",
|
||
unit="倍数",
|
||
fn=lambda fields, params: fields["close"] / fields["high"].rolling(params[P_WINDOW]).max(),
|
||
param_specs=(_window_spec(),),
|
||
requires=("close", "high"),
|
||
lookback_of=lambda params: params[P_WINDOW],
|
||
instances=(("close_to_high_60", {P_WINDOW: 60}),),
|
||
)
|
||
)
|
||
|
||
|
||
def _check_fast_slow(params: Mapping[str, Any]) -> str | None:
|
||
if params[P_FAST] >= params[P_SLOW]:
|
||
return f"快线窗口({params[P_FAST]}) 必须小于慢线窗口({params[P_SLOW]})"
|
||
return None
|
||
|
||
|
||
def _volume_ratio_label(params: Mapping[str, Any]) -> str:
|
||
direction = "越高越好" if params[P_DIRECTION] == DIRECTION_HIGHER else "越低越好"
|
||
return f"量比({params[P_FAST]}/{params[P_SLOW]} 日,{direction})"
|
||
|
||
|
||
register_template(
|
||
FactorTemplate(
|
||
name="volume_ratio",
|
||
label="量比",
|
||
description="量比:{fast} 日均量 / {slow} 日均量",
|
||
formula="mean(volume, {fast}) / mean(volume, {slow})",
|
||
brief="量比放大提示资金关注(短线活跃型);高换手也伴随更高波动,注意与波动因子搭配。",
|
||
unit="倍数",
|
||
fn=lambda fields, params: (
|
||
fields["volume"].rolling(params[P_FAST]).mean()
|
||
/ fields["volume"].rolling(params[P_SLOW]).mean()
|
||
),
|
||
param_specs=(
|
||
ParamSpec(
|
||
name=P_FAST,
|
||
label="快线",
|
||
kind="int",
|
||
default=5,
|
||
minimum=WINDOW_MIN,
|
||
maximum=WINDOW_MAX,
|
||
note="短窗口天数,必须小于慢线。",
|
||
),
|
||
ParamSpec(
|
||
name=P_SLOW,
|
||
label="慢线",
|
||
kind="int",
|
||
default=60,
|
||
minimum=WINDOW_MIN,
|
||
maximum=WINDOW_MAX,
|
||
note="长窗口天数,决定回看长度。",
|
||
),
|
||
),
|
||
requires=("volume",),
|
||
lookback_of=lambda params: params[P_SLOW],
|
||
check=_check_fast_slow,
|
||
instances=(("volume_ratio_5_60", {P_FAST: 5, P_SLOW: 60}),),
|
||
label_of=_volume_ratio_label,
|
||
)
|
||
)
|
||
|
||
register_template(
|
||
FactorTemplate(
|
||
name="ma_bias",
|
||
label="均线乖离",
|
||
description="{window} 日均线乖离率",
|
||
formula="(close - ma(close, {window})) / ma(close, {window})",
|
||
brief="均线乖离:上行趋势中正乖离偏强;乖离过大易回落,需警惕过热。",
|
||
unit="小数",
|
||
fn=lambda fields, params: (
|
||
(fields["close"] - fields["close"].rolling(params[P_WINDOW]).mean())
|
||
/ fields["close"].rolling(params[P_WINDOW]).mean()
|
||
),
|
||
param_specs=(_window_spec(),),
|
||
lookback_of=lambda params: params[P_WINDOW],
|
||
instances=(("ma_bias_20", {P_WINDOW: 20}),),
|
||
)
|
||
)
|
||
|
||
register_template(
|
||
FactorTemplate(
|
||
name="reversal",
|
||
label="短期反转",
|
||
description="短期反转:过去 {window} 日收益率取负",
|
||
formula="-1 * (close / close.shift({window}) - 1)",
|
||
brief="短期反转:前期跌幅大的超跌反弹机会,适合震荡/修复行情。",
|
||
unit="小数",
|
||
fn=lambda fields, params: -1.0 * _rolling_return(fields["close"], params[P_WINDOW]),
|
||
param_specs=(_window_spec(),),
|
||
lookback_of=lambda params: params[P_WINDOW],
|
||
instances=(("reversal_5", {P_WINDOW: 5}),),
|
||
)
|
||
)
|
||
|
||
# 特别分红导致的股息率畸高阈值(%):dv_ratio 会因一次性特别分红冲到 30%+,
|
||
# 直接用「最高股息率」排序会被这类非经常性事件占满头部(实测 600738 在 2020-01-02
|
||
# 为 37.2%)。本因子不隐式截断(截断属选股条件,应由用户在 conditions 里显式配置)。
|
||
# 注意:该常量目前**没有**任何代码引用(曾计划供条件模板引用);要按此上限过滤,
|
||
# 请在策略条件里显式配置 dv_ratio <= 30,而不是指望因子内部截断。
|
||
DIVIDEND_YIELD_SPECIAL_CAP_PCT = 30.0
|
||
|
||
register_template(
|
||
FactorTemplate(
|
||
name="dividend_yield",
|
||
label="股息率",
|
||
description="股息率(近 12 个月现金分红 / 总市值 × 100,%)",
|
||
formula="dv_ratio(Tushare daily_basic,逐日时点值)",
|
||
brief=(
|
||
"高股息:熊市/震荡市防御性较强,分红提供现金回报底;"
|
||
"需警惕「高股息陷阱」——股息率高常因股价下跌或一次性特别分红,"
|
||
"建议配合 dv_ratio 上限过滤与盈利质量条件使用。"
|
||
),
|
||
fn=lambda fields, params: fields["dv_ratio"],
|
||
unit="%",
|
||
requires=("dv_ratio",),
|
||
lookback_of=lambda params: 0, # 时点截面值,无滚动窗口
|
||
instances=(("dividend_yield", {P_DIRECTION: DIRECTION_HIGHER}),),
|
||
)
|
||
)
|
||
|
||
register_template(
|
||
FactorTemplate(
|
||
name="dividend_yield_ttm",
|
||
label="股息率 TTM",
|
||
description="股息率 TTM(近 12 个月滚动现金分红 / 总市值 × 100,%)",
|
||
formula="dv_ttm(Tushare daily_basic,逐日时点值)",
|
||
brief="同股息率,但口径为 TTM;与 dv_ratio 多数日期取值一致,可作交叉验证。",
|
||
unit="%",
|
||
fn=lambda fields, params: fields["dv_ttm"],
|
||
requires=("dv_ttm",),
|
||
lookback_of=lambda params: 0,
|
||
instances=(("dividend_yield_ttm", {P_DIRECTION: DIRECTION_HIGHER}),),
|
||
)
|
||
)
|