From 375de723099a77c82e74000b72b3f1538a27b9e9 Mon Sep 17 00:00:00 2001 From: zlt Date: Mon, 3 Aug 2026 10:09:16 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E4=B8=80=E4=B8=AA=E7=9B=91=E6=B5=8B?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 11 +- README.md | 18 +++ scripts/watch.py | 307 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 333 insertions(+), 3 deletions(-) create mode 100644 scripts/watch.py diff --git a/Makefile b/Makefile index 2331816..ce8df3e 100644 --- a/Makefile +++ b/Makefile @@ -23,8 +23,8 @@ RUN := docker compose run --rm --no-deps pms-web .PHONY: help deploy deploy-local build up down ps logs test stale initdb check health \ probe changes industry ws-status rebuild rebuild-accept \ - t-pool t-plan t-pre t-issue t-cmd t-cmds t-catalog t-plans t-mat t-dry \ - t-tick t-ins t-book t-gate reset-ledger shell + watch t-pool t-plan t-pre t-issue t-cmd t-cmds t-catalog t-plans t-mat \ + t-dry t-tick t-ins t-book t-gate reset-ledger shell help: ## 列出所有目标 @grep -hE '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ @@ -126,13 +126,18 @@ rebuild-accept: ## 只看判收: 安全垫分布 / 批次账 / 行业集中度 # 实现 B = PMS 自己算 (分日配额 / 分笔 / VWAP / 回踩 / 不追高 / 14:45 兜底) —— 已实现 # 所以跑 A 段不需要动决策系统的任何逻辑, 它这一段本来就不参与。 A ?= http://127.0.0.1:38100 -J := python3 -m json.tool +# --no-ensure-ascii 是必须的: 不加的话中文全被转义成 \uXXXX, 每条 reason 都得自己解码, +# 而这些接口的 reason 恰恰是最该看的东西 (Python 3.9+) +J := python3 -m json.tool --no-ensure-ascii # t-pool 与 t-plans 是**两个完全不同的东西**, 别被名字带偏 (原名 t-plan/t-plans 只差一个 s, # 实测把人绕进去过): # t-pool = 上游选股计划 = **候选池** = "我可以买哪些票" (400 只, 来自 /plan 接口) # t-plans = PMS 自己排的 **方案** = "买哪只、多少股、分几批" (来自你下的命令) # 候选池是命令的**原料**, 方案是命令的**产物**。候选池满了不等于会有方案 —— 中间要有命令。 +watch: ## 【盯盘看这个】一屏看完整条链 (只读)。加 N=10 每 10 秒刷; 加 WIDE=1 连账本一起 + $(RUN) python scripts/watch.py $(if $(N),-n $(N),) $(if $(WIDE),--wide,) + t-pool: ## A1 拉**候选池**(上游选股计划="能买哪些"; = 08:40 调度位) @curl -s -X POST '$(A)/api/ops/plan-refresh' | $(J) diff --git a/README.md b/README.md index 04d1a1f..1991657 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ scripts/ ws_smoke.py ws 联调工具: status/watch/place/cancel/inbox (绕开 dispatch_mode) code_fingerprint.py 当前 .py 源码的短哈希 —— `make test` 拿它比对容器与工作树, 防「git pull 了没 build, 跑的还是旧代码而且照样 PASS」 + watch.py **盯盘就看这个** (`make watch`): 通道/账户/在途指令/出口委托/ + 账本持仓 一屏看完, 只读。`make watch N=10` 每 10 秒刷 ``` ## 三条铁律 @@ -374,6 +376,22 @@ git pull → make test → ALL SUITES PASS | 12 | ~~部署便利性:`make deploy` 一句话完成 build + up + 各 profile~~ | ✅ 2026-07-31:`Makefile`(`make help` 看全部)。`deploy` 包 `scripts/deploy.sh`;另有 `test` / `initdb` / `check` / `probe` / `changes` / `industry` / `ws-status`。一次性命令统一带 `--no-deps`,免得跑个单测把 beat/worker 也拉起来 | | 13 | ~~静默失败专项:八条全修 + 关键路径禁止丢弃返回值~~ | ✅ 2026-07-31:见上一节。新增 `test_batch10_units.py` 27 例,其中 [A] 组是静态扫描守卫 | +### 盯盘:用 `make watch`,别拿 `t-*` 当监控 + +`t-ins` / `t-gate` 这类目标回的是原始 JSON——一条指令刷四十行,中文还被 `json.tool` 转义成 `\uXXXX`,而那些被转义的 `reason` 恰恰是最该看的东西。它们是**排查**用的(要看全字段时才用),不是**盯盘**用的。 + +盯盘时要回答的其实只有五个问题,分散在五个接口里:通道通不通、单发出去没有、出手或没出手为什么、成交回来没有、账本认领对不对。`make watch` 把这五问拍成一屏: + +```bash +make watch # 看一眼 +make watch N=10 # 每 10 秒刷一次,Ctrl-C 退出 +make watch WIDE=1 # 连方案分布与评审账本一起显示 +``` + +只读,跑多少次都不影响状态。两个判读要点写在表头上:**「没出手」和「出手失败」长得不一样**(前者是指令停在 PROPOSED、出口队列空、裁决栏写着 WAIT/STOP 及原因;后者是裁决 FIRE 但指令没进 DISPATCHED,或出口表有行卡在 QUEUED/SEND_FAILED),以及 **WAIT 与 STOP 是两回事**(WAIT 是"条件没到待会儿再看",STOP 是"本日不再出手",比如触发不追高闸——那只票今天不会再有动静,别一直等)。 + +顺带把 `Makefile` 里的 `json.tool` 加了 `--no-ensure-ascii`,所有 `t-*` 目标的中文现在都是中文了。 + ### 运行态注意(2026-07-31 收尾时的状态) - `pms-beat` **停着**——08:40 拉计划、盘中执行等调度位都不会自动跑,开发期用页面「上游计划 → 强刷」与「运维」抽屉手动触发。重开 beat 前先确认账本已重建,否则信号消化会把信号 ACK 掉(IGNORE 也照样 ACK,跨日拿不回来)。 diff --git a/scripts/watch.py b/scripts/watch.py new file mode 100644 index 0000000..a852d66 --- /dev/null +++ b/scripts/watch.py @@ -0,0 +1,307 @@ +# -*- 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(): + from app.repo import pms_repo + rows = pms_repo.list_instructions(statuses=list(LIVE_INS), limit=50) + _sec(f"在途指令 {len(rows)} 条") + if not rows: + print(" (无) —— 方案还没转指令就跑 make t-mat; 一条方案都没有就先下命令") + return + print(" " + _pad("代码", 11) + _pad("方向", 5) + _pad("总量", 8) + + _pad("已成", 7) + _pad("状态", 11) + _pad("配额", 7) + "最近裁决") + for r in rows: + d = (r.get("progress") or {}).get("last_decision") or {} + act = d.get("action") or "—" + mark = {"FIRE": "▶", "WAIT": "·", "STOP": "■", "SKIP": "○"}.get(act, " ") + why = _short(d.get("reason") or "(本轮还没轮到它)", 26) + at = d.get("at") or "" + 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(str(d.get("qty_hint") or "—"), 7) + + f"{mark} {at} {act} {why}") + 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 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() + 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()