Files
qlib/backend/app/quant/condition_fields.py
T
Simon 2e90f3eeac feat(backend): 字段库(condition_field)+ 因子参数化(模板/受控参数)+ 单位换算底座
字段库(本次新增的表与接口):
- `condition_field` 表 + `/api/condition-fields`:中文名/说明可编辑、可停用;
  `kind`/单位阶梯/`base_unit` 由代码注册表收敛(改类型 422,伪字段 422,
  越界单位 422),停用的字段不再进条件下拉,但既有策略仍按名字解析。
- 说明书里的数值条件按字段注册表补**基准单位**后缀(字段间比较不加,不猜单位)。

因子参数化(键即身份,冻结口径):
- 模板 + 参数注册表(`quant/factors.py`):`ParamSpec`(类型/范围/枚举/默认值/说明)+
  `FactorTemplate`(公式/依赖列/参数);规范键把**全部**参数写进名字,如
  `momentum(window=90,direction=lower_is_better)`,所以改参数 = 新建一个身份,
  旧因子/既有策略/已归档实验都不变义;`momentum(window=90)`(缺参数)明确拒绝 ——
  缺项要靠模板默认值补齐,而默认值是可改的代码细节,一旦改动会追溯性改义。
- 参数只在受控范围内取值(窗口 2~500、方向二选一),越界/未知模板/多给参数一律 422
  并列出允许范围,不静默截断、不悄悄取默认值;内置实例的启用开关由代码决定(422)。
- `/api/factors` 暴露 `template`/`params`/`param_specs`/`label`/`source`/`enabled`/
  `resolvable`;新增 `/api/factors/templates`、`POST /api/factors`、`PATCH /api/factors`;
  `get_factor = resolve_factor` 兼容全部旧调用点,参数化键也是一等条件字段。
- 迁移链:c5d6(存量策略陈旧说明重算)→ d6e7(condition_field)→ a7c1
  (factor_definition.enabled + name varchar(128))。

测试:新增 test_condition_fields.py / test_factor_params.py;全量 pytest 500 passed。
2026-10-01 16:33:32 +08:00

375 lines
18 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.
"""条件字段注册表 —— 「字段库」的唯一事实来源(2026-10)。
要解决的问题
------------
策略库的「过滤条件」此前是**手填字段名**的输入框:用户必须知道 dv_ratio /
static.industry / fundamental.roe 这类内部标识,既看不到含义,写错了也不报错 ——
引擎对未知字段求值一律返回 None,条件**永远不通过**,策略会安静地选出 0 只股票。
这正是 AGENT.md 禁止的「静默失败 / 假装支持」。
本模块的职责
------------
把**引擎真正支持的字段域**集中声明一次(中文名 + 含义 + 单位 + 分组 + 类型 + 排序),
供三方共用:
1. ``/api/condition-fields`` 据此 seed 目录、据此校验用户新增的自定义字段;
2. 前端据此渲染分组下拉、含义提示、并按类型收窄可选比较符;
3. :func:`is_supported_field` 直接查 ``Stock`` / ``FinancialIndicator`` 的字段定义与
因子注册表(``quant.factors``),**不另写一套近似规则** —— 避免注册表与引擎漂移。
诚实性约束(AGENT.md §24)
--------------------------
只登记真能算的字段。日期字段(如 ``static.list_date``)无法比较大小,:func:`reason_unsupported`
会明确拒绝并说明理由,而不是放行让用户建出一条「永远选不出股票」的条件。
单位一律照抄数据源落库口径(见 ``data_sources/tushare.py`` 的换算注释),不凭印象写。
"""
from __future__ import annotations
from dataclasses import dataclass
from app.domain.entities.condition_field import OPS_BY_KIND
from app.domain.entities.market import (
DAILY_BAR_NUMERIC_FIELDS,
DAILY_BASIC_NUMERIC_FIELDS,
FinancialIndicator,
Stock,
)
from app.quant.factors import FactorError, get_factor, list_factors, resolve_factor
# ---------- 分组(下拉的 optgroup 顺序即此顺序) ----------
GROUP_STOCK = "股票基础"
GROUP_QUOTE = "行情"
GROUP_TECH = "技术指标"
GROUP_DAILY = "每日指标"
GROUP_FUNDAMENTAL = "财务指标"
GROUP_FACTOR = "因子"
GROUP_ORDER: tuple[str, ...] = (
GROUP_STOCK,
GROUP_QUOTE,
GROUP_TECH,
GROUP_DAILY,
GROUP_FUNDAMENTAL,
GROUP_FACTOR,
)
# 比较符规则(哪种类型能比大小)定义在领域实体 domain/entities/condition_field.py,
# 此处转发 —— 保证「字段库 API 返回的 ops」与「注册表里 FieldDef.ops」出自同一处。
# ---------- 单位阶梯(2026-10) ----------
#
# 单位分两层,避免「改个显示单位把历史策略的数值偷偷换义」:
# · **基准单位**(FieldDef.unit):引擎存储与比较用的单位,写死在数据源落库口径里,
# 不可改。归档里的 ConditionSpec 存的永远是基准单位值 —— 复现不受界面设置影响。
# · **界面单位**(本阶梯里的备选项):只在「输入/显示」这一层做换算,factor 表示
# 「该单位 → 基准单位的系数」(即提交前 ×factor,回显时 ÷factor)。
# 所以用户在字段库把总市值选成「亿元」,输入 5 会存成 50000(万元)——引擎比较的仍是
# 基准单位,而界面上看到的始终是 5 亿元。改单位不会让任何历史策略变义。
#
# 只登记换算无歧义、且实际会用到的单位:金额(元/万元/亿元)、股数(股/手/万手)、
# 股本(万股/亿股)。百分数(%)与倍数(倍)不提供备选 —— 换成小数只会制造误读。
U_MONEY_YUAN: tuple[tuple[str, float], ...] = (("元", 1.0), ("万元", 1e4), ("亿元", 1e8))
U_MONEY_WAN: tuple[tuple[str, float], ...] = (("万元", 1.0), ("亿元", 1e4))
U_SHARE_GU: tuple[tuple[str, float], ...] = (("股", 1.0), ("手", 100.0), ("万手", 1e6))
U_SHARE_WAN: tuple[tuple[str, float], ...] = (("万股", 1.0), ("亿股", 1e4))
# 哪些字段提供备选界面单位(键 = 引擎字段名;不在表里的字段只能用基准单位)
_UNIT_LADDERS: dict[str, tuple[tuple[str, float], ...]] = {
# 行情原列:volume 入库为股(源为手 ×100),amount 入库为元(源为千元 ×1000)
"volume": U_SHARE_GU,
"amount": U_MONEY_YUAN,
# 每日指标:市值为万元,股本为万股
"total_mv": U_MONEY_WAN,
"circ_mv": U_MONEY_WAN,
"total_share": U_SHARE_WAN,
"float_share": U_SHARE_WAN,
"free_share": U_SHARE_WAN,
# 财务指标:金额入库为元
"fundamental.net_profit": U_MONEY_YUAN,
"fundamental.total_revenue": U_MONEY_YUAN,
}
@dataclass(frozen=True)
class FieldDef:
"""一个条件字段的登记项(引擎真能算的字段)。"""
name: str # 引擎字段名(写进 condition.field)
label: str # 中文名(下拉里给人看的)
description: str # 含义 / 口径(含单位),必须可核对
kind: str # num | str
group_name: str
unit: str = "" # **基准单位**(引擎存储/比较用),不可由界面更改
curated: bool = True # True=默认进字段库;False=仅登记为「可新增」(少用字段)
units: tuple[tuple[str, float], ...] = () # 可选界面单位;(单位, →基准单位系数),首项须是基准单位
@property
def ops(self) -> tuple[str, ...]:
return OPS_BY_KIND.get(self.kind, ())
@property
def unit_options(self) -> tuple[tuple[str, float], ...]:
"""可选界面单位;未登记阶梯的字段只有基准单位一项(界面不给选择)。"""
return self.units or ((self.unit, 1.0),)
# ---------- 股票基础(static.*):来自 Stock 实体,只有字符串字段可比较 ----------
# (attr, 中文名, 含义, curated)
_STATIC_FIELDS: tuple[tuple[str, str, str, bool], ...] = (
("industry", "所属行业", "股票基础信息里的行业名称(字符串),如「银行」「白酒」。等值用「=」,多值用「属于」。", True),
("market", "上市板块", "主板 / 创业板 / 科创板 / 北交所(字符串)。", True),
("area", "注册地域", "公司注册地省份或地区(字符串),如「广东」「北京」。", True),
("exchange", "交易所", "SH 上交所 / SZ 深交所 / BJ 北交所(字符串)。", False),
("status", "上市状态", "L 上市 / D 退市 / P 暂停上市(字符串)。研究池已默认剔除退市股。", False),
("name", "股票名称", "证券简称(字符串)。一般用于核对,不建议拿来做条件。", False),
("symbol", "股票代码", "Tushare 风格代码,如 600519.SH(字符串)。多值用「属于」。", False),
)
# ---------- 行情原列(open/high/low/close/volume/amount) ----------
# 换算口径见 data_sources/tushare.py: volume=vol(手)*100 → 股;amount=amount(千元)*1000 → 元。
_BAR_FIELDS: tuple[tuple[str, str, str, str, bool], ...] = (
("close", "收盘价", "当日收盘价(元)。复权口径由公共配置的 price_adjustment 决定(默认 hfq)。", "元", True),
("open", "开盘价", "当日开盘价(元),复权口径同上。", "元", True),
("high", "最高价", "当日最高价(元),复权口径同上。", "元", True),
("low", "最低价", "当日最低价(元),复权口径同上。", "元", True),
("volume", "成交量", "当日成交股数(股)。数据源原始单位为「手」,入库时已 ×100 换算。", "股", True),
("amount", "成交额", "当日成交金额(元)。数据源原始单位为「千元」,入库时已 ×1000 换算。", "元", True),
)
# ---------- 技术派生(滚动窗口计算,非行情原列) ----------
_TECH_FIELDS: tuple[tuple[str, str, str, str, bool], ...] = (
("ma20", "20 日均线", "收盘价的 20 个交易日简单移动平均(元),在选股日当日取值。", "元", True),
("ma60", "60 日均线", "收盘价的 60 个交易日简单移动平均(元),在选股日当日取值。", "元", True),
)
# ---------- 每日指标(daily_basic) ----------
# 单位照抄 data_sources/tushare.py: 百分数/倍数为原样,股本为万股,市值为万元。
_DAILY_FIELDS: tuple[tuple[str, str, str, str, bool], ...] = (
("dv_ratio", "股息率", "近 12 个月现金分红 / 总市值 × 100(%),逐日时点值 —— 即因子 dividend_yield 的口径。", "%", True),
("dv_ttm", "股息率 TTM", "近 12 个月滚动现金分红 / 总市值 × 100(%),即因子 dividend_yield_ttm 的口径。", "%", True),
("pe", "市盈率 PE", "总市值 / 最新年报净利润(倍,静态口径)。", "倍", True),
("pe_ttm", "市盈率 PE(TTM)", "总市值 / 最近 12 个月净利润(倍)。", "倍", True),
("pb", "市净率 PB", "总市值 / 最新报告期净资产(倍)。", "倍", True),
("turnover_rate", "换手率", "当日成交股数 / 流通股本 × 100(%)。", "%", True),
("volume_ratio", "量比", "当日成交量 / 过去 5 日平均成交量(倍)。", "倍", True),
("total_mv", "总市值", "总股本 × 当日收盘价(万元)。", "万元", True),
("circ_mv", "流通市值", "流通股本 × 当日收盘价(万元)。", "万元", True),
("ps", "市销率 PS", "总市值 / 最新年报营业收入(倍)。", "倍", False),
("ps_ttm", "市销率 PS(TTM)", "总市值 / 最近 12 个月营业收入(倍)。", "倍", False),
("total_share", "总股本", "总股本(万股)。", "万股", False),
("float_share", "流通股本", "流通股本(万股)。", "万股", False),
("free_share", "自由流通股本", "自由流通股本(万股)。", "万股", False),
)
# ---------- 财务指标(fundamental.*) ----------
# 可见性口径:只取 announce_date <= 选股日的最新已公告值(防未来函数,见 selection.run_condition_selection)。
_FUNDAMENTAL_FIELDS: tuple[tuple[str, str, str, str, bool], ...] = (
("roe", "净资产收益率 ROE", "最新已公告报告期的净资产收益率(%)。", "%", True),
("eps", "每股收益 EPS", "最新已公告报告期的每股收益(元)。", "元", True),
("gross_margin", "毛利率", "最新已公告报告期的毛利率(%)。", "%", True),
("net_profit", "归母净利润", "最新已公告报告期的归母净利润(元)。", "元", False),
("total_revenue", "营业总收入", "最新已公告报告期的营业总收入(元)。注意:目前只有新浪兜底源提供该字段,多数行可能为空 —— 缺失时条件视为不通过。", "元", False),
)
# static.* 里明确不支持的字段(存在但不可比较)
_STATIC_UNSUPPORTED: dict[str, str] = {
"list_date": "上市日期是日期,不是可比较的数值/字符串;请改用股票池的「上市天数」设置",
"delist_date": "退市日期是日期,不是可比较的数值/字符串",
}
def _defs() -> list[FieldDef]:
"""构造全部内置字段定义(每次调用重新构造,保证与代码注册表实时一致)。"""
out: list[FieldDef] = []
for attr, label, desc, curated in _STATIC_FIELDS:
out.append(
FieldDef(
name=f"static.{attr}",
label=label,
description=desc,
kind="str",
group_name=GROUP_STOCK,
curated=curated,
)
)
for name, label, desc, unit, curated in _BAR_FIELDS:
out.append(
FieldDef(
name, label, desc, "num", GROUP_QUOTE, unit, curated,
_UNIT_LADDERS.get(name, ()),
)
)
for name, label, desc, unit, curated in _TECH_FIELDS:
out.append(
FieldDef(name, label, desc, "num", GROUP_TECH, unit, curated, _UNIT_LADDERS.get(name, ()))
)
for name, label, desc, unit, curated in _DAILY_FIELDS:
out.append(
FieldDef(name, label, desc, "num", GROUP_DAILY, unit, curated, _UNIT_LADDERS.get(name, ()))
)
for name, label, desc, unit, curated in _FUNDAMENTAL_FIELDS:
full = f"fundamental.{name}"
out.append(
FieldDef(full, label, desc, "num", GROUP_FUNDAMENTAL, unit, curated, _UNIT_LADDERS.get(full, ()))
)
for d in list_factors():
# 因子既能当「打分因子」也能当「过滤条件」:这里复用因子注册表的元数据,
# 不另写描述,避免两处文案漂移。因子的 description 里已写明公式与口径;
# 标签用中文名(含参数),如「动量(窗口 60,越高越好)」——
# 参数化实例不在这里(它们在 /factors 目录里,条件下拉按名并入)。
out.append(
FieldDef(
name=d.name,
label=f"{d.display}(因子)",
description=d.description + (f" 用法:{d.brief}" if d.brief else ""),
kind="num",
group_name=GROUP_FACTOR,
curated=True,
)
)
return out
def builtin_fields() -> list[FieldDef]:
"""全部引擎支持的字段(含 curated=False 的「可新增但不默认展示」项)。
名字唯一性由因子名与各分组前缀保证;一旦重复说明注册表写错,直接抛错而非静默覆盖。
单位阶梯同样自检:首项必须是基准单位且系数为 1.0,系数必须为正 —— 写错了会让
「界面显示 5 亿元、引擎按 5 万元比」这种错静默溜进生产。
"""
defs = _defs()
names = [d.name for d in defs]
dup = {n for n in names if names.count(n) > 1}
if dup:
raise ValueError(f"条件字段注册表存在重名:{sorted(dup)}")
for d in defs:
if not d.units:
continue
base, factor = d.units[0]
if base != d.unit or factor != 1.0:
raise ValueError(
f"字段 {d.name} 的单位阶梯首项必须是基准单位 {d.unit!r}(系数 1.0),实际 {d.units[0]!r}"
)
if any(f <= 0 for _, f in d.units):
raise ValueError(f"字段 {d.name} 的单位换算系数必须为正:{d.units}")
if len({u for u, _ in d.units}) != len(d.units):
raise ValueError(f"字段 {d.name} 的单位阶梯有重复单位:{d.units}")
return defs
def curated_fields() -> list[FieldDef]:
"""默认进「字段库」的字段(下拉里开箱可见的那批)。"""
return [d for d in builtin_fields() if d.curated]
def get_field(name: str) -> FieldDef | None:
"""按字段名取定义:注册表字段,或**参数化因子键**(如 momentum(window=90,direction=…))。
为什么参数化因子也要能取到:它是引擎真认的条件字段(`momentum_60 > 0` 一直合法),
而字段库/说明书的单位后缀、类型判断都走这里。取不到会让人误以为「引擎不支持」,
甚至让「把参数化因子加进字段库」这一步半路 assert 崩掉(500 而不是 422)。
"""
for d in builtin_fields():
if d.name == name:
return d
return _factor_field(name)
def _factor_field(name: str) -> FieldDef | None:
"""参数化因子键 → 字段定义(因子是无量纲量,不带单位)。"""
try:
defn, _fn = resolve_factor(name)
except FactorError:
return None
if defn.name in {d.name for d in builtin_fields()}:
return None # 注册表字段已在上一步返回;这里只处理新增的参数化实例
return FieldDef(
name=defn.name,
label=f"{defn.display}(因子)",
description=defn.description + (f" 用法:{defn.brief}" if defn.brief else ""),
kind="num",
group_name=GROUP_FACTOR,
curated=False, # 不自动进字段库:目录在 /factors 管,条件里按名并进来
)
def _stock_attrs() -> set[str]:
return set(Stock.model_fields)
def _fundamental_attrs() -> set[str]:
"""FinancialIndicator 里可比较的数值字段(排除 symbol/报告期/来源等元数据)。"""
skip = {"symbol", "report_date", "announce_date", "source"}
return {n for n in FinancialIndicator.model_fields if n not in skip}
def reason_unsupported(name: str) -> str:
"""字段不可用的理由(用于 422 文案;可用字段返回空串)。"""
if not name or not name.strip():
return "字段名为空"
if name in DAILY_BAR_NUMERIC_FIELDS:
return ""
if name in ("ma20", "ma60"):
return ""
if name in DAILY_BASIC_NUMERIC_FIELDS:
return ""
if name.startswith("static."):
attr = name[len("static.") :]
if attr in _STATIC_UNSUPPORTED:
return f"{name} 不可用作条件:{_STATIC_UNSUPPORTED[attr]}"
if attr in _stock_attrs():
return ""
return f"{name} 不存在:股票基础信息里没有 {attr} 字段"
if name.startswith("fundamental."):
attr = name[len("fundamental.") :]
if attr in _fundamental_attrs():
return ""
return f"{name} 不存在:财务指标里没有 {attr} 字段"
try:
get_factor(name)
except FactorError:
return (
f"{name} 不是引擎支持的字段。可用:行情列({', '.join(DAILY_BAR_NUMERIC_FIELDS)})、"
"ma20/ma60、每日指标列、static.<股票基础字段>、fundamental.<财务字段>、"
"已注册因子名,或参数化因子键(如 momentum(window=90,direction=higher_is_better))"
)
return ""
def is_supported_field(name: str) -> bool:
"""引擎是否真能算这个字段(False = 条件永远不通过,必须拒绝)。"""
return reason_unsupported(name) == ""
def available_fields(existing: set[str]) -> list[FieldDef]:
"""引擎支持但**尚未进目录**的字段(用户「新增字段」时可选项)。
只从注册表里挑,用户因此不可能加进一个引擎算不出来的字段(§24 不假装支持)。
"""
return [d for d in builtin_fields() if not d.curated and d.name not in existing]
# ---------- 单位换算(界面单位 ⇄ 基准单位) ----------
def unit_options(name: str) -> list[tuple[str, float]]:
"""该字段可选的界面单位(首项为基准单位,系数 = 该单位 → 基准单位)。字段不存在 → 空列表。
**换算在界面层做**(前端按系数换算输入/回显),存储与引擎一律用基准单位:
这样归档里的 ConditionSpec 永远不随界面设置改变含义。
"""
d = get_field(name)
return list(d.unit_options) if d else []
def unit_allowed(name: str, unit: str) -> bool:
"""该单位是否在字段允许的阶梯里(API 据此 422 拒绝自由文本单位)。"""
return unit in {u for u, _ in unit_options(name)}