Files
tick-stock-panel/backend/app/strategy/custom_signals.py
T
shy3130 e0cd625ef4 feat(platform): 因子平台与因子↔策略双向联动 v0.2.3
- 因子平台: /factors 一级页(检验/因子库/编辑器/组合/挖掘), DSL 公式因子(25 算子点选、双语字段、我的因子模板、脏公式守卫), 版本与生命周期, 自动挖掘 L1 统计筛选
- 因子↔策略四条桥: 触发器 Zap 快建因子条件信号、因子一键生成排名策略、自定义信号 AI 提示词接入因子分组、策略回测因子归因(胜/败单入场信号日因子均值, 独立 tab, 双语因子名)
- 回测: 统计卡新增盈亏比(≥1 红/<1 绿), 蒙卡回撤合并为中位/95% 双值卡(自适应字号), 高级设置基础过滤与策略编辑器参数对齐(5 组区间)
- 信号库独立页 /signals(原设置 tab 迁出), 持仓提醒入导航; 挖掘并入因子页第 5 tab, /mining 旧链接重定向
- 研究线配套: 因子目录 61→77(评分/矩阵双内核), stats_v2(Newey-West/BH-FDR/DSR), enriched 管道与异动/报价服务配套调整
- 文档: README 导航与特性表、features.md 因子平台章节、操作说明书 9.2、factor-platform-plan 执行状态与 §5、二开文档桥接说明; 交流与支持节改版
- 版本 0.2.2 → 0.2.3; 后端全量 1625 passed(1 例环境性跳过), 前端 build 通过
2026-09-05 15:41:15 +08:00

320 lines
12 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.
"""自定义信号 — 用户用「字段 + 运算符 + 值」组合出的布尔信号。
职责:
- 从 data/user_data/custom_signals/*.json 加载信号定义
- 把每个信号的 conditions 编译成一条 Polars 布尔表达式(AND 组合)
- 供 pipeline 在 compute_signals / compute_enriched_today 末尾注入为列
不知道: 引擎、AI、API、回测、监控。纯函数 + 模块级缓存。
设计:
- 信号列名加前缀 ``csg_`` 避免与内置 ``signal_`` 列冲突。
- 回测/选股/监控都按列名找信号,因此注入列后零特殊处理即可三处生效。
- 字段白名单 + 固定运算符集,杜绝任意表达式注入。
- 第一版只支持 AND(多条件同时满足)。
"""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path
import polars as pl
logger = logging.getLogger(__name__)
# ── 常量 ────────────────────────────────────────────────
PREFIX = "csg_" # 自定义信号列名前缀
ID_RE = re.compile(r"^[a-z0-9_]{1,40}$")
OPS = {">", ">=", "<", "<=", "==", "!="}
# 字段白名单:只允许这些列出现在条件里(防注入)。均为数值型。
# 与 ENRICHED_COLUMNS 的数值列保持一致,排除 symbol/date/name 等非数值列。
ALLOWED_FIELDS: frozenset[str] = frozenset({
# 行情
"open", "high", "low", "close", "volume", "amount", "turnover_rate",
"consecutive_limit_ups", "consecutive_limit_downs",
# 基础
"prev_close", "change_pct", "change_amount", "amplitude",
# 均线 / 指数均线
"ma5", "ma10", "ma20", "ma30", "ma60",
"ema5", "ema10", "ema20", "ema30", "ema60",
# MACD / BOLL / KDJ / ATR
"macd_dif", "macd_dea", "macd_hist",
"boll_upper", "boll_lower",
"kdj_k", "kdj_d", "kdj_j",
"atr_14",
# 量价 / 极值 / 动量 / 波动率 / RSI
"vol_ma5", "vol_ma10", "vol_ratio_5d",
"high_60d", "low_60d",
"momentum_5d", "momentum_10d", "momentum_20d", "momentum_30d", "momentum_60d",
"annual_vol_20d",
"rsi_6", "rsi_14", "rsi_24",
# 异动偏离 (交易所异动规则口径, 运行时列)
"deviate_3d", "deviate_10d", "deviate_30d",
})
# 运算符 → Polars 表达式构造器(输入 col_expr, value
_OP_BUILDERS = {
">": lambda c, v: c > v,
">=": lambda c, v: c >= v,
"<": lambda c, v: c < v,
"<=": lambda c, v: c <= v,
"==": lambda c, v: c == v,
"!=": lambda c, v: c != v,
}
def allowed_fields() -> frozenset[str]:
"""条件可引用字段 = 物化列白名单 并入 注册表因子 (虚拟/自定义/复合)。
因子列在历史路径 (compute_signals) 由 materialize_factor_columns 复用
评分物化管线补算; 盘中单日快照无滚动窗口, 依赖因子的信号被 inject 以
缺列告警跳过 (与日期偏移条件同样的优雅降级)。
"""
from app.factors.registry import all_factors
return frozenset(ALLOWED_FIELDS | {spec.id for spec in all_factors()})
def materialize_factor_columns(
df: pl.DataFrame,
exprs: dict[str, pl.Expr],
needed: set[str] | None = None,
) -> pl.DataFrame:
"""把信号表达式引用、且 df 缺失的注册表因子列补算出来。
复用评分物化路径 (materialize_scoring_columns) — 与检验/评分同一条计算
逻辑, 不引入第二套实现。非注册表列不在此处理 (缺列仍由 inject 告警跳过)。
"""
if df.is_empty() or not exprs:
return df
cols = set(df.columns)
missing: set[str] = set()
for name, roots in expression_dependencies(exprs).items():
if needed is not None and name not in needed:
continue
missing.update(root for root in roots if root not in cols)
if not missing:
return df
from app.factors.registry import all_factors
factor_ids = {spec.id for spec in all_factors()}
to_compute = missing & factor_ids
if not to_compute:
return df
from app.strategy.scoring import materialize_scoring_columns
return materialize_scoring_columns(df, sorted(to_compute))
# ── 持久化(镜像 strategy/config.py 的写法)──────────────
def _dir(data_dir: Path) -> Path:
d = data_dir / "user_data" / "custom_signals"
d.mkdir(parents=True, exist_ok=True)
return d
def _path(data_dir: Path, signal_id: str) -> Path:
return _dir(data_dir) / f"{signal_id}.json"
def load_all(data_dir: Path) -> list[dict]:
"""读取全部自定义信号定义。损坏的文件被跳过。"""
d = _dir(data_dir)
out: list[dict] = []
for f in sorted(d.glob("*.json")):
try:
out.append(json.loads(f.read_text(encoding="utf-8")))
except Exception as e:
logger.warning("custom signal load failed %s: %s", f.name, e)
return out
def save_one(data_dir: Path, sig: dict) -> None:
p = _path(data_dir, sig["id"])
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(json.dumps(sig, ensure_ascii=False, indent=2), encoding="utf-8")
def delete_one(data_dir: Path, signal_id: str) -> bool:
p = _path(data_dir, signal_id)
if p.exists():
p.unlink()
return True
return False
# ── 校验 ────────────────────────────────────────────────
MAX_DAYS = 60 # 偏移天数上限 (前N日的 N)
def _parse_days(c: dict, key: str, i: int) -> int:
"""解析并校验条件的天数偏移 (leftDays / rightDays)。返回 0..MAX_DAYS。"""
raw = c.get(key, 0)
try:
n = int(raw)
except (TypeError, ValueError):
raise ValueError(f"第 {i+1} 个条件: {key} 必须是整数: {raw!r}")
if n < 0 or n > MAX_DAYS:
raise ValueError(f"第 {i+1} 个条件: {key} 必须在 0..{MAX_DAYS} 之间: {n}")
return n
def _parse_right(right: str) -> tuple[str, object]:
"""解析右值。返回 ('field', colname) 或 ('const', float)。
接受三种形式:
- 数字 (int / float / 数字字符串) → 常量
- "field:字段名" → 字段引用
- 裸字段名 (在白名单内) → 自动视为字段引用
(AI 生成偶尔漏写 field: 前缀; 白名单字段名不可能是数字, 无歧义)
"""
if isinstance(right, (int, float)):
return ("const", float(right))
if not isinstance(right, str):
raise ValueError(f"非法右值: {right!r}")
allowed = allowed_fields()
if right.startswith("field:"):
col = right[len("field:"):]
if col not in allowed:
raise ValueError(f"右值字段不在白名单: {col}")
return ("field", col)
# 纯数字
try:
return ("const", float(right))
except ValueError:
pass
# 裸字段名 — 兜底容错, 仍受白名单约束
if right in allowed:
return ("field", right)
raise ValueError(f"非法右值(应为 field:xxx 或数字): {right!r}")
def validate(sig: dict) -> None:
"""校验一个信号定义,非法则抛 ValueError(含中文信息)。"""
sid = sig.get("id", "")
if not isinstance(sid, str) or not ID_RE.match(sid):
raise ValueError(f"信号 id 非法(仅小写字母数字下划线,1-40字符): {sid!r}")
if not isinstance(sig.get("name"), str) or not sig["name"].strip():
raise ValueError("信号 name 不能为空")
if sig.get("kind") not in ("entry", "exit", "both"):
raise ValueError("kind 必须是 entry / exit / both")
conds = sig.get("conditions")
if not isinstance(conds, list) or len(conds) == 0:
raise ValueError("conditions 不能为空")
if len(conds) > 8:
raise ValueError("conditions 最多 8 条")
for i, c in enumerate(conds):
if not isinstance(c, dict):
raise ValueError(f"第 {i+1} 个条件格式错误")
left = c.get("left", "")
if left not in allowed_fields():
raise ValueError(f"第 {i+1} 个条件: 字段 {left!r} 不在白名单")
if c.get("op") not in OPS:
raise ValueError(f"第 {i+1} 个条件: 运算符 {c.get('op')!r} 非法")
_parse_right(c.get("right")) # 会校验右值字段/数字
_parse_days(c, "leftDays", i) # 左字段偏移
_parse_days(c, "rightDays", i) # 右字段偏移
# ── 编译为 Polars 表达式 ─────────────────────────────────
def column_name(signal_id: str) -> str:
"""信号 id → DataFrame 列名(加前缀)。"""
return f"{PREFIX}{signal_id}"
def _col(name: str, days: int = 0) -> pl.Expr:
"""构造列表达式; days>0 时取 N 个交易日前的值 (按 symbol 分组 shift)。"""
expr = pl.col(name)
if days > 0:
expr = expr.shift(days).over("symbol")
return expr
def build_expressions(signals: list[dict], allow_shift: bool = True) -> dict[str, pl.Expr]:
"""把多个自定义信号编译成 {column_name: pl.Expr}。
- 只处理 enabled != False 的信号。
- 单个信号内多条件用 ``&`` 串联(AND)。
- allow_shift=False 时, 跳过带日期偏移 (leftDays/rightDays>0) 的信号
(盘中单日快照上 .shift 跨 symbol 语义不正确, 优雅降级)。
- 编译失败的信号被跳过并告警(不影响其它信号)。
"""
out: dict[str, pl.Expr] = {}
for sig in signals:
if sig.get("enabled") is False:
continue
try:
conds = sig["conditions"]
col_name = column_name(sig["id"])
parts: list[pl.Expr] = []
for c in conds:
left_days = int(c.get("leftDays", 0) or 0)
right_days = int(c.get("rightDays", 0) or 0)
# 盘中路径不支持偏移 → 跳过整个信号
if not allow_shift and (left_days > 0 or right_days > 0):
raise ValueError("盘中实时路径不支持日期偏移条件, 已跳过")
left = c["left"]
op = c["op"]
kind, val = _parse_right(c["right"])
right_expr = _col(val, right_days) if kind == "field" else val
parts.append(_OP_BUILDERS[op](_col(left, left_days), right_expr))
combined = parts[0]
for p in parts[1:]:
combined = combined & p
out[col_name] = combined
except Exception as e:
logger.warning("custom signal compile failed %s: %s", sig.get("id"), e)
return out
def expression_dependencies(exprs: dict[str, pl.Expr] | None = None) -> dict[str, frozenset[str]]:
"""返回自定义信号列到根字段的依赖映射。"""
source = exprs if exprs is not None else {}
return {name: frozenset(_expr_root_columns(expr)) for name, expr in source.items()}
def inject(
df: pl.DataFrame,
exprs: dict[str, pl.Expr],
needed: set[str] | None = None,
) -> pl.DataFrame:
"""把编译好的信号表达式作为列加入 df。
``needed=None`` 保持历史全量语义;传入集合时只注入被请求的自定义信号。
缺失依赖会明确告警,避免回测静默丢失信号。
"""
if df.is_empty() or not exprs:
return df
cols = set(df.columns)
add: dict[str, pl.Expr] = {}
for name, expr in exprs.items():
if needed is not None and name not in needed:
continue
# 提取该表达式引用的所有字段列,缺失则跳过(避免运行时报错)
required = _expr_root_columns(expr)
if required.issubset(cols):
add[name] = expr
else:
logger.warning(
"custom signal %s missing dependencies: %s",
name,
sorted(required - cols),
)
if add:
df = df.with_columns([e.alias(n) for n, e in add.items()])
return df
def _expr_root_columns(expr: pl.Expr) -> set[str]:
"""尽力提取表达式里出现的列名。失败则返回空集(保守跳过)。"""
try:
# Polars 的 meta.root_names() 返回表达式引用的根列名
names = expr.meta.root_names()
return set(names)
except Exception:
return set()