tradingSystem/app/core/signal_rules.py

147 lines
6.8 KiB
Python
Raw Permalink Normal View History

# -*- coding: utf-8 -*-
"""
决策系统盘中信号的解析与消化规则 (纯逻辑, 无外部依赖, 可单测)
================================================================
设计 POSITION_MGMT_DESIGN.md §10信号消化 §1:
风控 SELL盘中 ENTRY/EXIT 广播照常产出, **下游停止直接执行**,
改由 PMS 订阅消化后统一决定卖出指令
两条流的真实格式 (2026-07-28 trading_service 的两个消费者实测确认):
买入/盘中信号 Redis db2, key = `intraday_signals:{YYYY-MM-DD}`, 每日一条流
扁平字段: ts_code / action(BUY|SELL|HOLD) / confidence(**0~1**) /
component_scores(JSON 字符串, 内含 minute_qrs) / suggested_price
风控卖出信号 Redis db3, key = `bionic:signals:llm_sell_actions`, 固定 key
外层含 data(JSON 字符串), 内层: ts_code / action(SELL) /
confidence(**0~100**) / dominant_signal / llm_reason
两条流的置信度**尺度不同**(0~1 0~100), 这是最容易踩的坑, 统一在 parse 里归一到 0~1
消化口径 (PMS ):
* SELL 信号 只对**持有的票**有意义置信度够高即转卖出动作 (减持方向不设确认门槛,
与保垫减仓同一口径); 置信度中等则落提议队列等用户裁决
* BUY / HOLD 信号 **不产生买入动作**买什么买多少是 PMS 自己的命令与动作引擎说了算
(设计: 持仓系统管做什么多少)这类信号只作为择时参考落痕, 不越权
"""
from __future__ import annotations
import json
SRC_INTRADAY, SRC_RISK_SELL = "intraday", "risk_sell"
ACT_EXIT, ACT_PROPOSE, ACT_RECORD, ACT_IGNORE = "EXIT", "PROPOSE", "RECORD", "IGNORE"
def _num(v, d=0.0):
try:
return float(v)
except (TypeError, ValueError):
return d
def _norm_conf(v) -> float:
"""置信度归一到 0~1。两条流一条给 0~1 一条给 0~100, 大于 1 的一律按百分制处理。"""
c = _num(v)
if c > 1:
c = c / 100.0
return max(0.0, min(1.0, c))
def parse_intraday(fields: dict, *, msg_id: str = None) -> dict:
"""买入/盘中信号流 (db2) 的一条消息 → 归一结构。"""
f = fields or {}
scores = {}
raw_scores = f.get("component_scores")
if raw_scores:
try:
scores = json.loads(raw_scores) if isinstance(raw_scores, str) else dict(raw_scores)
except (json.JSONDecodeError, TypeError, ValueError):
scores = {}
return {"source": SRC_INTRADAY, "msg_id": msg_id,
"ts_code": (f.get("ts_code") or "").strip(),
"action": (f.get("action") or "").strip().upper(),
"confidence": _norm_conf(f.get("confidence")),
"minute_qrs": _num(scores.get("minute_qrs")),
"suggested_price": _num(f.get("suggested_price")) or None,
"reason": f.get("reason") or f.get("llm_reason") or "",
"dominant_signal": f.get("dominant_signal") or ""}
def parse_risk_sell(fields: dict, *, msg_id: str = None) -> dict:
"""风控卖出信号流 (db3) 的一条消息 → 归一结构。外层套一层 data JSON 字符串。"""
f = fields or {}
inner = f
raw = f.get("data")
if raw:
try:
inner = json.loads(raw) if isinstance(raw, str) else dict(raw)
except (json.JSONDecodeError, TypeError, ValueError):
return {"source": SRC_RISK_SELL, "msg_id": msg_id, "ts_code": "", "action": "",
"confidence": 0.0, "parse_error": "内层 data JSON 解析失败",
"reason": "", "dominant_signal": ""}
return {"source": SRC_RISK_SELL, "msg_id": msg_id,
"ts_code": (inner.get("ts_code") or "").strip(),
"action": (inner.get("action") or "").strip().upper(),
"confidence": _norm_conf(inner.get("confidence")),
"dominant_signal": inner.get("dominant_signal") or "",
"reason": (inner.get("llm_reason") or inner.get("reason") or "")[:500],
"suggested_price": _num(inner.get("suggested_price")) or None}
def digest(signal: dict, position: dict, params: dict) -> dict:
"""一条信号 → 一个消化结论。
position: PMS 账本里这只票的快照 (无持仓传 None total_qty=0)
params: {sell_conf_min, auto_exit_conf, trim_ratio}
返回 {"action": EXIT|PROPOSE|RECORD|IGNORE, "qty", "reason", "hard_numbers"}
"""
code = (signal or {}).get("ts_code") or ""
act = (signal or {}).get("action") or ""
conf = _num((signal or {}).get("confidence"))
hard = {"source": signal.get("source"), "confidence": round(conf, 4),
"dominant_signal": signal.get("dominant_signal"),
"minute_qrs": signal.get("minute_qrs")}
if not code:
return _r(ACT_IGNORE, 0, "信号缺少股票代码", hard)
if act != "SELL":
# 买入/持有类信号不产生动作 —— 买什么买多少由 PMS 的命令与动作引擎决定
return _r(ACT_RECORD, 0, f"{act or '未知'} 信号仅作择时参考留痕, PMS 不据此买入", hard)
held = int((position or {}).get("total_qty") or 0)
if held <= 0:
return _r(ACT_IGNORE, 0, "未持有该票, 卖出信号无对象", hard)
conf_min = _num(params.get("sell_conf_min"), 0.75)
auto_conf = _num(params.get("auto_exit_conf"), 0.85)
if conf < conf_min:
return _r(ACT_IGNORE, 0,
f"置信度 {conf:.0%} < 消化门槛 {conf_min:.0%}, 不动", hard)
avail = int((position or {}).get("avail_qty") or 0)
hard.update({"total_qty": held, "avail_qty": avail})
why = signal.get("reason") or signal.get("dominant_signal") or "决策系统风控卖出"
if conf >= auto_conf:
# 高置信风控卖出 = 清仓。减持方向不设确认门槛 (与保垫减仓同一口径)
return _r(ACT_EXIT, held,
f"风控 SELL 置信度 {conf:.0%}{auto_conf:.0%}, 清仓 {held} 股 —— {why}", hard)
ratio = _num(params.get("trim_ratio"), 1 / 3)
# 四舍五入到一手, 不用向下取整: 配置里写 0.3333 还是 1/3 不该让 3000 股的三分之一
# 一会儿算成 1000 一会儿算成 900。不足一手时退化为全卖 (一手是最小可操作单位)。
qty = int(round(held * ratio / 100)) * 100
if qty <= 0:
qty = held
return _r(ACT_PROPOSE, qty,
f"风控 SELL 置信度 {conf:.0%} 介于 {conf_min:.0%}~{auto_conf:.0%}, "
f"提议减 {qty} 股待确认 —— {why}", hard)
def _r(action, qty, reason, hard):
return {"action": action, "qty": int(qty), "reason": reason, "hard_numbers": hard}
def dedup_key(signal: dict, ymd) -> str:
"""当日去重键: 同一只票、同一来源、同一动作, 一天只消化一次。"""
return f"{ymd}:{signal.get('source')}:{signal.get('ts_code')}:{signal.get('action')}"