字段库(本次新增的表与接口): - `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。
375 lines
18 KiB
Python
375 lines
18 KiB
Python
"""条件字段注册表 —— 「字段库」的唯一事实来源(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)} |