399 lines
16 KiB
Python
399 lines
16 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 _left_today(ins: dict, used: int):
|
||
"""本日剩余额度 = 当日配额 − 今日已投放。用的是执行器那两个纯函数, 口径一致。"""
|
||
from app.core import exec_timing as et, tradedays as td
|
||
try:
|
||
remaining = max(0, int(ins.get("qty") or 0) - int(ins.get("exec_qty") or 0))
|
||
dl = (ins.get("progress") or {}).get("deadline")
|
||
left_days = td.trade_days_left(dl, None) if dl else 1
|
||
quota = et.daily_quota(remaining, left_days,
|
||
allow_odd_tail=(ins.get("action") == "EXIT"))
|
||
return max(0, quota - int(used))
|
||
except Exception:
|
||
return "?"
|
||
|
||
|
||
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
|
||
# 「余」必须**现算**, 不能读 last_decision 里的 qty_hint —— 那是执行器上一跳
|
||
# 存下来的快照, 而投/废是此刻现算的。两者取自不同时刻, 会拼出自相矛盾的一行:
|
||
# 2026-08-03 实机 002518.SZ 显示 `0/0/200` —— 废了 200 却还说余 0, 因为执行器
|
||
# 那一跳时第二张分片还挂着(算已投), 等 watch 来看时它刚到期。三个数必须同源。
|
||
left = _left_today(r, used)
|
||
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()
|