228 lines
9.9 KiB
Python
228 lines
9.9 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
ws 通道联调工具 (协议 QMT_WS_PROTOCOL.md §9 的 S1/S2/S3)
|
|
=========================================================
|
|
运行:
|
|
docker compose run --rm pms-web python scripts/ws_smoke.py status # 通道状态一览
|
|
docker compose run --rm pms-web python scripts/ws_smoke.py watch # 持续盯 (Ctrl-C 退出)
|
|
docker compose run --rm pms-web python scripts/ws_smoke.py place --yes \
|
|
--code 600000.SH --side sell --qty 100 --price 9.99 # 手工发一张测试委托
|
|
docker compose run --rm pms-web python scripts/ws_smoke.py cancel --id INS-20260729-xxxx --yes
|
|
docker compose run --rm pms-web python scripts/ws_smoke.py inbox # 最近上行消息
|
|
|
|
为什么要有这个工具, 而不是直接切 PMS_DISPATCH_MODE=ws
|
|
-----------------------------------------------------
|
|
协议 §9 的「S2 影子模式」指的是 **QMT 侧只回报不下单**; PMS 的 `PMS_DISPATCH_MODE=shadow`
|
|
指的是 **PMS 侧压根不发**。两个"影子"是不同的东西, 别混。要跑 S2, PMS 必须真发 —— 但如果
|
|
为此把 PMS_DISPATCH_MODE 切成 ws, 整条真实指令流 (命令方案、自主提议、信号消化) 就一起
|
|
上了通道, 你没法控制"这一刻只发这一张"。
|
|
|
|
本工具直接往 pms_qmt_order 出口表塞一张委托, **绕开 dispatch_mode 开关**, 但完整走
|
|
ws_codec 的参数校验和 pms-ws 的签名下发路径 —— 消息格式、签名、幂等、回报入账全都是真的,
|
|
只有"发什么"由你说了算。联调完删掉这张单即可, 生产开关自始至终没动过。
|
|
|
|
**这张单会真的报到 QMT。** 对方若已在实盘模式, 就会真成交。所以:
|
|
* 必须显式 --yes
|
|
* 默认 100 股, 且限价故意给远离市价的值 (卖单挂高/买单挂低), 挂不上才是预期
|
|
* 组合当前超总仓上限时规则闸会挡买入 —— 但本工具**绕过规则闸**, 请自己把住
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
BAR = "=" * 70
|
|
|
|
|
|
def _fmt_age(dt):
|
|
if not dt:
|
|
return "从未"
|
|
return f"{(datetime.now() - dt).total_seconds():.0f}s 前"
|
|
|
|
|
|
def cmd_status(args):
|
|
from app.repo import qmt_repo
|
|
from app.services import dispatcher
|
|
from config.settings import settings
|
|
|
|
st = qmt_repo.get_state()
|
|
ch = dispatcher.channel_status()
|
|
print(BAR)
|
|
print(f"ws 通道状态 · {datetime.now():%Y-%m-%d %H:%M:%S}")
|
|
print(BAR)
|
|
print(f" 端点 {settings.PMS_QMT_WS_URL}")
|
|
print(f" 下发模式 PMS_DISPATCH_MODE = {dispatcher.mode()}"
|
|
f" (联调用本工具, 不必切到 ws)")
|
|
print(f" pms-ws 进程 {'在线' if ch['process_alive'] else '**不在线**'}"
|
|
f" (心跳 {_fmt_age(st.get('heartbeat_at'))})")
|
|
print(f" 连接 {ch['conn_state']}"
|
|
+ (f" 自 {st.get('connected_at')}" if st.get("connected_at") else ""))
|
|
print(f" seq 水位 已落库 {ch['last_seq']} / 已确认 {ch['acked_seq']}"
|
|
f" / 对端自报 {st.get('server_seq')}")
|
|
print(f" 待入账上行 {ch['inbox_pending']} 条")
|
|
print(f" 出口队列 {ch['queue'] or '空'}")
|
|
if st.get("stat"):
|
|
s = st["stat"]
|
|
print(f" 收发计数 收 {s.get('rx')} 发 {s.get('tx')} · 成交 {s.get('trades')}"
|
|
f" · 拒绝 {s.get('rejects')} · 重连 {s.get('reconnects')}")
|
|
if st.get("last_error"):
|
|
print(f" 最后错误 {st['last_error']}")
|
|
if ch.get("resync_required"):
|
|
print(" ⚠ resync_flag=1: 对端补发不全, 须走全量对账后经页面清除")
|
|
print()
|
|
rows = qmt_repo.list_orders(limit=int(args.limit))
|
|
if rows:
|
|
print(f"最近 {len(rows)} 张委托:")
|
|
for o in rows:
|
|
print(f" {o['instruction_id']:<28} {o['ts_code']:<11} {o['side']:<4} "
|
|
f"{o['qty']:>6}股 @{float(o['limit_price']):<8.2f} "
|
|
f"{o['status']:<12} 成交 {o['cum_qty']:>6}"
|
|
+ (f" 撤单 {o['cancel_state']}" if o['cancel_state'] != "NONE" else "")
|
|
+ (f" {o['reject_code']}" if o.get("reject_code") else ""))
|
|
else:
|
|
print("出口表暂无委托")
|
|
return 0
|
|
|
|
|
|
def cmd_watch(args):
|
|
print("持续盯通道状态, Ctrl-C 退出\n")
|
|
try:
|
|
while True:
|
|
os.system("clear" if os.name != "nt" else "cls")
|
|
cmd_status(args)
|
|
time.sleep(int(args.interval))
|
|
except KeyboardInterrupt:
|
|
print("\n已退出")
|
|
return 0
|
|
|
|
|
|
def cmd_place(args):
|
|
from app.core import ws_codec as wsc
|
|
from app.repo import qmt_repo
|
|
from app.services import dispatcher
|
|
|
|
ch = dispatcher.channel_status()
|
|
if not ch["process_alive"]:
|
|
print("FAIL pms-ws 进程不在线 —— 先起进程再联调:")
|
|
print(" docker compose --profile ws up -d pms-ws")
|
|
return 1
|
|
if ch["conn_state"] != "ONLINE":
|
|
print(f"WARN 连接当前 {ch['conn_state']} (未 ONLINE)。委托会排在队列里, "
|
|
f"连上之后立即发出 —— 确认这是你想要的。")
|
|
|
|
iid = args.id or wsc.new_instruction_id(int(datetime.now().strftime("%Y%m%d")))
|
|
valid_until = int((datetime.now() + timedelta(minutes=int(args.ttl))).timestamp() * 1000)
|
|
try:
|
|
payload = wsc.place_order_payload(
|
|
instruction_id=iid, ts_code=args.code, side=args.side, qty=int(args.qty),
|
|
limit_price=float(args.price), valid_until=valid_until,
|
|
intent=args.intent, note=args.note or "ws 联调测试单")
|
|
except wsc.CodecError as e:
|
|
print(f"FAIL 参数不合协议 ({e.code}): {e.message}")
|
|
return 1
|
|
|
|
print(BAR)
|
|
print("即将向 QMT 发出一张**真实委托**")
|
|
print(BAR)
|
|
for k in ("instruction_id", "ts_code", "side", "qty", "limit_price", "intent", "note"):
|
|
print(f" {k:<16} {payload[k]}")
|
|
print(f" {'valid_until':<16} {datetime.fromtimestamp(valid_until / 1000):%H:%M:%S}"
|
|
f" ({args.ttl} 分钟后由 QMT 自动撤)")
|
|
print(f"\n 对端若已在实盘模式, 这张单会真成交。S2 阶段请先与 QMT 侧确认"
|
|
f"「只回报不下单」。")
|
|
if not args.yes:
|
|
print("\n[演练] 未发出。确认后加 --yes 重跑。")
|
|
return 0
|
|
|
|
qmt_repo.enqueue_order(
|
|
instruction_id=iid, parent_id=f"SMOKE_{iid}", ts_code=payload["ts_code"],
|
|
side=payload["side"], qty=payload["qty"], limit_price=payload["limit_price"],
|
|
valid_until=valid_until, intent=payload["intent"], note=payload["note"])
|
|
print(f"\nOK 已入出口队列: {iid}")
|
|
print(f" pms-ws 会在 {0.5} 秒内取走并签名下发。跟踪:")
|
|
print(f" python scripts/ws_smoke.py status")
|
|
print(f" docker compose logs -f pms-ws")
|
|
print(f" 撤单:")
|
|
print(f" python scripts/ws_smoke.py cancel --id SMOKE_{iid} --yes")
|
|
return 0
|
|
|
|
|
|
def cmd_cancel(args):
|
|
from app.core import ws_codec as wsc
|
|
from app.repo import qmt_repo
|
|
if not args.id:
|
|
print("FAIL 需要 --id (父指令 id; 联调单是 SMOKE_INS-...)")
|
|
return 1
|
|
if not args.yes:
|
|
print(f"[演练] 将把 {args.id} 名下所有在途子单标为待撤。确认后加 --yes。")
|
|
return 0
|
|
cid = wsc.new_cancel_id(int(datetime.now().strftime("%Y%m%d")))
|
|
n = qmt_repo.request_cancel(parent_id=args.id, cancel_id=cid)
|
|
print(f"OK 已标记 {n} 张待撤 (cancel_id={cid})" if n
|
|
else "该指令名下没有在途子单 (可能已终态)")
|
|
return 0
|
|
|
|
|
|
def cmd_inbox(args):
|
|
from app.repo import qmt_repo
|
|
rows = qmt_repo.inbox_list(limit=int(args.limit))
|
|
if not rows:
|
|
print("inbox 为空 —— 还没收到任何上行消息")
|
|
return 0
|
|
print(f"最近 {len(rows)} 条上行消息 (新→旧):")
|
|
for r in rows:
|
|
mark = {0: "待入账", 1: "已入账", 2: "已消化"}.get(int(r.get("processed") or 0), "?")
|
|
body = json.dumps(r.get("payload") or {}, ensure_ascii=False)
|
|
print(f" seq={r['seq']:<8} {r['msg_type']:<15} {mark} {body[:110]}")
|
|
return 0
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="ws 通道联调工具 (协议 §9)")
|
|
sub = ap.add_subparsers(dest="cmd")
|
|
|
|
p = sub.add_parser("status", help="通道状态与最近委托")
|
|
p.add_argument("--limit", default=10)
|
|
p.set_defaults(fn=cmd_status)
|
|
|
|
p = sub.add_parser("watch", help="持续盯状态")
|
|
p.add_argument("--limit", default=10)
|
|
p.add_argument("--interval", default=3)
|
|
p.set_defaults(fn=cmd_watch)
|
|
|
|
p = sub.add_parser("place", help="手工发一张测试委托 (绕开 dispatch_mode)")
|
|
p.add_argument("--code", required=True, help="点式代码, 如 600000.SH")
|
|
p.add_argument("--side", required=True, choices=["buy", "sell"])
|
|
p.add_argument("--qty", default=100, help="默认 100 股")
|
|
p.add_argument("--price", required=True, help="限价, 2 位小数")
|
|
p.add_argument("--ttl", default=5, help="有效期 (分钟), 到点 QMT 自动撤")
|
|
p.add_argument("--intent", default="TRIM", choices=["OPEN", "FILL", "ADD", "DCA",
|
|
"TRIM", "EXIT", "T0"])
|
|
p.add_argument("--id", default=None, help="自定 instruction_id (默认自动生成)")
|
|
p.add_argument("--note", default=None)
|
|
p.add_argument("--yes", action="store_true", help="确认发出 (缺省只演练)")
|
|
p.set_defaults(fn=cmd_place)
|
|
|
|
p = sub.add_parser("cancel", help="请求撤单")
|
|
p.add_argument("--id", required=True, help="父指令 id")
|
|
p.add_argument("--yes", action="store_true")
|
|
p.set_defaults(fn=cmd_cancel)
|
|
|
|
p = sub.add_parser("inbox", help="最近上行消息")
|
|
p.add_argument("--limit", default=20)
|
|
p.set_defaults(fn=cmd_inbox)
|
|
|
|
args = ap.parse_args()
|
|
if not getattr(args, "fn", None):
|
|
ap.print_help()
|
|
return 2
|
|
return args.fn(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|