361 lines
14 KiB
Python
361 lines
14 KiB
Python
# -*- 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 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()
|