Files
qlib/backend/app/quant/factors.py
T
Simon 48a97c2a12 feat(backtest): 买卖点理由(用数据说话)+ 因子曲线 + 曲线新页面放大
用户要求:「所有买卖点详细说明买卖理由,用数据说话」「回测图上增加因子相关曲线
(买卖依据是股息率,就加股息率曲线)」「所有曲线能弹出新页面放大」。

一、买卖理由(后端产出结构化数据,前端只展示)
- 新增 `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 渲染,截图确认表格与曲线数值正确。
2026-10-01 17:57:00 +08:00

678 lines
27 KiB
Python
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.
"""因子引擎:因子**模板**(含可编辑参数)、实例注册表与计算(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}),),
)
)