308 lines
11 KiB
Python
308 lines
11 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 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()
|