# -*- coding: utf-8 -*- """ 研判闸客户端 · 二级关口 (设计 §7) ================================== 「PMS 备齐硬数字与账本流水作为 context → 请求决策系统研判 (其 process_intraday_audit 新增 PMS 请求类 direction, 复用既有仲裁哲学: 代码算数、AI 裁定性、越界收口) → 结构化答复 通过/驳回+理由。**研判服务不可用 → 自动降级 propose_only (人工确认替位) + ERROR 告警**。命令驱动动作不过研判闸 (不用 AI 审用户)。」 当前状态: 决策系统侧的 PMS 请求 direction 尚未开发 (bionic 仓库的配套改造)。 因此本模块默认 `PMS_JUDGE_API_BASE` 为空 = 未接通, 一律返回 UNAVAILABLE, 调用方按设计 降级为「人工确认替位」。接通后只需在页面把 base 填上, 无需改代码。 返回结构固定: {"verdict": PASS|REJECT|UNAVAILABLE, "reason", "degraded", "raw"} """ from __future__ import annotations import logging from app.services import param_store logger = logging.getLogger("pms.judge") PASS, REJECT, UNAVAILABLE = "PASS", "REJECT", "UNAVAILABLE" # 新建仓送研判时, hard_numbers 里**只送这几个键**。 # # 决策系统本来就不管仓位 —— 它是一套聚合了大量信号的判断系统, 「买多少」从来不是它的活。 # 而它收到 hard_numbers 之后是逐键渲染成「【硬数字 (PMS 已算好, 勿重算)】」贴进提示词的, # 所以把名额、可投金额、批次比例、组合占比原样送过去, 等于当面请一个不该管仓位的系统去看 # 仓位 —— 提示词后面那句「仓位纪律不归你管」压不住已经摆在它眼前的一串数字。 # # 这里只送「这只票凭什么被选出来」: 现价 + 候选池给的定性材料, 那正是要它裁的问题。 # 账本不受影响 —— pms_action_ledger.hard_numbers_json 照旧存全量, 判分锚一个字不少。 # # **只对 OPEN 生效。** FILL/ADD/DCA 的硬数字 (安全垫、评估档位、底仓量) 本身就是它们的判据, # 一个都不能删: 那几类动作是在**已有持仓**上做加减, 谈的就是这个仓位, 与新建仓不是一回事。 OPEN_JUDGE_KEYS = ("price", "score", "theme", "tier", "upside", "heat", "plan_rank", "plan_bucket", "plan_src", "sector", # 决策系统今天盘中判没判过这只票转多。这是它自己的结论, 不是仓位数字, # 送回去等于当面提醒它「你今天判过」, 让它拿自己的结论对照一次。 "intraday_buy_signal", "intraday_buy_reason", # 上游候选卡的判决与理由 (2026-09-02 起随计划下发)。决策系统那侧是逐键 # 渲染进提示词的, 所以这五项放行之后不用改它的提示词就能看见; 配合下面 # must_answer 里那条「每条理由到今天是否仍成立」, 研判就有了可逐条核对的靶子。 "verdict", "reasons", "missing", "risk", "card_rank", # 催化事件与定价状态的整句 (2026-09-08《量价研判链吸收方案》3.5): 它们是事件与量价, # 正是研报里决策智能体的"事件上下文", 不是产业逻辑 —— 与判决依据、论断、逻辑状态、 # 安全边际那几项不同, 这两句要送。送整句不送字典: 研判提示词把硬数字逐行印成 # "键: 值", 字典会印成一串代码, 整句模型才读得顺。 "events_text", "pricing_text", # 相关快讯整句 (2026-09-08 台账 050): 财联社电报近 3 天点名这只票的条目。它是市场 # 反应与公司快讯, 不是产业逻辑; 正负不由 PMS 判, 交给研判连同事件上下文一起看。 "news_text", # 公司深度整句 (2026-09-09 接入方案第四节): 质地档、买方论点一句、置信度、失效条件。照 09-02 送判决 # 与理由的先例——让研判拿自己的量价结论对照; 质地本身由选股系统判, 研判不重判。开关 PMS_JUDGE_COMPANY_TEXT。 "company_review_text", # 重问时的那一句 (2026-09-09 台账 055): 曾在什么价被驳回、现在价量变成什么样。 # 只在解锁重问那一次带; 平时这个键根本不在硬数字里, 白名单收了也送不出去。 "reask_text") # 新建仓送研判时的必答题 (2026-09-03)。候选卡的理由随硬数字送过去了, 研判要逐条回答 # 「这条理由到今天还成不成立」—— 这是把上游的静态判决与决策系统的当日观点接起来的那一句。 OPEN_MUST_ANSWER = "上游候选卡的每条理由到今天是否仍成立" def _judge_hard_numbers(action: str, hard: dict) -> dict: """按动作裁剪送出去的硬数字。非 OPEN 一律原样送 (行为与 2026-08-06 之前一字不差)。""" if str(action or "").upper() != "OPEN": return dict(hard or {}) keys = set(OPEN_JUDGE_KEYS) try: if not param_store.get_bool("PMS_JUDGE_COMPANY_TEXT", True): keys.discard("company_review_text") except Exception: # noqa: BLE001 —— 参数读不到按默认送 pass return {k: v for k, v in (hard or {}).items() if k in keys} def jsonable(v): """把请求体里 `requests` 的 `json=` 序列化不了的类型换掉。递归处理字典与列表。 2026-08-06 实机撞上的一条: 研判闸第一次真正接通并发出请求, 三条新建仓提议全部失败于 TypeError: Object of type Decimal is not JSON serializable 源头是 `proposal_service._recent_ledger()` —— 它把评审账本流水塞进 context, 其中 `price` 取自 `pms_action_ledger.price_at`, 那是数据库的 DECIMAL 列, 读出来是 Decimal。 **这条的危害是渐进的, 所以特别值得写下来**: 只要一只票在评审账本里有过任何一行, 它的研判请求就必然失败; 而账本只会越积越多, 于是过几天几乎所有票的研判都会失败, 全部降级成人工确认 —— 「不用每天靠人」这件事会一点点失效, 而每一条看起来都只是 「研判不可用, 降级人工确认」这种系统里天天都有的正常降级, 没人会觉得不对。 它今天才第一次有机会暴露, 是因为在此之前 PMS_JUDGE_API_BASE 一直是空的: `available()` 为假就直接返回不可用, 请求体根本没构造过。 修在这里而不是修 `_recent_ledger` 一处, 是因为请求体的任何一层将来都可能再冒出 数据库类型 —— 一次拦住比每加一个字段就想一次靠谱。`_recent_ledger` 那边也顺手把 `price` 转成了 float, 让日志与留痕里的数字也是干净的。 择时那条路 (`exec_advisor._advice`) 目前不受影响: 它的 refs 与 position 都取自 `portfolio.positions_view()`, 那里每个数值都过了 float()/round()。哪天它也报同样的 TypeError, 照这里的写法在它的 `_post` 前包一层即可。 """ from datetime import date, datetime from decimal import Decimal if isinstance(v, Decimal): return float(v) if isinstance(v, (datetime, date)): return str(v) if isinstance(v, dict): return {k: jsonable(x) for k, x in v.items()} if isinstance(v, (list, tuple, set)): return [jsonable(x) for x in v] return v def enabled() -> bool: return param_store.get_bool("PMS_JUDGE_ENABLED", True) def base_url() -> str: return (param_store.get("PMS_JUDGE_API_BASE", "") or "").strip().rstrip("/") def available() -> bool: return bool(enabled() and base_url()) def judged_actions() -> set: return set(param_store.get_list("PMS_JUDGE_ACTIONS", ["FILL", "ADD", "DCA", "SWITCH"])) def status() -> dict: if not enabled(): return {"available": False, "reason": "研判闸已关闭 (PMS_JUDGE_ENABLED=False), " "自主提议一律走人工确认"} if not base_url(): return {"available": False, "reason": "决策系统 PMS 研判接口未配置 " "(PMS_JUDGE_API_BASE 为空) —— 待 bionic 侧配套改造" "完成后填入, 当前自动降级为人工确认"} return {"available": True, "base": base_url(), "actions": sorted(judged_actions())} def _conf_or_none(v): """应答里的 confidence (契约 §2.3: 0~100) 转成数字; 缺失或不是数字一律 None。""" try: if v is None or (isinstance(v, str) and not v.strip()): return None return float(v) except (TypeError, ValueError): return None def _pv_heads_or_none(v): """应答里的 pv_heads 归一: {h5: {score, direction, justification}, h20: {...}}; 评分夹到 -1 到 1, 两个头都没有评分就是 None。""" if not isinstance(v, dict): return None out = {} for k in ("h5", "h20"): h = v.get(k) if not isinstance(h, dict): continue score = _conf_or_none(h.get("score")) if score is None: continue score = max(-1.0, min(1.0, score)) direction = "看多" if score > 0.2 else ("看空" if score < -0.2 else "中性") out[k] = {"score": round(score, 3), "direction": direction, "justification": str(h.get("justification") or "").strip()[:120]} return out or None def _map_verdict(data: dict) -> dict: """把决策系统应答的 verdict 归一到 PASS / REJECT / UNAVAILABLE, 并带出原因与置信度。 契约见 BIONIC_PMS_INTERFACE §2.3: verdict 合法取值是 PASS / REJECT / UNAVAILABLE, UNAVAILABLE 还会带一句原因。UNAVAILABLE 是设计内的正常降级 (该股无昨夜结论、裁决 越界或解析失败、队列超时等), **必须把决策系统给的真实原因带出来**, 而不是把它当成 协议出错、拼一句「研判答复无法识别」盖掉真原因。早先没有这个分支时, 一条正常的 UNAVAILABLE 会落进最后那道兜底, 页面显示成「研判答复无法识别: UNAVAILABLE」—— 读起来像协议 bug, 其实只是决策系统说「这只票我没有昨夜结论」。 只有既不是三种合法值、又解析不出的乱码, 才真算「无法识别」。 confidence (2026-09-03 起保留): 应答里的置信度原样带出 (0~100 制, 缺就是 None), 随 提议的硬数字进队列与账本, 让人裁决时看得见决策系统有多确定。 """ data = data or {} verdict = str(data.get("verdict") or data.get("decision") or "").upper() reason = data.get("reason") or data.get("rationale") or "" conf = _conf_or_none(data.get("confidence")) # 两个期限的头 (2026-09-08 量价研判链): 决策系统在应答里并列给的 5 日与 20 日判断, 原样带出进提议硬数字, # 只显示不触发。缺就是 None, 旧应答一个字不用改。 heads = _pv_heads_or_none(data.get("pv_heads")) if verdict in ("PASS", "APPROVE", "APPROVED", "ALLOW", "通过"): return {"verdict": PASS, "reason": reason, "degraded": False, "raw": data, "confidence": conf, "pv_heads": heads} if verdict in ("REJECT", "DENY", "DENIED", "BLOCK", "驳回"): return {"verdict": REJECT, "reason": reason, "degraded": False, "raw": data, "confidence": conf, "pv_heads": heads} if verdict in ("UNAVAILABLE", "PMS_UNAVAILABLE", "NA", "N/A", "不可用"): logger.warning("[研判闸] 决策系统回不可用, 降级人工确认: %s", reason or "(无原因)") return {"verdict": UNAVAILABLE, "reason": reason or "决策系统研判不可用", "degraded": True, "raw": data, "confidence": conf, "pv_heads": heads} logger.error("[研判闸] 答复无法识别 (%s), 按不可用降级", verdict or data) return {"verdict": UNAVAILABLE, "reason": f"研判答复无法识别: {verdict or data}", "degraded": True, "raw": data, "confidence": conf} def must_answer_for(action: str) -> list: """按动作给研判的必答题。补仓类问「杀逻辑还是杀情绪」(设计 §7 原文); 新建仓问 「候选卡的每条理由到今天是否仍成立」(2026-09-03 加); 其余不问。""" a = str(action or "").upper() if a == "DCA": return ["下跌是杀逻辑还是杀情绪"] if a == "OPEN": return [OPEN_MUST_ANSWER] return [] def request(candidate: dict, context: dict = None, *, timeout: int = None, force: bool = False, reask_text: str = None) -> dict: """请求一次研判。任何异常/超时/未接通都返回 UNAVAILABLE (绝不把提议当成通过)。 force (2026-09-09, 台账 055): 请求体里多带 force_refresh 与 reask_text 两个键。 决策系统那侧对同票同动作有一层三十分钟缓存, 而那层缓存的键里没有任何输入指纹 —— 价格、资金、量能全变了, 三十分钟内仍然回同一份裁决。解锁重问必须绕开它, 否则 "重新评估"评的还是半小时前那份。节流责任仍在那侧: 它自己有每票每日的强制次数预算。 **force 为假时请求体与今天逐字节相同** —— 这是"加不改"的机器证明, 单测钉着。 """ action = str(candidate.get("action") or "").upper() if action not in judged_actions(): return {"verdict": PASS, "reason": f"{action} 不在研判范围, 规则闸通过即可", "degraded": False, "raw": None, "confidence": None} if not available(): st = status() logger.error("[研判闸] 不可用, 降级人工确认: %s", st["reason"]) return {"verdict": UNAVAILABLE, "reason": st["reason"], "degraded": True, "raw": None, "confidence": None} payload = { "direction": "PMS_JUDGE", "action": action, "ts_code": candidate.get("ts_code"), "qty": candidate.get("qty"), "reason": candidate.get("reason"), # 硬数字按动作裁剪: 新建仓只送定性材料, 不送仓位数字 (见 OPEN_JUDGE_KEYS 的说明) "hard_numbers": _judge_hard_numbers(action, candidate.get("hard_numbers")), "context": context or {}, # 设计要求: 补仓类研判必须回答「下跌是杀逻辑还是杀情绪」。 # 新建仓 (2026-09-03 起) 也带一条: 「上游候选卡的每条理由到今天是否仍成立」。 # 早先这条只写在决策系统的判据里、这边不传, 理由是「建仓该问什么」属于它的知识; # 现在候选卡的理由随硬数字送了过去, 问题就有了具体的靶子, 由 PMS 点名要它逐条回答。 "must_answer": must_answer_for(action), } if force: payload["force_refresh"] = True if reask_text: payload["reask_text"] = str(reask_text)[:600] to = int(timeout or param_store.get_int("PMS_JUDGE_TIMEOUT", 90)) url = base_url() + (param_store.get("PMS_JUDGE_PATH", "/api/intraday/pms_judge") or "") payload = jsonable(payload) # 数据库类型 (Decimal/日期) 换成 JSON 认得的, 见 jsonable try: import requests r = requests.post(url, json=payload, timeout=to) r.raise_for_status() data = r.json() or {} except Exception as e: logger.error("[研判闸] 请求失败, 降级人工确认: %s: %s", type(e).__name__, e) return {"verdict": UNAVAILABLE, "reason": f"研判请求失败: {type(e).__name__}: {e}", "degraded": True, "raw": None, "confidence": None} return _map_verdict(data)