Files
tick-stock-panel/backend/app/strategy/custom_signals_ai.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

260 lines
10 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.
"""AI 生成自定义信号 — 组装提示词 + 解析并校验 AI 返回的结构化条件。
职责:
- build_messages(description): 把用户一句描述 + 字段白名单/运算符/格式要求组装成 LLM 消息
- parse_and_validate(text): 把 LLM 返回的 JSON 解析为 {name, conditions},并复用
custom_signals.validate() 做白名单/运算符/偏移校验(安全闸门)
不知道: API、AI 调用、持久化。纯函数,无副作用。
"""
from __future__ import annotations
import json
import re
from app.indicators.pipeline import ENRICHED_COLUMNS, ENRICHED_COLUMNS_BY_CATEGORY
from app.strategy import custom_signals
# 分类 → 中文标签(与 /api/custom-signals/options 的分组一致)
_GROUP_LABELS = {
"basic": "基础", "ma": "均线 MA", "ema": "指数均线 EMA",
"macd": "MACD", "boll": "布林带 BOLL", "kdj": "KDJ",
"atr": "ATR", "volume": "量价", "extremes": "极值",
"momentum": "动量", "volatility": "波动率", "rsi": "RSI",
}
# 行情类字段不在 ENRICHED_COLUMNS_BY_CATEGORY 里, 单独归一组
_QUOTE_FIELDS = {
"open", "high", "low", "close", "volume", "amount", "turnover_rate",
"consecutive_limit_ups", "consecutive_limit_downs",
}
_FENCED_JSON_RE = re.compile(r"```(?:json)?\s*\n?(.*?)```", re.DOTALL)
def _format_fields() -> str:
"""按类别格式化白名单字段(key(中文标签)), 供 LLM 参考.
行情/指标类物理列之后追加注册表因子, 分组与 /api/custom-signals/options
的 factor 分组一致: 因子是预计算因子值, 同样可作为条件字段比较.
"""
allowed = custom_signals.allowed_fields()
lines: list[str] = []
quote = sorted(f for f in _QUOTE_FIELDS if f in allowed)
lines.append(
"行情: " + ", ".join(f"{f}({ENRICHED_COLUMNS.get(f, f)})" for f in quote)
)
for cat, label in _GROUP_LABELS.items():
fields = [f for f in ENRICHED_COLUMNS_BY_CATEGORY.get(cat, []) if f in allowed]
if fields:
lines.append(
f"{label}: "
+ ", ".join(f"{f}({ENRICHED_COLUMNS.get(f, f)})" for f in fields)
)
from app.factors.registry import all_factors
factor_groups: dict[str, list[str]] = {}
for spec in all_factors():
if spec.id in custom_signals.ALLOWED_FIELDS:
continue # 已作为物理列出现在清单里
label = spec.label
if spec.asset_types == frozenset({"stock"}):
label += "·仅股票"
factor_groups.setdefault(spec.group, []).append(f"{spec.id}({label})")
for group, items in sorted(factor_groups.items()):
lines.append(f"因子·{group}: " + ", ".join(sorted(items)))
# string 扩展字段 (概念/行业归属): 只支持 contains/==/!=, 右值为字符串字面量
from app.factors.ext_factors import ext_string_field_entries
str_entries = ext_string_field_entries()
if str_entries:
lines.append(
"字符串字段(仅 contains/==/!=): "
+ ", ".join(f"{e['key']}({e['label']})" for e in str_entries)
)
return "\n".join(lines)
_SYSTEM_TEMPLATE = """你是A股量化信号设计专家。用户会描述一个信号思路,你要把它拆解为布尔条件组合(多条件之间是「且」关系,即同时满足),并输出结构化 JSON 供系统编译为选股/回测/监控信号。
可用字段(白名单,只能使用以下字段,禁止自造或使用白名单之外的字段):
{fields}
其中「因子·」开头的行是平台预计算的因子值(动量/波动/量价等衍生特征),可直接比较数值构造条件。
运算符(op):> >= < <= == !=
字符串字段额外支持 contains(包含子串, 如概念/行业归属判断), 右值为字符串字面量, 如 "AI"、"半导体".
右值(right):
- 数字:写字符串形式,如 "2"、"3000"、"0.05"
- 另一字段:必须带 "field:" 前缀,如 "field:ma20";严禁裸写字段名,如 "macd_dea" 应写成 "field:macd_dea"
- 字符串字面量: 仅当左字段是「字符串字段」时使用(配合 contains/==/!=), 如所属概念包含AI写成 {{"left": "字符串字段", "op": "contains", "right": "AI", "leftDays": 0, "rightDays": 0}}
日期偏移(leftDays / rightDays):取 N 个交易日前的值,0 = 当日最新;范围 0~{max_days}。只有明确需要「前N日」时才使用偏移。
要求:
1. 只输出一个 JSON 对象,禁止 markdown 代码块、禁止任何解释或多余文字。
2. JSON 结构固定为:
{{"name": "简短中文信号名称(≤12字)", "conditions": [
{{"left": "字段", "op": "运算符", "right": "数字字符串或field:字段", "leftDays": 0, "rightDays": 0}}
]}}
示例(右值引用另一字段时必须带 field: 前缀,不能裸写字段名):
{{"name": "MACD金叉", "conditions": [
{{"left": "macd_dif", "op": ">", "right": "field:macd_dea", "leftDays": 0, "rightDays": 0}}
]}}
3. conditions 至少 1 个、最多 8 个;优先用最少的条件表达清晰的思路。
4. 多条件必须能同时满足,不要输出互相矛盾的条件。"""
def build_messages(description: str) -> list[dict]:
"""组装 LLM 消息:[system 提示词, user 描述]。"""
system = _SYSTEM_TEMPLATE.format(
fields=_format_fields(),
max_days=custom_signals.MAX_DAYS,
)
return [
{"role": "system", "content": system},
{"role": "user", "content": description},
]
def parse_and_validate(text: str) -> dict:
"""解析并校验 AI 返回的 JSON → {"name", "conditions"}。非法时抛 ValueError。
只取 name 与 conditionsid / kind 由用户在表单里填写,不信任 AI。
"""
raw = _extract_json_object(text)
if not isinstance(raw, dict):
raise ValueError("AI 返回的 JSON 不是对象")
name = raw.get("name")
if not isinstance(name, str) or not name.strip():
raise ValueError("AI 未返回信号名称 name")
name = name.strip()[:30]
conditions_raw = raw.get("conditions")
if not isinstance(conditions_raw, list) or not conditions_raw:
raise ValueError("AI 未返回任何条件 conditions")
conditions = [_normalize_condition(c) for c in conditions_raw]
# 复用现有白名单/运算符/偏移校验作为安全闸门(id/kind 用占位值)。
probe = {
"id": "aigenerated",
"name": name,
"kind": "both",
"conditions": conditions,
}
custom_signals.validate(probe)
return {"name": name, "conditions": conditions}
def _normalize_condition(c: object) -> dict:
if not isinstance(c, dict):
raise ValueError("条件的每一项必须是 JSON 对象")
left = c.get("left")
op = c.get("op")
right = c.get("right")
if right is None:
raise ValueError("条件缺少右值 right")
if isinstance(right, bool):
raise ValueError("右值不能是布尔值")
if isinstance(right, (int, float)):
right = _num_to_str(right)
if not isinstance(right, str) or not right.strip():
raise ValueError(f"右值非法: {right!r}")
right = right.strip()
# 兜底: AI 偶尔漏写 field: 前缀的裸字段名, 补全为规范形式 (含因子字段)
if not right.startswith("field:") and right in custom_signals.allowed_fields():
right = f"field:{right}"
return {
"left": str(left),
"op": str(op),
"right": right,
"leftDays": _norm_days(c.get("leftDays")),
"rightDays": _norm_days(c.get("rightDays")),
}
def _norm_days(value: object) -> object:
"""归一化日期偏移为 int(缺省 0);无法转换时原样返回,由 validate 报中文错误。"""
if value is None:
return 0
if isinstance(value, bool):
return value
try:
return int(value)
except (TypeError, ValueError):
return value
def _num_to_str(value) -> str:
if isinstance(value, int):
return str(value)
f = float(value)
return str(int(f)) if f.is_integer() else str(f)
def _extract_json_object(text: str) -> object:
"""从 LLM 文本提取 JSON 对象(多级容错)。
依次尝试: 整段 → markdown 围栏内 → 首个 {...} 平衡块;
每级再对 尾随垃圾 / 尾逗号 做轻量修复。全部失败才报错。
"""
source = text or ""
candidates: list[str] = []
stripped = source.strip()
if stripped:
candidates.append(stripped)
candidates.extend(
match.group(1).strip() for match in _FENCED_JSON_RE.finditer(source)
)
brace = _first_brace_block(source)
if brace and brace.strip() not in candidates:
candidates.append(brace)
last_error: Exception | None = None
for candidate in candidates:
parsed = _try_parse_json(candidate)
if parsed is not None:
return parsed
try:
json.loads(candidate)
except json.JSONDecodeError as e:
last_error = e
raise ValueError(f"AI 返回的不是合法 JSON: {last_error}")
def _try_parse_json(candidate: str) -> object | None:
"""尽力解析一段可能带尾随垃圾 / 尾逗号的 JSON;失败返 None。"""
variants = [candidate.strip()]
last = candidate.rfind("}")
if last >= 0 and last < len(candidate) - 1:
variants.append(candidate[:last + 1].strip())
for v in variants:
if not v:
continue
try:
return json.loads(v)
except json.JSONDecodeError:
pass
# 去掉数组/对象结尾的多余逗号 (AI 常见错误): `,}` / `,]`
cleaned = re.sub(r",\s*([}\]])", r"\1", v)
if cleaned != v:
try:
return json.loads(cleaned)
except json.JSONDecodeError:
pass
return None
def _first_brace_block(text: str) -> str:
"""括号配对截取首个 {...} 块(AI 偶尔混入前后解释文字时的兜底)。"""
start = text.find("{")
if start < 0:
return text
depth = 0
for i in range(start, len(text)):
if text[i] == "{":
depth += 1
elif text[i] == "}":
depth -= 1
if depth == 0:
return text[start:i + 1]
return text[start:]