# -*- coding: utf-8 -*- """ 联测监视盘 —— 一屏看完整条链 (只读, 一个字都不写) ========================================================== 运行: python scripts/watch.py 看一眼 python scripts/watch.py -n 10 每 10 秒刷一次, Ctrl-C 退出 python scripts/watch.py --wide 连评审账本与方案一起显示 为什么要这个 ------------ `make t-ins` / `make t-gate` 这类目标回的是原始 JSON, 中文还被转义成 \\uXXXX, 一条指令 刷四十行屏。盯盘时要回答的其实只有五个问题, 它们分散在五个接口里: 通道通不通 · 单发出去没有 · 出手/没出手为什么 · 成交回来没有 · 账本认领对不对 这个脚本把这五问拍成一屏。**它只读不写**, 随便跑, 跑多少次都不影响任何状态。 判读要点 (盯盘时最容易混的两件事) -------------------------------- * **「没出手」和「出手失败」长得不一样。** 没出手 = 指令停在 PROPOSED、出口队列空、 裁决栏写着 WAIT/STOP 及原因; 出手失败 = 裁决是 FIRE 但指令没进 DISPATCHED, 或者出口 表有行却卡在 QUEUED/SEND_FAILED。本屏把两者的判据并排放, 不用去猜。 * **WAIT 与 STOP 是两回事。** WAIT 是"条件没到, 待会儿再看"; STOP 是"本日不再出手" (触发不追高闸那类)。STOP 的票今天不会再有动静, 别一直等它。 """ import argparse import os import sys import time sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) W = 78 LIVE_INS = ("PROPOSED", "RULE_PASSED", "DISPATCHED") LIVE_ORD = ("QUEUED", "SENDING", "SENT", "ACCEPTED", "SUBMITTED", "PARTIAL") def _rule(ch="═"): print(ch * W) def _w(s): """显示宽度: 中日韩字符占两格。不算这个的话表格和分隔线全会跑偏。""" return sum(2 if ord(c) > 0x2E80 else 1 for c in str(s)) def _sec(title): print() print(f"─ {title} " + "─" * max(0, W - _w(title) - 3)) def _n(v, d=0): try: return float(v) except (TypeError, ValueError): return d def _money(v): return "—" if v is None else f"{_n(v):,.0f}" def _short(s, n): """按显示宽度截断 (中文按两格算), 避免表格错位。""" s = str(s or "") out, w = [], 0 for c in s: cw = 2 if ord(c) > 0x2E80 else 1 if w + cw > n: out.append("…") break out.append(c) w += cw return "".join(out) def _pad(s, n): s = _short(s, n) return s + " " * max(0, n - _w(s)) # ================================================================ 各段 def head(now): from app.services import dispatcher, param_store mode = param_store.get("PMS_DISPATCH_MODE", "shadow") auto = param_store.get("PMS_AUTONOMY", "propose_only") halt_b = param_store.get_bool("PMS_GLOBAL_BUY_HALT", False) halt_e = param_store.get_bool("PMS_GLOBAL_EXEC_HALT", False) brake = param_store.get_int("PMS_BRAKE_UNTIL", 0) _rule() flag = "" if halt_e: flag += " ⛔ 全局暂停执行" elif halt_b: flag += " ⛔ 全局暂停买入" if brake: flag += f" ⛔ 刹车至 {brake}" print(f" PMS 联测监视 · {now} · 下发 {mode} · 自主 {auto}{flag}") _rule() if mode != "ws": print(f" 通道 影子模式 —— 指令只记账不下发, 出口表不会有任何委托") return try: ch = dispatcher.channel_status() except Exception as e: print(f" 通道 读取失败: {type(e).__name__}: {e}") return q = ch.get("queue") or {} qs = " ".join(f"{k}={v}" for k, v in q.items() if v) or "空" st = ch.get("stat") or {} line = (f" 通道 {ch['conn_state']}" f" 心跳 {ch.get('heartbeat_age_sec')}s" f" 水位 {ch.get('last_seq')}/{ch.get('acked_seq')}" f" 出口 {qs}" f" 待入账 {ch.get('inbox_pending')}") print(line) warn = [] if not ch.get("online"): warn.append(f"连接不在线 ({ch['conn_state']}) —— 买入一律拒发, 卖出仍可入队") if ch.get("resync_required"): warn.append("对端补发不全, 需走全量对账 (协议 §6.2)") if ch.get("orphan_held"): warn.append(f"{ch['orphan_held']} 笔孤儿成交挂起待人工 —— 有账没记") if st.get("orphan_updates"): warn.append(f"{st['orphan_updates']} 条回报在出口表找不到对应行") if st.get("last_reject"): warn.append(f"最近一条拒绝: {st['last_reject']}") for w in warn: print(f" ⚠ {w}") def account(): from app.services import portfolio try: v = portfolio.positions_view() except Exception as e: print(f" 账户 读取失败: {type(e).__name__}: {e}") return t = v["totals"] src = "真钱" if t.get("cash_source") == "ws" else "估算" print(f" 账户 总资产 {_money(t.get('total_asset'))}" f" 可用 {_money(t.get('cash_avail'))} ({src})" f" 持仓 {t['names_count']} 只 / {_money(t['portfolio_mv'])}" f" 组合 {_n(t['portfolio_pct']):.1%}") if v.get("price_missing"): print(f" ⚠ 取不到现价: {', '.join(v['price_missing'][:6])}") def instructions(): """今日一栏必须写成「投/余/废」三个数, 不能只印一个剩余量。 2026-08-03 实机教训: 「当日配额已出完」这一句, 在两种完全相反的情形下长得一模一样 —— (a) 该投的都投出去了、单子正挂着 (正常) (b) 分片到期作废、一股没成却把额度吃光了 (bug)。用户盯着同一句话反复调试, 而真正的区别在**投出去的那些现在是什么下场**。 三个数一摆就分得开: 作废那栏不是 0, 就说明有额度被白占过。 """ from app.repo import pms_repo from app.services import executor rows = pms_repo.list_instructions(statuses=list(LIVE_INS), limit=50) _sec(f"在途指令 {len(rows)} 条") if not rows: print(" (无) —— 方案还没转指令就跑 make t-mat; 一条方案都没有就先下命令") return from datetime import datetime ymd = int(datetime.now().strftime("%Y%m%d")) print(" " + _pad("代码", 11) + _pad("方向", 5) + _pad("总量", 8) + _pad("已成", 7) + _pad("状态", 11) + _pad("今日 投/余/废", 15) + "最近裁决") for r in rows: prog = r.get("progress") or {} d = prog.get("last_decision") or {} act = d.get("action") or "—" mark = {"FIRE": "▶", "WAIT": "·", "STOP": "■", "SKIP": "○"}.get(act, " ") why = _short(d.get("reason") or "(本轮还没轮到它)", 24) at = d.get("at") or "" kids = [c for c in (prog.get("children") or []) if int(c.get("ymd") or 0) == ymd] try: used = executor._consumed_today(kids, ymd) except Exception: used = sum(int(c.get("qty") or 0) for c in kids) void = sum(int(c.get("qty") or 0) for c in kids) - used left = d.get("qty_hint") left = "—" if left is None else str(left) q = f"{used}/{left}/{void}" print(" " + _pad(r["ts_code"], 11) + _pad("买" if r["side"] == "buy" else "卖", 5) + _pad(str(r["qty"]), 8) + _pad(str(r.get("exec_qty") or 0), 7) + _pad(r["status"], 11) + _pad(q, 15) + f"{mark} {at} {act} {why}") print(" 裁决: ▶出手 ·等条件(待会儿再看) ■本日停手(如不追高) ○当日跳过(停牌/一字板)") print(" 今日 投/余/废 = 今天已投放(仍在途或已成)/本日剩余额度/到期作废已退回额度的量") def outbound(): from app.repo import qmt_repo try: rows = qmt_repo.list_orders(limit=30) except Exception as e: _sec("出口委托") print(f" 读取失败: {type(e).__name__}: {e}") return live = [r for r in rows if r["status"] in LIVE_ORD] _sec(f"出口委托 (pms_qmt_order) 在途 {len(live)} / 最近 {len(rows)}") if not rows: print(" (空) —— 还没有任何委托发往 QMT。切到 ws 之后这里一直空, " "说明是**没到出手条件**, 不是发失败了") return print(" " + _pad("代码", 11) + _pad("方向", 5) + _pad("数量", 8) + _pad("限价", 9) + _pad("状态", 12) + _pad("成交", 8) + "备注") for r in rows[:12]: note = "" if r.get("reject_code"): note = f"拒绝 {r['reject_code']}: {_short(r.get('reject_reason'), 26)}" elif r.get("broker_order_id"): note = f"券商单号 {_short(r['broker_order_id'], 22)}" if r.get("cancel_state") and r["cancel_state"] != "NONE": note = f"[撤单 {r['cancel_state']}] " + note print(" " + _pad(r["ts_code"], 11) + _pad("买" if r["side"] == "buy" else "卖", 5) + _pad(str(r["qty"]), 8) + _pad(f"{_n(r.get('limit_price')):.2f}", 9) + _pad(r["status"], 12) + _pad(str(r.get("cum_qty") or 0), 8) + note) stuck = [r for r in rows if r["status"] in ("QUEUED", "SEND_FAILED")] if stuck: print(f" ⚠ {len(stuck)} 张卡在 {stuck[0]['status']} —— " f"这才是「发不出去」, 看 pms-ws 日志") def crosscheck(): """出口表与父指令必须对得上 —— 对不上是**本端记账掉了一环**, 比发不出去更隐蔽。 正常次序 (设计「先记账后动作」): executor 判 FIRE → dispatcher 落出口表 → 立刻把父指令置 DISPATCHED 并把这一笔记进 progress.children。三件事要么都成, 要么都没发生。只成了第一件的话: * 父指令不知道自己今天已经发过 → 下一跳可能重复出手 (出口表 instruction_id 唯一索引会拦住, 于是它转而卡死: 每一跳都撞重复键, 再也发不出去) * 当日配额 fired_today 恒为 0 → 分日节奏失效 * 页面看到的是「PROPOSED / 已成 0」, 而 QMT 那边单子挂着 —— 两边说的不是一回事 2026-08-03 实机撞到过一次, 所以做成常驻检查。 """ from app.repo import pms_repo, qmt_repo from app.services import param_store if param_store.get("PMS_DISPATCH_MODE", "shadow") != "ws": return try: orders = qmt_repo.list_orders(limit=200) ins = {r["instruction_id"]: r for r in pms_repo.list_instructions(limit=200)} except Exception: return bad = [] by_parent = {} for o in orders: by_parent.setdefault(o.get("parent_id"), []).append(o) for pid, os_ in by_parent.items(): p = ins.get(pid) if not p: bad.append(f"出口表有 {len(os_)} 张 {os_[0]['ts_code']} 的委托, " f"但本端找不到父指令 {pid}") continue n_child = len((p.get("progress") or {}).get("children") or []) if p["status"] in ("PROPOSED", "RULE_PASSED"): bad.append(f"{p['ts_code']} 出口表已有 {len(os_)} 张委托 " f"({os_[0]['status']}), 而父指令还停在 {p['status']} —— " f"下发成了但**本端没记上**") if n_child != len(os_): bad.append(f"{p['ts_code']} 父指令记着 {n_child} 笔子单, " f"出口表实际有 {len(os_)} 张 —— 分日配额会算错") if not p.get("dispatch_ref"): bad.append(f"{p['ts_code']} 出口表有委托但父指令 dispatch_ref 是空的") if not bad: return _sec("⚠ 出口表与本端指令对不上") for b in dict.fromkeys(bad): print(f" ✗ {b}") print(" 这一类不是「发不出去」, 是**发出去了但账没记全**。查 executor.run_tick 里") print(" dispatcher.dispatch 之后那几行 (update_instruction / insert_ledger) 报了什么。") def positions(): from app.services import portfolio try: held = portfolio.positions_view()["held"] except Exception as e: _sec("账本持仓") print(f" 读取失败: {type(e).__name__}: {e}") return _sec(f"账本持仓 {len(held)} 只") if not held: print(" (空) —— 有成交回来之后这里才会长出批次") return print(" " + _pad("代码", 11) + _pad("数量", 8) + _pad("可卖", 8) + _pad("摊薄成本", 10) + _pad("现价", 9) + _pad("安全垫", 9) + _pad("批次 底/补/加/摊", 18) + "行业") for p in held: cu = p.get("cushion_pct") cu_s = "—" if cu is None else f"{_n(cu):+.2%}" batch = "/".join(str(p.get(k) or 0) for k in ("base_qty", "fill_qty", "add_qty", "dca_qty")) print(" " + _pad(p["ts_code"], 11) + _pad(str(p["total_qty"]), 8) + _pad(str(p.get("avail_qty") or 0), 8) + _pad(f"{_n(p.get('avg_cost')):.3f}", 10) + _pad(f"{_n(p.get('price')):.3f}", 9) + _pad(cu_s, 9) + _pad(batch, 18) + _short(p.get("sector") or "—", 16)) def ledger(n=8): from app.repo import pms_repo rows = pms_repo.list_ledger(limit=n) _sec(f"评审账本 最近 {len(rows)} 条 (谁拒了什么、为什么)") if not rows: print(" (无)") return for r in rows: t = (str(r.get("decided_at") or "")[-8:] or "--:--:--") v = r.get("verdict") mark = {"PASS": "✓", "REJECT": "✗", "WARN": "⚠"}.get(v, "·") why = _short(r.get("reason"), 40) fc = r.get("failed_checks") or [] tail = (" ← " + _short(fc[0], 46)) if fc else "" print(f" {mark} {t} {_pad(r.get('ts_code') or '—', 11)}" f"{_pad(r.get('action') or '', 8)}{why}{tail}") def plans(): from app.repo import pms_repo rows = pms_repo.list_plans(limit=200) by = {} for r in rows: by[r["status"]] = by.get(r["status"], 0) + 1 _sec("方案分布") if not rows: print(" (无) —— 先下命令 (make t-issue), 命令才会排出方案") return print(" " + " ".join(f"{k} {v} 条" for k, v in sorted(by.items())) + " PENDING=可转指令 GATED=等动作引擎解锁 EXECUTING=已转过") def render(wide, clear=False): from datetime import datetime if clear: os.system("clear" if os.name != "nt" else "cls") head(datetime.now().strftime("%Y-%m-%d %H:%M:%S")) account() instructions() outbound() crosscheck() positions() if wide: plans() ledger() print() def main(): ap = argparse.ArgumentParser(description="PMS 联测监视盘 (只读)") ap.add_argument("-n", "--interval", type=int, default=0, help="每 N 秒刷新一次 (缺省只看一眼)") ap.add_argument("--wide", action="store_true", help="连方案分布与评审账本一起显示") args = ap.parse_args() if not args.interval: render(args.wide) return try: while True: render(args.wide, clear=True) print(f" (每 {args.interval}s 刷新, Ctrl-C 退出)") time.sleep(args.interval) except KeyboardInterrupt: print("\n已退出。") if __name__ == "__main__": main()