From 0610a7e328be2a3c6d9df40c995bdc3f63e92507 Mon Sep 17 00:00:00 2001 From: zlt Date: Thu, 3 Sep 2026 11:51:42 +0800 Subject: [PATCH] =?UTF-8?q?PMS=20=E6=8E=A5=E9=80=9A=E9=80=89=E8=82=A1?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E5=88=A4=E5=86=B3=EF=BC=9A=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E5=A2=9E=E8=AF=BB=E5=88=A4=E5=86=B3=E4=BA=94?= =?UTF-8?q?=E9=94=AE=E3=80=81=E6=8C=89=E5=88=A4=E5=86=B3=E5=88=86=E6=B5=81?= =?UTF-8?q?=E6=96=B0=E5=BB=BA=E4=BB=93=E3=80=81=E7=A0=94=E5=88=A4=E9=94=AE?= =?UTF-8?q?=E6=94=BE=E8=A1=8C=E4=B8=8E=E5=BF=85=E7=AD=94=E9=A2=98=E3=80=81?= =?UTF-8?q?=E4=BA=BA=E5=B7=A5=E8=A3=81=E5=86=B3=E7=90=86=E7=94=B1=E5=BF=85?= =?UTF-8?q?=E5=A1=AB=E3=80=81=E4=BF=A1=E5=8F=B7=E6=9D=A5=E6=BA=90=E5=8C=BA?= =?UTF-8?q?=E5=88=86=E3=80=81=E4=BC=9A=E8=AF=9D=E5=AF=86=E9=92=A5=E8=A1=A5?= =?UTF-8?q?=E8=BF=9B=E5=AF=86=E9=92=A5=E5=90=8D=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按判决分流(PMS_PLAN_ROUTE_BY_VERDICT,默认开):候选走自动流程,关注强制人工确认,仅展示不出提议;上游无判决或开关关闭时与旧行为一致。自动执行开关(PMS_OPEN_AUTO_EXEC_ON_VERDICT,默认关)留给一致性检查通过后再开。 测试:新增第二十批 15 例,第七批 32 到 35 例,全套 626 例 ALL SUITES PASS;例数表按实跑校正(原记 591 已过期)。 Co-Authored-By: Claude Opus 5 --- app/core/action_engine.py | 55 ++++ app/core/signal_rules.py | 35 ++- app/services/judge.py | 61 +++- app/services/param_store.py | 9 +- app/services/plan_feed.py | 96 +++++- app/services/proposal_service.py | 79 ++++- app/services/signal_service.py | 3 + app/web/static/index.html | 36 ++- config/settings.py | 12 + scripts/probe_plan_api.py | 20 +- scripts/run_tests.py | 36 ++- scripts/test_batch20_units.py | 509 +++++++++++++++++++++++++++++++ scripts/test_batch7_units.py | 92 +++++- 13 files changed, 991 insertions(+), 52 deletions(-) create mode 100644 scripts/test_batch20_units.py diff --git a/app/core/action_engine.py b/app/core/action_engine.py index 17d2a3e..ec653ee 100644 --- a/app/core/action_engine.py +++ b/app/core/action_engine.py @@ -42,6 +42,32 @@ A_FILL, A_ADD, A_DCA, A_TRIM = "FILL", "ADD", "DCA", "TRIM" A_OPEN = "OPEN" # 新建仓 (与 planner.A_OPEN、executor.BUY_ACTIONS 同名同义) BUY, SELL = "buy", "sell" +# 上游选股系统给每张候选卡的判决 (2026-09-02 起随计划接口每行下发; 旧版计划没有这个字段)。 +# 候选 门槛全过、无硬风险、确认线在 —— 走现有的自动流程 (规则闸 → 研判闸 → 档位分流) +# 关注 门槛全过、无硬风险、但确认线缺失或陈旧 —— 这是上游说「无法判断」, 强制交人裁决 +# 仅展示 只差覆盖或潜在吸筹 —— 不出提议, 只在候选处置里记一句原因 +# 三个值是中文原文, 与上游接口逐字对应, 这里不做任何映射。 +VERDICT_CANDIDATE, VERDICT_WATCH, VERDICT_DISPLAY = "候选", "关注", "仅展示" +WHY_WATCH_CONFIRM = "选股系统判为关注(无法判断),交人裁决" +WHY_DISPLAY_ONLY = "选股系统判为仅展示" + + +def verdict_confirm_why(verdict): + """按判决要不要强制人工确认: 要则回原因, 不要回 None。 + + 候选与没有判决 (旧版计划) 都回 None —— 走档位。关注回固定文案。仅展示不该走到这里 + (scan_open 在更前面就跳过了), 真走到也按关注处理。判决值认不出来 (上游改了词表) + 一律强制交人: 宁可多问一次人, 也不拿一个看不懂的判决自动建仓。 + """ + v = (str(verdict).strip() if verdict is not None else "") + if not v or v == VERDICT_CANDIDATE: + return None + if v == VERDICT_WATCH: + return WHY_WATCH_CONFIRM + if v == VERDICT_DISPLAY: + return WHY_DISPLAY_ONLY + ",交人裁决" + return f"选股系统判决值无法识别({v}),交人裁决" + # **有资格**送研判闸的动作 (设计 §7: 自主提议的补足/加仓/补仓/调仓; 2026-08-06 加入新建仓)。 # 注意只是"有资格"——真正送不送由页面参数 PMS_JUDGE_ACTIONS 决定 (judge.request 第一行就按它 # 过滤)。两道门分开是有用的: 决策系统那侧的 OPEN 判据万一要退回去, 页面上摘掉一个词就行, @@ -354,6 +380,11 @@ def eval_open(c: dict, params: dict, caps: dict, room_amt: float): # 它可以拿自己的结论去对照; 没有信号时这两项是 None, 不误导。 "intraday_buy_signal": bool(c.get("sig_buy")), "intraday_buy_reason": (c.get("sig_buy") or {}).get("reason"), + # 上游候选卡的判决与理由 (2026-09-02 起下发; 旧版计划这五项都是 None)。同样是定性 + # 材料: 送研判时一并带过去 (judge.OPEN_JUDGE_KEYS), 让决策系统逐条核对「这几条理由 + # 到今天还成不成立」; 进评审账本与提议卡, 让人看得见系统为什么挑了它。 + "verdict": c.get("verdict"), "reasons": c.get("reasons"), + "missing": c.get("missing"), "risk": c.get("risk"), "card_rank": c.get("card_rank"), # ---- 仓位口径: 只进评审账本做判分锚。judge.py 送研判时会把这几项过滤掉, # 理由见那边的 OPEN_JUDGE_KEYS —— 决策系统本来就不管仓位, 别送过去带偏它。 "target_pct": target_pct, "target_amount": round(full_amt, 2), @@ -394,11 +425,22 @@ def scan_open(*, candidates: list, params: dict, caps: dict, room_amt: float, room_amt: 还有多少钱可投, 已取「总仓上限×总规模 − 组合市值」与「真实可用资金」的小者 skip: 不再评估的 (代码, 动作) —— 字典时值是跳过原因 (在途提议 / 在途指令 / 今日被规则闸拒过 / 今日被研判闸驳回过, 这四种处境完全不同, 必须分得开) + + 按上游判决分流 (2026-09-03, params["open_route_by_verdict"] 为真时生效): + 仅展示 → 不产出候选, 写一条跳过原因 (正常路径上 plan_feed 已在候选阶段剔掉, 这里是 + 兜底 —— 直接调本函数的人也得到同一口径); + 关注 → 照常产出候选, 但打上 needs_user_confirm 并带上原因 (confirm_why), 由 + proposal_service._route_one 强制入人工队列; + 候选 / 没有判决 → 一个字不改。 + 落点选在这里而不是 eval_open: 判决决定的是「这条候选往哪条路走」, 与「建不建、建多少」 + 是两个问题; 放在产出候选的循环里, 仅展示的行连名额与金额都不扣, 而且开关关着时 + 这段代码一行都不执行 —— 旧行为逐字保留。 """ skip = skip or {} out, skipped = [], [] slots = int(slots or 0) left = _f(room_amt) + route = bool(params.get("open_route_by_verdict")) if slots <= 0: return {"candidates": [], "skipped": [ {"ts_code": "*", "action": A_OPEN, @@ -427,6 +469,12 @@ def scan_open(*, candidates: list, params: dict, caps: dict, room_amt: float, code = c.get("ts_code") if not code: continue + if route and c.get("verdict") == VERDICT_DISPLAY: + # 仅展示的行不占名额、不占金额, 也不进后面任何一道闸; 放在名额判断之前, + # 是为了让它的跳过原因永远是「仅展示」而不是碰巧的「名额用完」。 + skipped.append({"ts_code": code, "action": A_OPEN, + "why": WHY_DISPLAY_ONLY + ",不出提议"}) + continue if slots <= 0: skipped.append({"ts_code": code, "action": A_OPEN, "why": "本轮新仓名额已用完 (下一跳按最新持仓数重算)"}) @@ -448,6 +496,13 @@ def scan_open(*, candidates: list, params: dict, caps: dict, room_amt: float, if not cand: skipped.append({"ts_code": code, "action": A_OPEN, "why": why or "未产出候选"}) continue + if route: + confirm_why = verdict_confirm_why(c.get("verdict")) + if confirm_why: + # 关注 (或认不出的判决): 候选照常产出、照常占名额、照常过规则闸与研判闸, + # 只是最后一步不许自动落指令 —— 与深档补仓强制确认走的是同一个字段。 + cand["needs_user_confirm"] = True + cand["confirm_why"] = confirm_why out.append(cand) used = _f(cand.get("target_amount")) left -= used diff --git a/app/core/signal_rules.py b/app/core/signal_rules.py index 2276fcd..1896b2a 100644 --- a/app/core/signal_rules.py +++ b/app/core/signal_rules.py @@ -46,6 +46,28 @@ ACT_EXIT, ACT_PROPOSE, ACT_RECORD, ACT_IGNORE = "EXIT", "PROPOSE", "RECORD", "IG # 买入信号留痕: 不产生任何买入动作, 但**持没持仓都要写账本** (与 ACT_RECORD 的差别就在这) ACT_NOTE_BUY = "NOTE_BUY" +# 信号来源 (2026-09-03 起区分)。db2 那条盘中流**两家都写**: 择时决策系统的大脑广播 +# (producer_id 形如 bionic_brain_intraday_v2.0) 与盘中择时程序的入场信号 (producer_id 形如 +# intraday_timing_v0.1.0), 格式相近而语义不同 —— 前者是知情二审的结论, 后者是量化程序的 +# 触发。此前 PMS 一律记成「决策系统盘中判该股转多」, 于是新建仓的插队排序把择时程序的 +# 触发也当成了决策系统的结论。现在按 producer_id 前缀分开: 只有以 bionic 开头的才算 +# 择时决策系统; 缺失一律记作 unknown, 不猜。 +PRODUCER_UNKNOWN = "unknown" +BIONIC_PRODUCER_PREFIX = "bionic" +# 留痕 reason 的固定开头。动作引擎那条路 (proposal_service._buy_signals_today) 靠这个开头 +# 认出「哪几条是择时决策系统判的」—— 账本查询只回 reason 不回硬数字, 用开头判最省一次改表。 +BUY_NOTE_PREFIX_BIONIC = "决策系统盘中判该股转多" +BUY_NOTE_PREFIX_OTHER = "盘中择时程序买入信号" + + +def is_bionic_producer(producer_id) -> bool: + return str(producer_id or "").strip().lower().startswith(BIONIC_PRODUCER_PREFIX) + + +def is_bionic_buy_note(reason) -> bool: + """账本里一条 SIGNAL_BUY 留痕是不是择时决策系统判的 (看 reason 开头)。""" + return str(reason or "").startswith(BUY_NOTE_PREFIX_BIONIC) + def _num(v, d=0.0): try: @@ -92,7 +114,9 @@ def parse_intraday(fields: dict, *, msg_id: str = None) -> dict: "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 ""} + "dominant_signal": f.get("dominant_signal") or "", + # 发送方标识 (2026-09-03 起保留): 缺就是 unknown, 不猜是谁发的 + "producer_id": str(f.get("producer_id") or "").strip() or PRODUCER_UNKNOWN} def parse_risk_sell(fields: dict, *, msg_id: str = None) -> dict: @@ -136,10 +160,17 @@ def digest(signal: dict, position: dict, params: dict) -> dict: if act == "BUY": # 买入信号**仍然不产生买入动作** —— 买什么买多少归动作引擎。 # 但无论持没持仓都要留痕: 未持仓的票正是新建仓关心的那一批, 从前它们连账本都没有。 + # 留痕文案按来源分写 (2026-09-03): 择时决策系统的保持原句「决策系统盘中判该股转多」; + # 其他发送方 (盘中择时程序、未知) 写「盘中择时程序买入信号(来源 xxx)」—— + # 动作引擎的插队排序只认前者, 所以这个开头就是两类的分界线, 别改。 held_now = int((position or {}).get("total_qty") or 0) hard["held"] = held_now + producer = str(signal.get("producer_id") or "").strip() or PRODUCER_UNKNOWN + hard["producer_id"] = producer + head = (BUY_NOTE_PREFIX_BIONIC if is_bionic_producer(producer) + else f"{BUY_NOTE_PREFIX_OTHER}(来源 {producer})") return _r(ACT_NOTE_BUY, 0, - f"决策系统盘中判该股转多 (置信度 {conf:.0%}" + f"{head} (置信度 {conf:.0%}" + (f", 建议价 {signal.get('suggested_price')}" if signal.get("suggested_price") else "") + f"){'; 该股当前有持仓' if held_now > 0 else '; 该股当前无持仓'} —— " diff --git a/app/services/judge.py b/app/services/judge.py index 5db0c16..2b09aa9 100644 --- a/app/services/judge.py +++ b/app/services/judge.py @@ -39,7 +39,15 @@ OPEN_JUDGE_KEYS = ("price", "score", "theme", "tier", "upside", "heat", "plan_rank", "plan_bucket", "plan_src", "sector", # 决策系统今天盘中判没判过这只票转多。这是它自己的结论, 不是仓位数字, # 送回去等于当面提醒它「你今天判过」, 让它拿自己的结论对照一次。 - "intraday_buy_signal", "intraday_buy_reason") + "intraday_buy_signal", "intraday_buy_reason", + # 上游候选卡的判决与理由 (2026-09-02 起随计划下发)。决策系统那侧是逐键 + # 渲染进提示词的, 所以这五项放行之后不用改它的提示词就能看见; 配合下面 + # must_answer 里那条「每条理由到今天是否仍成立」, 研判就有了可逐条核对的靶子。 + "verdict", "reasons", "missing", "risk", "card_rank") + +# 新建仓送研判时的必答题 (2026-09-03)。候选卡的理由随硬数字送过去了, 研判要逐条回答 +# 「这条理由到今天还成不成立」—— 这是把上游的静态判决与决策系统的当日观点接起来的那一句。 +OPEN_MUST_ANSWER = "上游候选卡的每条理由到今天是否仍成立" def _judge_hard_numbers(action: str, hard: dict) -> dict: @@ -112,8 +120,18 @@ def status() -> dict: 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 _map_verdict(data: dict) -> dict: - """把决策系统应答的 verdict 归一到 PASS / REJECT / UNAVAILABLE, 并带出原因。 + """把决策系统应答的 verdict 归一到 PASS / REJECT / UNAVAILABLE, 并带出原因与置信度。 契约见 BIONIC_PMS_INTERFACE §2.3: verdict 合法取值是 PASS / REJECT / UNAVAILABLE, UNAVAILABLE 还会带一句原因。UNAVAILABLE 是设计内的正常降级 (该股无昨夜结论、裁决 @@ -122,21 +140,38 @@ def _map_verdict(data: dict) -> dict: 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")) if verdict in ("PASS", "APPROVE", "APPROVED", "ALLOW", "通过"): - return {"verdict": PASS, "reason": reason, "degraded": False, "raw": data} + return {"verdict": PASS, "reason": reason, "degraded": False, "raw": data, + "confidence": conf} if verdict in ("REJECT", "DENY", "DENIED", "BLOCK", "驳回"): - return {"verdict": REJECT, "reason": reason, "degraded": False, "raw": data} + return {"verdict": REJECT, "reason": reason, "degraded": False, "raw": data, + "confidence": conf} if verdict in ("UNAVAILABLE", "PMS_UNAVAILABLE", "NA", "N/A", "不可用"): logger.warning("[研判闸] 决策系统回不可用, 降级人工确认: %s", reason or "(无原因)") return {"verdict": UNAVAILABLE, "reason": reason or "决策系统研判不可用", - "degraded": True, "raw": data} + "degraded": True, "raw": data, "confidence": conf} logger.error("[研判闸] 答复无法识别 (%s), 按不可用降级", verdict or data) return {"verdict": UNAVAILABLE, "reason": f"研判答复无法识别: {verdict or data}", - "degraded": True, "raw": 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) -> dict: @@ -144,11 +179,12 @@ def request(candidate: dict, context: dict = None, *, timeout: int = None) -> di action = str(candidate.get("action") or "").upper() if action not in judged_actions(): return {"verdict": PASS, "reason": f"{action} 不在研判范围, 规则闸通过即可", - "degraded": False, "raw": None} + "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} + return {"verdict": UNAVAILABLE, "reason": st["reason"], "degraded": True, "raw": None, + "confidence": None} payload = { "direction": "PMS_JUDGE", "action": action, "ts_code": candidate.get("ts_code"), @@ -157,9 +193,10 @@ def request(candidate: dict, context: dict = None, *, timeout: int = None) -> di "hard_numbers": _judge_hard_numbers(action, candidate.get("hard_numbers")), "context": context or {}, # 设计要求: 补仓类研判必须回答「下跌是杀逻辑还是杀情绪」。 - # 新建仓的必答 (「把这只票挑出来的驱动到今天还成不成立」) **只写在决策系统的判据里**, - # 这边不传 —— 「建仓该问什么」属于仲裁哲学, 是它的知识, PMS 不需要懂。 - "must_answer": (["下跌是杀逻辑还是杀情绪"] if action == "DCA" else []), + # 新建仓 (2026-09-03 起) 也带一条: 「上游候选卡的每条理由到今天是否仍成立」。 + # 早先这条只写在决策系统的判据里、这边不传, 理由是「建仓该问什么」属于它的知识; + # 现在候选卡的理由随硬数字送了过去, 问题就有了具体的靶子, 由 PMS 点名要它逐条回答。 + "must_answer": must_answer_for(action), } 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 "") @@ -172,6 +209,6 @@ def request(candidate: dict, context: dict = None, *, timeout: int = None) -> di 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} + "degraded": True, "raw": None, "confidence": None} return _map_verdict(data) diff --git a/app/services/param_store.py b/app/services/param_store.py index 8f24306..b85bd17 100644 --- a/app/services/param_store.py +++ b/app/services/param_store.py @@ -33,7 +33,10 @@ INFRA_PREFIX = ("PROXY_DB", "SOURCE_DB", "DB_MYSQL", "SIGNAL_REDIS", "PMS_REDIS" # 协议 QMT_WS_PROTOCOL.md §10.1.1 明确要求「只走 .env, 不入库、不进 ParamStore、 # 不写进代码」。它们同样以 PMS_ 开头, 不单列的话会被 _editable_keys() 当成普通业务参数: # 页面能改 (于是私钥 seed 落进 pms_runtime_param 表), snapshot() 还会把它原文显示出来。 -SECRET_KEYS = ("PMS_QMT_SIGN_SEED_HEX", "PMS_QMT_PEER_PUBKEY_B64") +# 2026-09-03 补进会话票签名密钥 PMS_SESSION_SECRET: 它 2026-08 随登录一起加进 settings, +# 当时漏了这份名单, 于是参数设置页把它当普通业务参数原文显示出来 (登录中间件读的是 +# settings, 功能没受影响, 但密钥不该出现在页面上)。 +SECRET_KEYS = ("PMS_QMT_SIGN_SEED_HEX", "PMS_QMT_PEER_PUBKEY_B64", "PMS_SESSION_SECRET") # 运行态开关: key -> (默认值, 类型, 说明) RUNTIME_EXTRA = { @@ -153,6 +156,10 @@ DESC = { "PMS_OPEN_REQUIRE_WS_CASH": "拿不到 ws 资金快照时不自动新建仓 (只影响新建仓, 其余动作照旧)", "PMS_OPEN_SIGNAL_PRIORITY": "今天被决策系统判过盘中转多的候选票排最前 (只影响先后, 不影响资格)", "PMS_OPEN_REF_DRIFT_MAX": "参考位盘中被改写超此幅度 → 该票当日暂停新建仓", + "PMS_PLAN_ROUTE_BY_VERDICT": "按选股系统的判决分流自主新建仓: 候选走自动流程 / 关注强制人工确认 / " + "仅展示不出提议 (上游行无 verdict 或本开关关着时按档位, 与旧行为一致)", + "PMS_OPEN_AUTO_EXEC_ON_VERDICT": "propose_only 档位下, 判决为候选 + 决策系统研判通过 + 规则闸通过 + " + "上游风险列表为空 的新建仓提议直接落指令 (默认关; 其余仍入队等人裁决)", "PMS_JUDGE_TICK_BUDGET_SEC": "单轮提议扫描用于研判的时间预算 (秒), 用尽则剩下的候选留到下一跳", "PMS_JUDGE_ENABLED": "研判闸开关", "PMS_JUDGE_ACTIONS": "需过研判闸的动作", "PMS_JUDGE_TIMEOUT": "研判超时 (秒) → 降级 propose_only", diff --git a/app/services/plan_feed.py b/app/services/plan_feed.py index 136b544..cdc626b 100644 --- a/app/services/plan_feed.py +++ b/app/services/plan_feed.py @@ -43,9 +43,19 @@ `app.core.plan_diff` 纯逻辑算。顺带补上 §7.3 那个洞: `/plan` 是实时算的, 同一个 `date` 能 对应多个版本而应答里没有版本戳 —— 落了快照, 同一 `plan_date` 底下有几行就是重算过几版。 -模块级只依赖 stdlib + `app.core.command_spec` (纯逻辑), 其余 (requests / param_store / -pms_repo / plan_diff / tradedays) 一律函数内懒加载 —— 让解析与筛选这两段纯逻辑可以零依赖 -单测。 +**候选卡的判决与理由 (2026-09-02 起上游随每行下发, 向下兼容)**: 每行多了五个键 —— +`verdict` (候选 / 关注 / 仅展示 三个中文值)、`reasons` (理由列表, 这里只留前四条)、 +`missing` (缺失项列表)、`risk` (风险列表)、`card_rank` (卡内序, 越小越靠前); 顶层多了 +`plan_version` 与 `generated_at`。解析层只做「原样带出来, 缺就是 None」, 不在这里做任何 +判断; 唯一的例外是 `select_candidates(route_by_verdict=True)` 会把判为「仅展示」的行在 +top_n 截断**之前**剔掉 —— 那是为了不让它们白占候选池的名额, 剔掉的代码单独列在 +`display_only` 里, 由自主建仓那条路写成跳过原因 (页面「系统在盯的候选」能看到)。 +「关注」强制人工确认与「候选」自动流程的分流不在本模块, 见 action_engine.scan_open 与 +proposal_service._route_one。旧版计划没有这些键时, 一切行为与 2026-09-02 之前一字不差。 + +模块级只依赖 stdlib + `app.core.command_spec` / `app.core.action_engine` (都是纯逻辑), +其余 (requests / param_store / pms_repo / plan_diff / tradedays) 一律函数内懒加载 —— +让解析与筛选这两段纯逻辑可以零依赖单测。 """ from __future__ import annotations @@ -53,6 +63,7 @@ import logging import threading import time +from app.core.action_engine import VERDICT_DISPLAY from app.core.command_spec import normalize_code logger = logging.getLogger("pms.plan") @@ -101,6 +112,31 @@ def _text_or_none(v): return s or None +# 候选卡理由最多带几条进 PMS。上游一张卡的理由可以很长, 而这几条要进硬数字、进评审账本、 +# 进研判请求体、显示在提议卡上 —— 四个去处都只需要前几条最要紧的。 +REASONS_KEEP = 4 + + +def _list_or_none(v, limit: int = 0): + """字符串列表归一: 列表逐项去空白、丢空项, 单个字符串当一项; 缺失或类型不对一律 None。 + + 空列表**保留为空列表**而不是折成 None —— 「上游说没有风险」(risk=[]) 与「上游没给这个 + 字段」(缺键) 是两件事, 自动执行开关那条「风险列表为空」判据要分得开。 + """ + if v is None: + return None + if isinstance(v, str): + s = v.strip() + if not s: + return None # 空串与缺键同义 (与 _text_or_none 一个口径) + items = [s] + elif isinstance(v, (list, tuple)): + items = [str(x).strip() for x in v if x is not None and str(x).strip()] + else: + return None + return items[:limit] if limit and limit > 0 else items + + def _rows(raw, bucket: str) -> list: """一档榜单归一。坏行跳过而不是整体抛错 —— 单条变形不该让整张计划失效。""" out, seen = [], set() @@ -125,6 +161,12 @@ def _rows(raw, bucket: str) -> list: "n_sources": _int_or_none(ev.get("n_sources")), "moved_ratio": _float_or_none(ev.get("moved_ratio")), "bucket": bucket, + # 候选卡的判决与理由 (2026-09-02 起上游下发; 旧版计划没有, 缺就是 None) + "verdict": _text_or_none(it.get("verdict")), + "reasons": _list_or_none(it.get("reasons"), limit=REASONS_KEEP), + "missing": _list_or_none(it.get("missing")), + "risk": _list_or_none(it.get("risk")), + "card_rank": _int_or_none(it.get("card_rank")), }) return out @@ -156,6 +198,10 @@ def parse_plan(payload, *, requested=None) -> dict: themes[r["ts_code"]] = r["theme"] return { "date": date, + # 计划版本与生成时刻 (2026-09-02 起上游下发)。同一个 date 的计划会被重算多版, + # 这两项是上游自己的版本戳; 旧版应答没有, 缺就是 None (名册快照那套指纹照旧管用)。 + "plan_version": _text_or_none(payload.get("plan_version")), + "generated_at": _text_or_none(payload.get("generated_at")), "heat_date": _text_or_none(payload.get("heat_date")), "market_snapshot_days": [str(x) for x in (payload.get("market_snapshot_days") or [])], "theme_cap": _int_or_none(payload.get("theme_cap")), @@ -211,9 +257,16 @@ def assert_fresh(plan: dict, *, max_stale_tdays: int = 1, today=None) -> int: def select_candidates(plan: dict, *, held=(), black=(), top_n: int = 30, tiers=None, include_observe: bool = False, min_score=None, min_sources: int = 0, min_upside=None, theme_cap: int = 0, - exclude_st: bool = True) -> dict: + exclude_st: bool = True, route_by_verdict: bool = False) -> dict: """排序池 → 候选清单。 + route_by_verdict: 按上游判决分流时, 判为「仅展示」的行在 top_n 截断**之前**剔掉并列进 + `display_only` (代码列表), 让自主建仓那条路写成跳过原因。放在这一层而不是动作引擎, 是 + 因为 top_n 那一刀按纯 score 切 —— 仅展示的票分数未必低, 不先剔掉的话前 30 名可能大半 + 是仅展示, 真正的候选与关注排在第 31 名之后永远轮不上。默认 False = 与 2026-09-02 + 之前一字不差 (命令驱动建仓与探活脚本各自显式决定要不要开)。没有 verdict 的行永远 + 不受这条影响。 + 排序: score 降序, 同分按 rank 升序 (上游 rank 已是它自己的最终次序, 拿来当稳定次序)。 tier 白名单**只对带 tier 的行生效** —— 观察档没有 tier, 它的闸门是 include_observe。 min_upside 相反, **对所有行生效**: upside 缺失按 0 算一起挡掉 (观察档 upside 恒为 @@ -242,8 +295,8 @@ def select_candidates(plan: dict, *, held=(), black=(), top_n: int = 30, tiers=N theme_cap = int(theme_cap or 0) dropped = {"held": 0, "black": 0, "tier": 0, "score": 0, "sources": 0, "upside": 0, - "st": 0, "theme": 0, "dup": 0, "capped": 0} - passed, seen, per_theme, st_unknown = [], set(), {}, [] + "st": 0, "theme": 0, "dup": 0, "capped": 0, "display": 0} + passed, seen, per_theme, st_unknown, display_only = [], set(), {}, [], [] for r in sorted(pool, key=lambda x: (-(x.get("score") or 0.0), x.get("rank") or 10 ** 9)): c = r["ts_code"] if c in seen: @@ -256,6 +309,10 @@ def select_candidates(plan: dict, *, held=(), black=(), top_n: int = 30, tiers=N if c in black: dropped["black"] += 1 continue + if route_by_verdict and r.get("verdict") == VERDICT_DISPLAY: + dropped["display"] += 1 # 上游判为仅展示: 不进池, 单独列出让人看得见 + display_only.append(c) + continue if exclude_st: nm = (r.get("name") or "").strip() if not nm: @@ -288,10 +345,15 @@ def select_candidates(plan: dict, *, held=(), black=(), top_n: int = 30, tiers=N items = [{"ts_code": r["ts_code"], "name": r["name"], "score": r.get("score") or 0.0, "sector": r.get("theme"), "theme": r.get("theme"), "tier": r.get("tier"), "heat": r.get("heat"), "upside": r.get("upside"), "rank": r.get("rank"), - "bucket": r["bucket"], "src": "plan_api"} + "bucket": r["bucket"], "src": "plan_api", + # 候选卡五键原样透传 (缺就是 None), 动作引擎把它们放进硬数字 + "verdict": r.get("verdict"), "reasons": r.get("reasons"), + "missing": r.get("missing"), "risk": r.get("risk"), + "card_rank": r.get("card_rank")} for r in passed[:n]] return {"date": plan.get("date"), "considered": len(pool), "eligible": len(passed), "items": items, "dropped": dropped, "st_unknown": st_unknown, + "display_only": display_only, "tier_complete": _tier_complete(pool, tiers)} @@ -353,6 +415,7 @@ def _params() -> dict: "snapshot_keep": ps.get_int("PMS_PLAN_SNAPSHOT_KEEP", 200), "diff_rank_jump": ps.get_int("PMS_PLAN_DIFF_RANK_JUMP", 50), "diff_tail_guard": ps.get_float("PMS_PLAN_DIFF_TAIL_GUARD", 0.5), + "route_by_verdict": ps.get_bool("PMS_PLAN_ROUTE_BY_VERDICT", True), } @@ -705,18 +768,28 @@ def snapshot_log(*, plan_date=None, limit: int = 50) -> dict: # ================================================================ 对外: 候选与状态 -def candidates(*, held=(), black=()) -> dict: - """参数驱动的候选清单 (不含价格 —— 价格由调用方用 market.get_price 现取)。""" +def candidates(*, held=(), black=(), route_by_verdict: bool = False) -> dict: + """参数驱动的候选清单 (不含价格 —— 价格由调用方用 market.get_price 现取)。 + + route_by_verdict 默认 False: 命令驱动建仓 (command_service._candidates) 不传, 于是用户 + 下的升仓命令照旧看全池, 与 2026-09-02 之前一字不差 —— 判决分流只管自主提议这条路 + (方案 3.2 的措辞是「候选自动进方案、关注强制确认、仅展示不出**提议**」), 命令是用户 + 自己的决定, 不替他过滤。自主建仓 (proposal_service._scan_open) 按 PMS_PLAN_ROUTE_BY_VERDICT + 显式传 True。要让命令驱动也按判决过滤, 改这里的调用方, 不改默认值。 + """ p = _params() plan = get_plan() + route = bool(route_by_verdict) out = select_candidates(plan, held=held, black=black, top_n=p["top_n"], tiers=p["tiers"], include_observe=p["include_observe"], min_score=(p["min_score"] or None), min_sources=p["min_sources"], min_upside=(p["min_upside"] or None), - theme_cap=p["theme_cap_local"], exclude_st=p["exclude_st"]) + theme_cap=p["theme_cap_local"], exclude_st=p["exclude_st"], + route_by_verdict=route) out["age_tdays"] = plan.get("age_tdays") out["theme_cap"] = plan.get("theme_cap") + out["route_by_verdict"] = route return out @@ -742,6 +815,9 @@ def status() -> dict: st.update({"ok": False, "hint": f"{type(e).__name__}: {e}"}) return st st.update({"ok": True, "date": plan["date"], "age_tdays": plan.get("age_tdays"), + "plan_version": plan.get("plan_version"), + "generated_at": plan.get("generated_at"), + "route_by_verdict": p["route_by_verdict"], "heat_date": plan.get("heat_date"), "market_snapshot_days": plan.get("market_snapshot_days"), "counts": plan["counts"], "returned": plan["returned"], diff --git a/app/services/proposal_service.py b/app/services/proposal_service.py index e499e09..35b3086 100644 --- a/app/services/proposal_service.py +++ b/app/services/proposal_service.py @@ -32,6 +32,7 @@ from datetime import datetime, timedelta from app.core import action_engine as ae from app.core import command_spec as cs from app.core import rule_gate +from app.core import signal_rules as sr from app.core import tradedays as td from app.repo import pms_repo from app.services import (command_service, executor, industry, judge, market, param_store, @@ -180,12 +181,20 @@ def _scan_open(view, params, stock_params, skip, mkt, out) -> list: black = {c for c, d in (stock_params or {}).items() if d.get("black")} held = [x["ts_code"] for x in view["held"]] try: - sel = plan_feed.candidates(held=held, black=black) + # 按判决分流开着时, 上游判为「仅展示」的票在候选阶段就剔掉 (理由见 + # plan_feed.select_candidates 的说明: 不剔的话它们会白占 top_n 的名额)。 + sel = plan_feed.candidates(held=held, black=black, + route_by_verdict=bool(params.get("open_route_by_verdict"))) except plan_feed.PlanFeedError as e: # 拿不到 ≠ 今天没票可买。显式留痕, 本轮不产新建仓候选, 已有持仓的四类照常。 logger.error("[新建仓] 候选池取数失败, 本轮不建仓: %s", e) out["skipped"].append({"action": ae.A_OPEN, "why": f"候选池取不到, 本轮不建仓: {e}"}) return [] + # 仅展示的票逐只写跳过原因: disposition_snapshot 复用本函数, 页面「系统在盯的候选」 + # 由此显示「选股系统判为仅展示」而不是一个说不清的空白。 + for code in (sel.get("display_only") or []): + out["skipped"].append({"ts_code": code, "action": ae.A_OPEN, + "why": ae.WHY_DISPLAY_ONLY + ",不出提议"}) items = list(sel.get("items") or []) if not items: out["skipped"].append({"action": ae.A_OPEN, @@ -470,31 +479,68 @@ def _route_one(c, view, params, stock_params, brake_active, now, dry_run, out, autonomy = (out.get("open_autonomy") or AUTONOMY_FULL) if is_open else out["autonomy"] force_queue = bool(c.get("needs_user_confirm")) or verdict.get("degraded") auto_exec = (side == "sell") or (autonomy == AUTONOMY_FULL and not force_queue) + # 自动执行开关 (2026-09-03, PMS_OPEN_AUTO_EXEC_ON_VERDICT): 新建仓档位是 propose_only 时, + # 「判决候选 + 决策系统研判真回了通过 + 规则闸通过 (走到这里就是通过了) + 上游风险列表 + # 为空」四条齐, 这一条按 full 处理。强制入队的 (关注 / 深档补仓 / 研判不可用) 永远不走。 + auto_why = None + if is_open and not auto_exec and not force_queue and autonomy == AUTONOMY_PROPOSE: + auto_why = _verdict_auto_exec_why(c, verdict) + if auto_why: + auto_exec = True if dry_run: (out["executed"] if auto_exec else out["queued"]).append( {**_brief(c), "route": "auto" if auto_exec else "queue", - "judge": verdict["verdict"], "dry_run": True}) + "judge": verdict["verdict"], "dry_run": True, + **({"why": auto_why} if auto_why else {})}) return if auto_exec: iid = _make_instruction(c, price, now) + reason = verdict.get("reason") or c["reason"] + if auto_why: + reason = f"判决候选自动执行: {reason}" pms_repo.insert_ledger(ts_code=code, action=action, arbiter="judge" if c.get("judge_required") else "rule", verdict="PASS", price_at=price, hard_numbers=c["hard_numbers"], - ref_id=iid, - reason=(verdict.get("reason") or c["reason"])[:500]) + ref_id=iid, reason=reason[:500]) out["executed"].append({**_brief(c), "instruction_id": iid, "why": ("减持方向自动执行" if side == "sell" - else ("新建仓档位 full" if is_open else "档位 full"))}) + else (auto_why or ("新建仓档位 full" if is_open + else "档位 full")))}) else: pid = _make_proposal(c, price, verdict) - why = ("深档补仓强制确认" if c.get("needs_user_confirm") + # 强制确认的原因: 候选自带的 (关注判决等, 见 action_engine.verdict_confirm_why) + # 优先, 没有就是深档补仓那条老规矩。 + why = ((c.get("confirm_why") or "深档补仓强制确认") if c.get("needs_user_confirm") else ("研判不可用, 降级人工确认" if verdict.get("degraded") else ("新建仓档位 propose_only" if is_open else "档位 propose_only"))) out["queued"].append({**_brief(c), "proposal_id": pid, "why": why}) +def _verdict_auto_exec_why(c, verdict): + """自动执行开关的四条件判定: 全部成立回一句原因 (写进 executed 与账本), 否则 None。 + + 四条: ① 开关 PMS_OPEN_AUTO_EXEC_ON_VERDICT 为真; ② 上游判决是「候选」; ③ 决策系统的 + 研判**真的回了通过** —— 只认带应答体 (raw) 的 PASS, 「动作不在研判范围即放行」那种 + 没有问过决策系统的 PASS 不算, 研判不可用更不算; ④ 上游风险列表为空 (None 与 [] 都算 + 空: 判为候选本身就意味着上游没标硬风险, 缺键是旧字段布局)。规则闸通过是调用方保证的 + (未过早就 return 了)。任一条不满足就回 None, 调用方照旧入队 —— 这条路只放宽不收紧。 + """ + if not param_store.get_bool("PMS_OPEN_AUTO_EXEC_ON_VERDICT", False): + return None + hn = c.get("hard_numbers") or {} + if hn.get("verdict") != ae.VERDICT_CANDIDATE: + return None + if (not c.get("judge_required") or verdict.get("verdict") != judge.PASS + or verdict.get("degraded") or not isinstance(verdict.get("raw"), dict)): + return None + if hn.get("risk"): + return None + return ("判决候选自动执行 (判决候选 + 研判通过 + 规则闸通过 + 风险列表为空; " + "PMS_OPEN_AUTO_EXEC_ON_VERDICT=True)") + + def _judge_budget_left(deadline) -> bool: """这一轮还够不够再送一次研判。 @@ -600,7 +646,10 @@ def _make_proposal(c, price, verdict) -> str: ttl = param_store.get_int("PMS_PROPOSAL_TTL_HOURS", 24) pid = f"PRP_{td.ymd()}_{c['ts_code'].replace('.', '')}_{c['action']}" hn = {**(c.get("hard_numbers") or {}), "price": price, "reason": c["reason"], - "needs_user_confirm": c.get("needs_user_confirm", False)} + "needs_user_confirm": c.get("needs_user_confirm", False), + # 研判应答的结论与置信度 (2026-09-03): 人裁决时要看得见决策系统怎么说、有多确定。 + # judge_reason 另有一列, 这两项进硬数字是为了随账本走 (采纳/驳回时原样落账)。 + "judge_verdict": verdict.get("verdict"), "judge_conf": verdict.get("confidence")} try: pms_repo.insert_proposal( proposal_id=pid, ts_code=c["ts_code"], action=c["action"], qty=c["qty"], @@ -635,6 +684,7 @@ def _scan_params(view: dict) -> dict: "build_window_tdays": param_store.get_int("PMS_BUILD_WINDOW_TDAYS", 10), "fill_max_loss": param_store.get_float("PMS_FILL_MAX_LOSS", -0.03), "open_signal_priority": param_store.get_bool("PMS_OPEN_SIGNAL_PRIORITY", True), + "open_route_by_verdict": param_store.get_bool("PMS_PLAN_ROUTE_BY_VERDICT", True), }) return p @@ -686,14 +736,23 @@ def _rejected_today_keys() -> dict: def _buy_signals_today() -> dict: - """今天的盘中转多留痕。读不到就返回空 —— 它只影响候选的先后, 不影响资格, - 所以读失败时降级成「按分数排」就够了, 不该因此让整轮新建仓停摆。""" + """今天**择时决策系统**判过盘中转多的留痕。读不到就返回空 —— 它只影响候选的先后, + 不影响资格, 所以读失败时降级成「按分数排」就够了, 不该因此让整轮新建仓停摆。 + + 只认发送方以 bionic 开头的那些 (2026-09-03): db2 那条流盘中择时程序也在写, 它的入场 + 触发不是决策系统的结论, 不该拿来插队。区分靠留痕 reason 的固定开头 (signal_rules 里 + 两边共用同一个常量), 账本查询本身一个字不改。同一只票同一天两家都发过时, 查询按 + 最新一条取 reason —— 最新那条若是择时程序的, 决策系统更早的那条会被盖掉, 这只票就 + 不插队; 方向是少插一次队, 不是多插。 + """ try: - return pms_repo.buy_signals_today(datetime.now().replace( + rows = pms_repo.buy_signals_today(datetime.now().replace( hour=0, minute=0, second=0, microsecond=0)) except Exception as e: logger.warning("[新建仓] 读当日转多留痕失败 (本轮按纯分数排序): %s", e) return {} + return {code: v for code, v in (rows or {}).items() + if sr.is_bionic_buy_note((v or {}).get("reason"))} def _judge_rejected_open_keys() -> dict: diff --git a/app/services/signal_service.py b/app/services/signal_service.py index ae11737..a3e334e 100644 --- a/app/services/signal_service.py +++ b/app/services/signal_service.py @@ -189,6 +189,9 @@ def _handle(sig, view, prm, seen, ymd, dry_run, out, strat_codes=frozenset()): # 直接免疫不派单, 所以 REVERSAL_BUY 是几十条的量级, 不是全市场广播。 # verdict 用 NOTE 不用 PASS —— 这是「记下来」不是「放行」, 账本里必须分得开 # (watch.py 对不认识的 verdict 有兜底符号, 不会显示异常)。 + # 留痕 reason 按来源分写 (2026-09-03, 文案在 signal_rules.digest): 择时决策系统的 + # 保持「决策系统盘中判该股转多」开头, 其他发送方写「盘中择时程序买入信号(来源 xxx)」; + # 硬数字里另存 producer_id。新建仓的插队排序只认前者 (proposal_service._buy_signals_today)。 key = sr.dedup_key(sig, ymd) if key in seen: out["ignored"] += 1 diff --git a/app/web/static/index.html b/app/web/static/index.html index c09f03b..4a1fe34 100644 --- a/app/web/static/index.html +++ b/app/web/static/index.html @@ -1011,7 +1011,8 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;} {{ p.ts_code }} {{ tx('action', p.action) }} {{ p.expire_at }} 前有效 -
决策系统:{{ p.judge_reason }}
+
决策系统:{{ p.judge_reason }}(置信度 {{ (p.hard_numbers||{}).judge_conf }})
+
选股系统:判为「{{ (p.hard_numbers||{}).verdict }}」,{{ propReasons(p).join(';') }}
{{ propWhy(p) }}
约 {{ p.qty }} 股 · 参考价 {{ (p.hard_numbers||{}).price==null ? '—' : (p.hard_numbers||{}).price }} · 约需 {{ money(((p.hard_numbers||{}).price||0)*(p.qty||0)) }}
@@ -1653,6 +1654,12 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;} + + +