加一个监测脚本

This commit is contained in:
zlt 2026-08-03 10:09:16 +08:00
parent cf99e1446c
commit 375de72309
3 changed files with 333 additions and 3 deletions

View File

@ -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 \ .PHONY: help deploy deploy-local build up down ps logs test stale initdb check health \
probe changes industry ws-status rebuild rebuild-accept \ 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 \ watch t-pool t-plan t-pre t-issue t-cmd t-cmds t-catalog t-plans t-mat \
t-tick t-ins t-book t-gate reset-ledger shell t-dry t-tick t-ins t-book t-gate reset-ledger shell
help: ## 列出所有目标 help: ## 列出所有目标
@grep -hE '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ @grep -hE '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
@ -126,13 +126,18 @@ rebuild-accept: ## 只看判收: 安全垫分布 / 批次账 / 行业集中度
# 实现 B = PMS 自己算 (分日配额 / 分笔 / VWAP / 回踩 / 不追高 / 14:45 兜底) —— 已实现 # 实现 B = PMS 自己算 (分日配额 / 分笔 / VWAP / 回踩 / 不追高 / 14:45 兜底) —— 已实现
# 所以跑 A 段不需要动决策系统的任何逻辑, 它这一段本来就不参与。 # 所以跑 A 段不需要动决策系统的任何逻辑, 它这一段本来就不参与。
A ?= http://127.0.0.1:38100 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 与 t-plans 是**两个完全不同的东西**, 别被名字带偏 (原名 t-plan/t-plans 只差一个 s,
# 实测把人绕进去过): # 实测把人绕进去过):
# t-pool = 上游选股计划 = **候选池** = "我可以买哪些票" (400 只, 来自 /plan 接口) # t-pool = 上游选股计划 = **候选池** = "我可以买哪些票" (400 只, 来自 /plan 接口)
# t-plans = PMS 自己排的 **方案** = "买哪只、多少股、分几批" (来自你下的命令) # 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 调度位) t-pool: ## A1 拉**候选池**(上游选股计划="能买哪些"; = 08:40 调度位)
@curl -s -X POST '$(A)/api/ops/plan-refresh' | $(J) @curl -s -X POST '$(A)/api/ops/plan-refresh' | $(J)

View File

@ -80,6 +80,8 @@ scripts/
ws_smoke.py ws 联调工具: status/watch/place/cancel/inbox (绕开 dispatch_mode) ws_smoke.py ws 联调工具: status/watch/place/cancel/inbox (绕开 dispatch_mode)
code_fingerprint.py 当前 .py 源码的短哈希 —— `make test` 拿它比对容器与工作树, code_fingerprint.py 当前 .py 源码的短哈希 —— `make test` 拿它比对容器与工作树,
防「git pull 了没 build, 跑的还是旧代码而且照样 PASS」 防「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 也拉起来 | | 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] 组是静态扫描守卫 | | 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 收尾时的状态) ### 运行态注意2026-07-31 收尾时的状态)
- `pms-beat` **停着**——08:40 拉计划、盘中执行等调度位都不会自动跑,开发期用页面「上游计划 → 强刷」与「运维」抽屉手动触发。重开 beat 前先确认账本已重建,否则信号消化会把信号 ACK 掉IGNORE 也照样 ACK跨日拿不回来 - `pms-beat` **停着**——08:40 拉计划、盘中执行等调度位都不会自动跑,开发期用页面「上游计划 → 强刷」与「运维」抽屉手动触发。重开 beat 前先确认账本已重建,否则信号消化会把信号 ACK 掉IGNORE 也照样 ACK跨日拿不回来

307
scripts/watch.py Normal file
View File

@ -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()