Files
tick-stock-panel/backend/app/factors/registry.py
T
shy3130 e3a2fbce13 feat(ext-data): 扩展表字段接入信号与因子(数值因子/评分 + string 归属筛选)
数值字段(int/float) → 信号+因子双通道:
- ext_factors: 帧组装时 join 扩展列并注册 kind=base 因子(分组「扩展数据」),
  时序模式按 (symbol,交易日) 精确对齐无未来函数, 快照模式仅当日单日帧
  注入(历史帧跳过防未来函数)
- registry.all_factors 惰性同步(配置目录签名幂等, 以注册表为权威增删);
  custom_signals.allowed_fields 自动并入 → 信号下拉/因子库/AI提示词/检验
  同一份清单; factor 补算入口按需注入
- 失效链: 上传/拉取/配置变更自动清扩展帧缓存+策略缓存, API层补
  repo.clear_cache; 写入后下一次计算立即生效
- 列名保留中文(预设表字段名), 非ASCII数值字段只进信号不注册因子
  (DSL标识符ASCII-only)

string 字段(概念/行业归属) → 仅信号条件通道:
- 运算符 包含(contains,字面量匹配非正则)/等于/不等于, 右值为字符串字面量,
  可与数值条件混合(强势板块归属 AND 热度阈值)
- 前端信号编辑器按字段类型切换运算符与右值输入; /options 暴露
  stringFields; AI 提示词含字符串字段清单与 contains 用法
- string 不注册为因子(数值口径), 空值不误报

测试: test_ext_factors 18个(PIT对齐/跨日不泄露/快照门控/写入失效/
contains字面量/中文列名端到端等); 存量因子计数测试补 data/ 运行时隔离
夹具(黄金断言不依赖本机扩展表); 受影响回归148个全过; pnpm build 通过;
ruff 对齐 main 基线
2026-09-06 18:27:33 +08:00

401 lines
18 KiB
Python

"""因子注册表 (L-REG) — 因子元数据的单一权威来源。
P1 收口范围: 目录元数据 (id/label/group/公式)、虚拟因子依赖声明、评分预热窗口。
三处历史清单在此合一:
- backtest/factor.py FACTOR_COLUMNS (由 factor_columns_view() 生成兼容别名)
- strategy/scoring.py VIRTUAL_SCORING_DEPENDENCIES (由 virtual_dependencies() 生成)
- strategy/scoring.py _ROLLING_SCORING_WARMUP (由 scoring_warmups() 生成)
P1 边界 (诚实声明):
- scoring_value_expr 的表达式分发仍留在 scoring.py, 注册表不含计算逻辑;
复合/自定义因子 (composite/custom) 与 DSL 在 P2/P3 接入后再收口。
- unit 字段 P1 统一 "none": 单位口径涉及金融数据契约 (CONTRIBUTING §3),
未经逐因子核对禁止猜测填充; 前端 P1 也不按 unit 格式化。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
Kind = Literal["base", "virtual", "composite", "custom"]
Direction = Literal["high", "low", "none"]
Unit = Literal["ratio", "pct", "score", "count", "days", "currency", "none"]
PitSource = Literal["financial_announce", "share_capital_announce", "none"]
Stability = Literal["stable", "experimental", "deprecated"]
_ALL_ASSETS = frozenset({"stock", "etf"})
_STOCK_ONLY = frozenset({"stock"})
@dataclass(frozen=True)
class FactorSpec:
id: str
label: str
group: str
formula_text: str
kind: Kind = "base"
version: int = 1
# base: 空集合 = 已物化列自身; virtual: 展开到 enriched base 列
dependencies: frozenset[str] = field(default_factory=frozenset)
direction: Direction = "none" # P1 不预填: 方向以最近检验 IC 符号为准 (见平台方案 §3.6)
unit: Unit = "none"
warmup_bars: int = 1
pit: bool = False
pit_source: PitSource = "none"
asset_types: frozenset[str] = _ALL_ASSETS
incremental_safe: bool = True
scale_free: bool = True
null_policy: Literal["keep", "drop_row"] = "keep"
stability: Stability = "stable"
tags: tuple[str, ...] = ()
# composite 专用: ((成员 id, 权重), ...); 其余类型为空
components: tuple[tuple[str, float], ...] = ()
def column_view(self) -> dict:
"""历史 FACTOR_COLUMNS 条目视图 (键与顺序兼容)。"""
return {"id": self.id, "label": self.label, "group": self.group, "desc": self.formula_text}
def _base(fid: str, label: str, group: str, desc: str, **overrides) -> FactorSpec:
return FactorSpec(id=fid, label=label, group=group, formula_text=desc, kind="base", **overrides)
def _virtual(fid: str, label: str, group: str, desc: str, deps: frozenset[str], **overrides) -> FactorSpec:
return FactorSpec(
id=fid, label=label, group=group, formula_text=desc,
kind="virtual", dependencies=deps, **overrides,
)
def _financial(fid: str, label: str, desc: str) -> FactorSpec:
return FactorSpec(
id=fid, label=label, group="财务", formula_text=desc,
kind="base", pit=True, pit_source="financial_announce", asset_types=_STOCK_ONLY,
)
# 顺序即历史 FACTOR_COLUMNS 顺序 (mining_schedule 取前 48 个, 不得重排)。
_CATALOG: tuple[FactorSpec, ...] = (
# --- 动量 ---
_base("momentum_5d", "5日动量", "动量", "5个交易日累计收益率"),
_base("momentum_10d", "10日动量", "动量", "10个交易日累计收益率"),
_base("momentum_20d", "20日动量", "动量", "20个交易日累计收益率"),
_base("momentum_30d", "30日动量", "动量", "30个交易日累计收益率"),
_base("momentum_60d", "60日动量", "动量", "60个交易日累计收益率"),
_base("change_pct", "日涨跌幅", "动量", "当日收盘相对前收盘的收益率"),
# --- 均线偏离 (虚拟) ---
*(
_virtual(
f"ma{period}_bias", f"MA{period}乖离", "均线偏离", f"收盘价 / MA{period} - 1",
deps=frozenset({"close", f"ma{period}"}),
)
for period in (5, 10, 20, 30, 60)
),
*(
_virtual(
f"ema{period}_bias", f"EMA{period}乖离", "均线偏离", f"收盘价 / EMA{period} - 1",
deps=frozenset({"close", f"ema{period}"}),
)
for period in (5, 10, 20, 30, 60)
),
# --- 超买超卖 ---
_base("rsi_6", "RSI(6)", "超买超卖", "6日相对强弱指标"),
_base("rsi_14", "RSI(14)", "超买超卖", "14日相对强弱指标"),
_base("rsi_24", "RSI(24)", "超买超卖", "24日相对强弱指标"),
# --- 趋势 ---
_base(
"macd_hist", "MACD柱(原值)", "趋势",
"兼容历史研究; 跨股票比较建议优先使用MACD柱强度",
scale_free=False,
),
_virtual("macd_dif_pct", "MACD DIF强度", "趋势", "MACD DIF / 收盘价", deps=frozenset({"close", "macd_dif"})),
_virtual("macd_dea_pct", "MACD DEA强度", "趋势", "MACD DEA / 收盘价", deps=frozenset({"close", "macd_dea"})),
_virtual("macd_hist_pct", "MACD柱强度", "趋势", "MACD柱 / 收盘价, 消除股价尺度影响", deps=frozenset({"close", "macd_hist"})),
_base("kdj_k", "KDJ-K", "趋势", "KDJ指标K值"),
_base("kdj_d", "KDJ-D", "趋势", "KDJ指标D值"),
_base("kdj_j", "KDJ-J", "趋势", "KDJ指标J值"),
_virtual(
"boll_position", "布林位置", "趋势", "收盘价在布林带下轨到上轨之间的位置",
deps=frozenset({"close", "boll_upper", "boll_lower"}),
),
# --- 波动率 ---
_base("annual_vol_20d", "20日波动率", "波动率", "20日收益率年化标准差"),
_base("atr_14", "ATR(14)原值", "波动率", "兼容历史研究; 跨股票比较建议优先使用ATR相对波动", scale_free=False),
_virtual("atr_pct", "ATR相对波动", "波动率", "ATR(14) / 收盘价", deps=frozenset({"close", "atr_14"})),
_base("amplitude", "日振幅", "波动率", "当日高低价差 / 前收盘价"),
_virtual(
"boll_width", "布林带宽", "波动率", "布林带上下轨宽度 / MA20",
deps=frozenset({"ma20", "boll_upper", "boll_lower"}),
),
# --- 量价 ---
_base("vol_ratio_5d", "5日量比", "量价", "当日成交量 / 前5日平均成交量"),
_virtual(
"vol_ratio_10d", "10日量比", "量价", "当日成交量 / 前10日平均成交量",
deps=frozenset({"volume"}), warmup_bars=11,
),
_virtual(
"vol_trend_5_10", "成交量趋势", "量价", "5日平均成交量 / 10日平均成交量 - 1",
deps=frozenset({"vol_ma5", "vol_ma10"}),
),
_base("turnover_rate", "换手率", "量价", "使用历史时点流通股本计算的当日换手率"),
_virtual(
"turnover_ratio_5d", "换手率放大", "量价", "当日换手率 / 前5日平均换手率 - 1",
deps=frozenset({"turnover_rate"}), warmup_bars=6,
),
_virtual(
"log_amount", "成交额对数", "量价", "ln(成交额 + 1), 降低极端规模影响",
deps=frozenset({"amount"}), scale_free=False,
),
_virtual(
"amount_ratio_5d", "成交额放大", "量价", "当日成交额 / 前5日平均成交额 - 1",
deps=frozenset({"amount"}), warmup_bars=6,
),
# --- 价格位置 ---
_virtual("gap_return", "开盘跳空", "价格位置", "开盘价 / 前收盘价 - 1", deps=frozenset({"open", "prev_close"})),
_virtual("intraday_return", "日内收益", "价格位置", "收盘价 / 开盘价 - 1", deps=frozenset({"open", "close"})),
_virtual(
"close_position", "收盘位置", "价格位置", "收盘价在当日最低价到最高价之间的位置",
deps=frozenset({"high", "low", "close"}),
),
_virtual(
"distance_to_high_60d", "距60日高点", "价格位置", "收盘价 / 60日最高收盘价 - 1",
deps=frozenset({"close", "high_60d"}),
),
_virtual(
"distance_from_low_60d", "距60日低点", "价格位置", "收盘价 / 60日最低收盘价 - 1",
deps=frozenset({"close", "low_60d"}),
),
_virtual(
"vwap_bias", "VWAP乖离", "价格位置", "收盘价 / 当日成交均价 - 1, 成交均价 = 成交额 / (成交量x100)",
deps=frozenset({"close", "volume", "amount"}),
),
# --- 收益形态 (虚拟, 滚动窗口) ---
_virtual(
"max_ret_20d", "20日最大单日涨幅", "收益形态", "近20个交易日单日涨幅最大值(彩票效应, 高值代表博彩型特征强)",
deps=frozenset({"close"}), warmup_bars=21,
),
_virtual(
"ret_skew_20d", "20日收益偏度", "收益形态", "近20个交易日日收益偏度, 高值代表右偏(偶发大涨)",
deps=frozenset({"close"}), warmup_bars=21,
),
_virtual(
"up_days_20d", "20日上涨天数", "收益形态", "近20个交易日中上涨天数(0~20)",
deps=frozenset({"close"}), warmup_bars=21,
),
# --- 流动性 (虚拟) ---
_virtual(
"amihud_20d", "20日Amihud非流动性", "流动性", "近20日平均 |日涨跌幅| / 成交额(亿元), 高值代表流动性差",
deps=frozenset({"close", "amount"}), warmup_bars=21,
),
_virtual(
"turnover_z_60d", "换手率60日z分", "流动性", "(当日换手率 - 前60日均值) / 前60日标准差, 衡量换手异动",
deps=frozenset({"turnover_rate"}), warmup_bars=61,
),
# --- 量价 (续) ---
_virtual(
"vol_price_corr_20d", "20日量价相关", "量价", "近20个交易日日涨跌幅与成交量的相关系数, 高值代表量价同向",
deps=frozenset({"close", "volume"}), warmup_bars=21,
),
_virtual(
"vol_trend_5_60", "量能趋势(5/60)", "量价", "5日平均成交量 / 60日平均成交量 - 1",
deps=frozenset({"volume"}), warmup_bars=60,
),
# --- 涨停基因 (虚拟) ---
_virtual(
"limit_up_count_20d", "涨停基因(20日)", "涨停基因", "近20个交易日涨停次数",
deps=frozenset({"consecutive_limit_ups"}), warmup_bars=21,
),
_virtual(
"limit_up_count_60d", "涨停基因(60日)", "涨停基因", "近60个交易日涨停次数",
deps=frozenset({"consecutive_limit_ups"}), warmup_bars=61,
),
# --- 财务 (点时, 仅股票) ---
_financial("pb_latest", "市净率(最新公告)", "收盘价 / 最新已公告每股净资产; 无财务数据或公告前为空"),
_financial("roe_latest", "ROE(最新公告)", "最新已公告净资产收益率(%); 无财务数据或公告前为空"),
_financial("gross_margin_latest", "毛利率(最新公告)", "最新已公告销售毛利率(%)"),
_financial("net_margin_latest", "净利率(最新公告)", "最新已公告销售净利率(%)"),
_financial("revenue_yoy_latest", "营收增速(最新公告)", "最新已公告营业收入同比(%)"),
_financial("net_income_yoy_latest", "净利增速(最新公告)", "最新已公告归母净利润同比(%)"),
_financial("debt_ratio_latest", "资产负债率(最新公告)", "最新已公告资产负债率(%)"),
# --- 扩充批次 (2026-09-05): 规模/收益分解/长窗口/下行风险/量能潮/换手水平 ---
_virtual(
"log_float_mv", "流通市值对数", "规模",
"ln(收盘价 x 当日成交量 / 换手率), 由换手率反推流通股本, 高值代表大盘",
deps=frozenset({"close", "volume", "turnover_rate"}), scale_free=False,
),
_virtual(
"momentum_120d", "120日动量", "动量",
"120个交易日累计收益率 (中期动量, 与短窗口互补)",
deps=frozenset({"close"}), warmup_bars=121,
),
_virtual(
"mom_accel_20_60", "动量加速度", "动量",
"20日动量 - 60日动量, 衡量近期动量相对中期是否增强",
deps=frozenset({"momentum_20d", "momentum_60d"}),
),
_virtual(
"rsi_14_delta_5d", "RSI五日变化", "超买超卖",
"RSI(14) - 5日前的RSI(14), 衡量强弱指标的边际变化",
deps=frozenset({"rsi_14"}), warmup_bars=6,
),
_virtual(
"overnight_ret_20d", "20日隔夜收益", "收益形态",
"近20日累计隔夜收益(开盘价/前收盘-1求和), A股隔夜与日内收益的定价机制不同",
deps=frozenset({"open", "prev_close"}), warmup_bars=21,
),
_virtual(
"intraday_ret_20d", "20日日内收益", "收益形态",
"近20日累计日内收益(收盘价/开盘价-1求和), 与隔夜收益构成收益分解",
deps=frozenset({"open", "close"}), warmup_bars=21,
),
_virtual(
"downside_vol_20d", "20日下行波动", "波动率",
"sqrt(近20日 min(日收益,0)^2 均值), 只度量下跌侧风险",
deps=frozenset({"close"}), warmup_bars=21,
),
_virtual(
"vol_regime_5_60", "波动率状态(5/60)", "波动率",
"5日收益标准差 / 60日收益标准差, 高值代表波动骤然放大",
deps=frozenset({"close"}), warmup_bars=61,
),
_virtual(
"amplitude_trend_20_60", "振幅趋势(20/60)", "波动率",
"20日平均振幅 / 60日平均振幅 - 1",
deps=frozenset({"amplitude"}), warmup_bars=61,
),
_virtual(
"obv_trend_20d", "20日量能潮", "量价",
"近20日 sign(日收益)x成交量 之和 / (20日均量x20), 有界[-1,1], 净买入方向的一致性",
deps=frozenset({"close", "volume"}), warmup_bars=21,
),
_virtual(
"amount_mean_20d", "20日均成交额(亿)", "量价",
"近20日平均成交额(亿元), 规模/流动性水平量",
deps=frozenset({"amount"}), warmup_bars=21, scale_free=False,
),
_virtual(
"turnover_mean_20d", "20日均换手", "流动性",
"近20日平均换手率, A股经典低换手溢价因子",
deps=frozenset({"turnover_rate"}), warmup_bars=21,
),
_virtual(
"turnover_std_20d", "20日换手波动", "流动性",
"近20日换手率标准差 / 均值 (变异系数), 衡量交易活跃的稳定性",
deps=frozenset({"turnover_rate"}), warmup_bars=21,
),
_virtual(
"position_240d", "一年价格位置", "价格位置",
"收盘价在近240个交易日最低价到最高价之间的位置 (0~1)",
deps=frozenset({"close"}), warmup_bars=241,
),
_virtual(
"distance_to_high_240d", "距一年高点", "价格位置",
"收盘价 / 240日最高收盘价 - 1, 接近0代表贴近一年新高",
deps=frozenset({"close"}), warmup_bars=241,
),
_virtual(
"kdj_kd_diff", "KDJ K-D差", "趋势",
"KDJ K值 - D值, 正值代表快线在慢线上方",
deps=frozenset({"kdj_k", "kdj_d"}),
),
)
_REGISTRY: dict[str, FactorSpec] = {}
def register_factor(spec: FactorSpec) -> None:
"""注册因子; 重复 id 且版本未增时拒绝 (fail-closed)。"""
existing = _REGISTRY.get(spec.id)
if existing is not None and existing.version >= spec.version:
raise ValueError(f"factor id 已注册且版本未提升: {spec.id}")
_REGISTRY[spec.id] = spec
for _spec in _CATALOG:
register_factor(_spec)
def get_factor(fid: str) -> FactorSpec | None:
return _REGISTRY.get(fid)
def unregister_factor(fid: str) -> FactorSpec | None:
"""注销动态注册的因子 (内置目录因子不可注销, fail-closed)。"""
if any(spec.id == fid for spec in _CATALOG):
raise ValueError(f"内置因子不可注销: {fid}")
return _REGISTRY.pop(fid, None)
def _ordered_specs() -> list[FactorSpec]:
"""内置目录顺序在前, 动态注册因子 (custom/composite) 按注册顺序追加。"""
ordered: list[FactorSpec] = list(_CATALOG)
known = {spec.id for spec in _CATALOG}
ordered.extend(spec for fid, spec in _REGISTRY.items() if fid not in known)
return ordered
def _ensure_ext_factors() -> None:
"""扩展表字段惰性同步 (配置目录签名幂等); 失败不阻断注册表读取。"""
try:
from app.factors.ext_factors import ensure_synced
ensure_synced()
except Exception:
import logging
logging.getLogger(__name__).debug("ext factor sync skipped", exc_info=True)
def all_factors(
asset_type: str | None = None,
stable_only: bool = False,
) -> list[FactorSpec]:
"""按目录顺序返回因子; asset_type 过滤适用资产, stable_only 过滤实验/废弃因子。
返回前惰性同步扩展表因子 (ext_ 前缀 base 条目), 使信号字段白名单、
因子库列表和 AI 提示词看到同一份扩展字段清单。
"""
_ensure_ext_factors()
return [
spec for spec in _ordered_specs()
if (asset_type is None or asset_type in spec.asset_types)
and (not stable_only or spec.stability == "stable")
]
def factor_dependencies(fids) -> frozenset[str]:
"""递归展开依赖到 enriched base 列; 未知 id 原样保留 (与 scoring_dependencies 历史语义一致)。"""
resolved: set[str] = set()
for fid in fids:
spec = _REGISTRY.get(str(fid))
if spec is None:
resolved.add(str(fid))
elif spec.dependencies:
resolved.update(spec.dependencies)
else:
resolved.add(spec.id)
return frozenset(resolved)
def factor_columns_view() -> list[dict]:
"""历史 FACTOR_COLUMNS 兼容视图 (顺序、键一致; 动态注册因子追加在末尾)。"""
return [spec.column_view() for spec in _ordered_specs()]
def virtual_dependencies() -> dict[str, frozenset[str]]:
"""历史 VIRTUAL_SCORING_DEPENDENCIES 兼容视图。"""
return {
spec.id: spec.dependencies
for spec in _CATALOG
if spec.kind == "virtual" and spec.dependencies
}
def scoring_warmups() -> dict[str, int]:
"""历史 _ROLLING_SCORING_WARMUP 兼容视图 (仅滚动窗口虚拟因子)。"""
return {
spec.id: spec.warmup_bars
for spec in _CATALOG
if spec.kind == "virtual" and spec.warmup_bars > 1
}