"""条件字段注册表 —— 「字段库」的唯一事实来源(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)}