From 391eed4607efa34bb589da048f30a115a5a18998 Mon Sep 17 00:00:00 2001 From: zlt Date: Wed, 29 Jul 2026 11:01:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=80=E5=A7=8B=E8=81=94=E8=B0=83=EF=BC=8C?= =?UTF-8?q?=E5=8A=A0=E5=85=A5=E6=A8=A1=E6=8B=9F=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 16 ++++ scripts/ws_smoke.py | 227 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 scripts/ws_smoke.py diff --git a/README.md b/README.md index bda0382..c41be86 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ scripts/ check_db.py 实机连通性与表结构自检 (需真实 .env) gen_keys.py ws 通道密钥: 生成 / 只取公钥(--pubkey) / PEM(--pem) / 自检(--check) reset_ledger.py 清空账本并把回放游标对齐到当前 (影子运行期重来一次; 不碰下游表) + ws_smoke.py ws 联调工具: status/watch/place/cancel/inbox (绕开 dispatch_mode) ``` ## 三条铁律 @@ -252,6 +253,21 @@ docker compose --profile ws up -d pms-ws && docker compose logs -f pms-ws 任一步不放心都可以退回去:把 `PMS_DISPATCH_MODE` 改回 `shadow` 即恢复人工执行,`pms-ws` 停掉也只是让指令拒发(保持原状),不会产生半截状态。 +### 联调(协议 §9 的 S1/S2/S3) + +> **两个「影子模式」不是一回事,别混。** 协议 §9 的 S2「影子模式」指 **QMT 侧只回报不下单**;PMS 的 `PMS_DISPATCH_MODE=shadow` 指 **PMS 侧压根不发**。要跑 S2,PMS 必须真发——但为此切 `PMS_DISPATCH_MODE=ws` 会把整条真实指令流(命令方案、自主提议、信号消化)一起放上通道,你没法控制「这一刻只发这一张」。 + +所以联调用 `scripts/ws_smoke.py`:它直接往 `pms_qmt_order` 出口表塞委托,**绕开 `dispatch_mode` 开关**,但完整走 `ws_codec` 的参数校验与 pms-ws 的签名下发路径——消息格式、签名、幂等、回报入账全是真的,只有「发什么」由你说了算。生产开关自始至终不动。 + +| 阶段 | 做什么 | 通过标准 | +|---|---|---| +| **S1** 握手心跳 | 起 `pms-ws`,`ws_smoke.py watch` 盯着;拔网线 60 秒再插回 | `conn_state` 回到 ONLINE,`seq` 水位无缺口,日志里退避是 1→2→5→10→30 | +| **S2** 消息互通 | 先跟 QMT 侧确认「只回报不下单」;`place --yes` 发一张 100 股、限价远离市价的单,走完 ack → order_update → trade → 终态 | `ws_smoke.py inbox` 看到全套上行;`pms_qmt_order` 状态推进正确;`pms_lot` 入账、`pms_cash_flow` 有费用行 | +| **S3** 小额实盘 | 单笔 ≤100 股,覆盖五类:全成 / 部分成交 / 主动撤单(`cancel`)/ 到期过期(`--ttl 1` 等它自己撤)/ 各类拒绝(挂涨停价触发 `LIMIT_UP`) | 五类各至少一次;日终对账零差异 | +| **切换** | 页面把 `PMS_DISPATCH_MODE` 改 `ws` | 真实指令流开始走通道 | + +`place` 默认 100 股,且**建议限价故意给远离市价的值**(卖单挂高、买单挂低)——挂不上才是预期,验的是消息链路而不是成交。 + **待外部协商(剩余)**:`QMT_INTERFACE_REQUIREMENTS.md` 的 A/C/D 各项。已闭环的有:A2 状态枚举与成交均价、A3 资金快照、A4 逐笔成交、Q1~Q11 与 R1/R2(详见协议 §10)。已知存疑项:`trading_log.extra_data` 里的 `total_filled` 口径与示例数据矛盾,PMS 事后核对绕开该列,只读 `traded_volume / traded_price / traded_amount`(协议 §10.2)。 ## 开发约定 diff --git a/scripts/ws_smoke.py b/scripts/ws_smoke.py new file mode 100644 index 0000000..80311e3 --- /dev/null +++ b/scripts/ws_smoke.py @@ -0,0 +1,227 @@ +# -*- 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())