tradingSystem/scripts/watch.py

399 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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()