Files
tick-stock-panel/backend/app/api/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

192 lines
7.6 KiB
Python

"""自定义信号 API 路由 — HTTP 请求 → 调用 custom_signals 模块 → 返回响应。
只做胶水:校验 → 持久化 → 失效缓存。不含表达式编译逻辑。
"""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from app.strategy import custom_signals
router = APIRouter(prefix="/api/custom-signals", tags=["custom-signals"])
def _data_dir(request: Request) -> Path:
return request.app.state.repo.store.data_dir
def _invalidate(request: Request) -> None:
"""失效自定义信号表达式缓存, 并清掉含旧信号列的计算缓存。
信号增删会改变注入列集合: 只清表达式缓存不够, repo 内存缓存 /
strategy 磁盘缓存里算好的历史窗口仍不含新 csg_ 列 (或仍含已删列),
需要一并清除, 否则创建信号后立即运行策略仍会报缺列。
"""
from app.indicators.pipeline import invalidate_custom_signals
invalidate_custom_signals()
from app.services import strategy_cache
strategy_cache.clear_cache(_data_dir(request))
repo = request.app.state.repo
if hasattr(repo, "clear_cache"):
repo.clear_cache()
class ConditionModel(BaseModel):
left: str # 字段名(须在白名单)
op: str # > >= < <= == !=
right: str # "field:xxx" 或数字字符串
leftDays: int = 0 # 左字段取几日前 (0=当日, 默认)
rightDays: int = 0 # 右字段取几日前 (仅 right 为字段时有意义)
class SignalModel(BaseModel):
id: str
name: str
kind: str # entry | exit | both
conditions: list[ConditionModel]
enabled: bool = True
class AIGenerateRequest(BaseModel):
description: str
# ── 字段选项 / 运算符 ───────────────────────────────────
@router.get("/options")
def get_options():
"""返回可选字段与运算符,供前端下拉框使用。"""
# 字段带中文标签(取自 ENRICHED_COLUMNS,回退为字段名本身)
from app.indicators.pipeline import ENRICHED_COLUMNS, ENRICHED_COLUMNS_BY_CATEGORY
allowed = custom_signals.ALLOWED_FIELDS
fields = [
{"key": f, "label": ENRICHED_COLUMNS.get(f, f)}
for f in sorted(allowed)
]
# 字段分组 (只包含白名单内的字段, 供前端 optoptgroup 渲染)
_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"}
groups = [{"key": "quote", "label": "行情",
"fields": [{"key": f, "label": ENRICHED_COLUMNS.get(f, f)}
for f in sorted(allowed & quote_fields)]}]
for cat, label in _GROUP_LABELS.items():
cat_fields = [f for f in ENRICHED_COLUMNS_BY_CATEGORY.get(cat, []) if f in allowed]
if cat_fields:
groups.append({"key": cat, "label": label,
"fields": [{"key": f, "label": ENRICHED_COLUMNS.get(f, f)} for f in cat_fields]})
# 注册表因子 (虚拟/自定义/复合): 历史路径由 compute_signals 复用评分物化
# 管线补算; 已是物化列的基础因子 (rsi_14 等) 上面已分组, 此处跳过。
from app.factors.registry import all_factors
factor_groups: dict[str, list[dict[str, str]]] = {}
for spec in all_factors():
if spec.id in allowed:
continue
label = spec.label
if spec.warmup_bars > 1:
label = f"{label} · 预热{spec.warmup_bars}日"
if list(spec.asset_types) == ["stock"]:
label = f"{label} · 仅股票"
factor_groups.setdefault(spec.group or "因子", []).append({"key": spec.id, "label": label})
for group_label, group_fields in factor_groups.items():
groups.append({"key": f"factor:{group_label}", "label": f"因子 · {group_label}", "fields": group_fields})
fields.extend(group_fields)
return {
"fields": fields,
"groups": groups,
"maxDays": custom_signals.MAX_DAYS,
"operators": [">", ">=", "<", "<=", "==", "!="],
"kinds": [
{"key": "entry", "label": "入场"},
{"key": "exit", "label": "出场"},
{"key": "both", "label": "出入通用"},
],
}
# ── 列表 ───────────────────────────────────────────────
@router.get("")
def list_signals(request: Request):
sigs = custom_signals.load_all(_data_dir(request))
return {"signals": sigs}
# ── 新建 / 更新 ────────────────────────────────────────
@router.post("")
def save_signal(req: SignalModel, request: Request):
sig = req.model_dump()
try:
custom_signals.validate(sig)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
custom_signals.save_one(_data_dir(request), sig)
_invalidate(request)
return {"ok": True, "signal": sig}
# ── AI 生成 ─────────────────────────────────────────────
@router.post("/ai/generate")
async def ai_generate_signal(req: AIGenerateRequest):
"""AI 根据自然语言描述生成自定义信号条件。
不落盘:只返回 {name, conditions} 供前端回填表单,由用户确认后走
常规 save 流程。校验复用 custom_signals.validate()(白名单安全闸门)。
"""
from app.services.ai_provider import generate_ai_text
from app.strategy import custom_signals_ai
description = req.description.strip()
if not description:
raise HTTPException(status_code=400, detail="请先描述信号思路")
if len(description) > 500:
raise HTTPException(status_code=400, detail="描述过长(最多 500 字)")
messages = custom_signals_ai.build_messages(description)
try:
# max_tokens=None 不传上限: 推理模型思考 token 计入预算, 显式限制
# 会挤占正文导致 JSON 截断/0 字 (与四个分析器同因, 见 0ee3aa8)
text = await generate_ai_text(messages, temperature=0.2, max_tokens=None)
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except Exception as e:
raise HTTPException(status_code=400, detail=f"AI 生成失败: {e}") from e
try:
return custom_signals_ai.parse_and_validate(text)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
# ── 删除 ───────────────────────────────────────────────
@router.delete("/{signal_id}")
def delete_signal(signal_id: str, request: Request):
if not custom_signals.ID_RE.match(signal_id):
raise HTTPException(status_code=400, detail="信号 id 非法")
deleted = custom_signals.delete_one(_data_dir(request), signal_id)
if not deleted:
raise HTTPException(status_code=404, detail="信号不存在")
_invalidate(request)
return {"ok": True}