From c4833ba2575960bf1a61acb23683f3c2bfe1b155 Mon Sep 17 00:00:00 2001 From: zlt Date: Tue, 28 Jul 2026 15:48:57 +0800 Subject: [PATCH] =?UTF-8?q?qmt=E7=B3=BB=E7=BB=9F=E5=AF=B9=E6=8E=A5?= =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 11 + QMT_INTERFACE_REQUIREMENTS.md | 25 +- QMT_WS_PROTOCOL.md | 531 ++++++++++++++++++++++++++++ README.md | 105 +++++- app/core/exec_timing.py | 30 ++ app/core/ws_codec.py | 391 +++++++++++++++++++++ app/db/session.py | 8 + app/repo/qmt_repo.py | 387 +++++++++++++++++++++ app/services/dispatcher.py | 251 ++++++++++---- app/services/executor.py | 11 +- app/services/param_store.py | 37 +- app/web/main.py | 52 +++ app/ws/__init__.py | 0 app/ws/runner.py | 635 ++++++++++++++++++++++++++++++++++ config/settings.py | 22 +- ddl_pms_v1.sql | 101 +++++- docker-compose.yml | 23 +- requirements.txt | 6 + scripts/check_db.py | 65 +++- scripts/run_tests.py | 6 +- scripts/test_batch3_units.py | 19 + scripts/test_batch6_units.py | 427 +++++++++++++++++++++++ scripts/test_wiring.py | 13 +- 23 files changed, 3050 insertions(+), 106 deletions(-) create mode 100644 QMT_WS_PROTOCOL.md create mode 100644 app/core/ws_codec.py create mode 100644 app/repo/qmt_repo.py create mode 100644 app/ws/__init__.py create mode 100644 app/ws/runner.py create mode 100644 scripts/test_batch6_units.py diff --git a/.env.example b/.env.example index 9d5c701..6615eb2 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,14 @@ PMS_REDIS_URL=redis://:密码@192.168.16.150:6379/8 # ---- 管理页面 ---- PMS_WEB_PORT=38100 + +# ---- QMT WebSocket 直连通道的密钥 (协议 QMT_WS_PROTOCOL.md §10.1.1) ---- +# 这两项是**密钥**, 只在这里维护: 不入库、不进 ParamStore、不上管理页面、不进代码。 +# 生成一对新密钥并打印公钥 (公钥带外交给 QMT 侧, 私钥不出机器): +# docker compose run --rm pms-web python -c "\ +# import secrets; from app.core import ws_codec as w; \ +# s=secrets.token_hex(32); print('seed(私钥, 填下面):', s); \ +# print('pubkey(公钥, 给对方):', w.public_key_b64(s))" +PMS_QMT_SIGN_SEED_HEX= +# QMT 侧的 Ed25519 公钥 (base64), 用来验上行签名。上下行是两对不同的密钥, 互不复用。 +PMS_QMT_PEER_PUBKEY_B64= diff --git a/QMT_INTERFACE_REQUIREMENTS.md b/QMT_INTERFACE_REQUIREMENTS.md index 3c4d415..7cdb695 100644 --- a/QMT_INTERFACE_REQUIREMENTS.md +++ b/QMT_INTERFACE_REQUIREMENTS.md @@ -2,7 +2,9 @@ > 用途:本清单由用户持有,与 QMT 侧(下游交易系统)协商。请下游按编号逐项答复(能提供/字段差异/时延/替代方案),答复直接回填本文档「答复」列,作为对接定稿依据。 > 背景:架构调整后,对下游的指挥权由决策系统(bionic_trader)移交持仓系统(tradingSystem/PMS)。`trading_order` / `trading_position` 两表仍归下游维护,PMS 只读;PMS 新增一条带数量的统一指令通道(见 B 部分)。 -> 版本:V1.0(2026-07-27) +> 版本:**V2.0(2026-07-28)** —— QMT 侧已就六项问询答复,本版回填答复并调整通道方案。 +> +> **本版最大变化:B 部分的表通道方案已废止**。双方商定指令下发与执行回报改走 **WebSocket 双向长连接**,协议细节见同目录 [`QMT_WS_PROTOCOL.md`](./QMT_WS_PROTOCOL.md)。B 部分保留仅供追溯,不再作为实现依据。A 部分(只读数据)与 C 部分(切换约定)继续有效。 --- @@ -11,14 +13,21 @@ | # | 数据项 | 需要的字段 | 期望更新时延 | 用途 | 答复 | |---|---|---|---|---|---| | A1 | 持仓快照 `trading_position` | 股票代码(点式)、持仓数量、**可用数量(T+1 可卖)**、成本价(如有)、冻结数量(如有) | 盘中 ≤ 5 分钟 | 账本对账基准。当前 PMS 只确认过 `stock_code` 列可用,**请提供该表完整字段定义(DDL)**,尤其确认是否已有"可用数量"列——若无,PMS 自行按 T+1 规则推算 | **PMS 侧实机探明(2026-07-27,待下游确认语义)**:该表 14 列,含 `stock_code / stock_name / total_quantity / available_quantity / frozen_quantity / cost_price / market_price / market_value / profit_loss`。**可用数量列已存在**(`available_quantity`),本项数据需求实质已满足,PMS 已按此列对接。仍请确认:①`available_quantity` 是否即 T+1 可卖口径 ②更新时延 ③`stock_code` 格式(点式/前缀式) | -| A2 | 委托与成交 `trading_order` | 委托号、股票代码、方向、委托价、委托量、状态(**完整状态枚举文档**)、成交量、成交均价、委托/成交时间、来源标识 | 状态变更后 ≤ 1 分钟 | 成交回放入账(批次/成本)、指令执行确认。**请提供完整 DDL 与状态流转说明**(现掌握的 submitted/filled/completed/pending/failed 为推断口径,需正式确认) | **PMS 侧实机探明**:该表 20 列,已见 `order_id / strategy_id / stock_code / stock_name / order_side / order_quantity / order_price / target_price / order_status` 等。**仍缺两项,是本清单里最卡 PMS 的**:①状态枚举的正式定义 ②**成交来源标识**(哪条委托对应 PMS 的哪条指令)。在来源标识到位前,PMS 只能按「同股同向 + 下发早于成交 + FIFO」贪心认领,认领不上即判外部成交并告警——人工与 PMS 同时下单时会误判 | -| A3 | 账户资金快照(**新增需求**) | 总资产、可用资金、冻结资金、当日卖出可用资金(T+0 回笼) | 盘中 ≤ 5 分钟;日终必须 | 总规模校准与买入前资金校验。形式不限:新表 / 现有表 / HTTP 接口均可,请给出可行方案 | | -| A4 | 成交回报明细(可选) | 若 A2 已含逐笔或聚合成交(成交量/均价),本项可免;否则请提供逐笔成交表 | 同 A2 | 部分成交场景的精确入账 | | -| A5 | 上游买入计划 `trading_buy_plan` | PMS 将作为该表的承接方(替代原决策系统 ENTRY_GATE 的角色)。请确认:①下游当前是否仍轮询 `is_active=6` 自动挂单?②切换后是否可以**停止**该轮询(统一走 B1 通道),或保留作为过渡(方案 X) | — | 旧通道处置(见 C2) | | +| A2 | 委托与成交 `trading_order` | 委托号、股票代码、方向、委托价、委托量、状态(**完整状态枚举文档**)、成交量、成交均价、委托/成交时间、来源标识 | 状态变更后 ≤ 1 分钟 | 成交回放入账(批次/成本)、指令执行确认。**请提供完整 DDL 与状态流转说明**(现掌握的 submitted/filled/completed/pending/failed 为推断口径,需正式确认) | **PMS 侧实机探明**:该表 20 列,已见 `order_id / strategy_id / stock_code / stock_name / order_side / order_quantity / order_price / target_price / order_status` 等。**仍缺两项,是本清单里最卡 PMS 的**:①状态枚举的正式定义 ②**成交来源标识**(哪条委托对应 PMS 的哪条指令)。在来源标识到位前,PMS 只能按「同股同向 + 下发早于成交 + FIFO」贪心认领,认领不上即判外部成交并告警——人工与 PMS 同时下单时会误判 |

**QMT 侧答复(2026-07-28)**:①**状态枚举**已给出 —— `submitted` 已提交 / `filled` **部分成交** / `completed` 已完成 / `pending` 挂单成功但 QMT 无响应 / `failed` 委托失败;撤单另有可靠终态。②**成交均价**将新增列。③**策略标识**字段表中已有,改造后可写入。

**PMS 侧结论**:本项**主链路已由 WebSocket 承接**,`trade` 逐笔成交消息带 `instruction_id`,来源标识问题自然消解。仍需注意两点,已并入新协议:⑴表中的 `strategy_id` 是指向 `t_trading_strategy` 的整数外键(因子策略表),只能回答「是不是 PMS 下的」,回答不了「是哪一条指令」,**不足以支撑回放认领**;⑵`filled` = 部分成交这个命名与字面含义相反,协议层改用 `PARTIAL` / `FILLED` 区分,映射见新协议 §7.2。**PMS 不再读 `order_status` 判断成交** | +| A3 | 账户资金快照(**新增需求**) | 总资产、可用资金、冻结资金、当日卖出可用资金(T+0 回笼) | 盘中 ≤ 5 分钟;日终必须 | 总规模校准与买入前资金校验。形式不限:新表 / 现有表 / HTTP 接口均可,请给出可行方案 | **已确认可提供**,走 WebSocket 实时回传。字段与消息格式见新协议 §5.7 `snapshot{kind:"funds"}`。**`sell_return_today`(当日卖出回笼)务必提供**,PMS 买入前的资金校验依赖它 | +| A4 | 成交回报明细(可选) | 若 A2 已含逐笔或聚合成交(成交量/均价),本项可免;否则请提供逐笔成交表 | 同 A2 | 部分成交场景的精确入账 | **已升格为必需项**:QMT 侧确认可回传「逐笔和最终结果」。**PMS 账本以逐笔 `trade` 消息为唯一入账依据**,`order_update` 仅作状态跟踪与终态校验。见新协议 §5.5 | +| A5 | 上游买入计划 `trading_buy_plan` | PMS 将作为该表的承接方(替代原决策系统 ENTRY_GATE 的角色)。请确认:①下游当前是否仍轮询 `is_active=6` 自动挂单?②切换后是否可以**停止**该轮询(统一走 B1 通道),或保留作为过渡(方案 X) | — | 旧通道处置(见 C2) | **未答复,仍待确认**(新协议 Q1)。另注:该表唯一约束为 `(stock_code, trading_time)` 且 `buy_amount` 是金额非股数,单股分批会撞约束——这也是通道改走 WebSocket 后不再受制于该表的原因之一 | -## B. PMS 写入:统一指令通道(新增,核心协商项) +## B. ~~PMS 写入:统一指令通道~~ 【已废止 · 2026-07-28】 -### B1 `pms_order_request` —— 带数量的统一买卖指令(建议表结构,可等价改为 Redis 流) +> **本节方案已被 WebSocket 通道取代,不再作为实现依据,保留仅供追溯。** +> 双方商定:指令下发与执行结果(逐笔成交、最终结果、落库结果)全部走 WebSocket 双向长连接; +> 幂等与防重放由「PMS 生成的 `instruction_id` + 签名 + 时间窗」保证,不再依赖表的唯一约束。 +> **现行方案见 [`QMT_WS_PROTOCOL.md`](./QMT_WS_PROTOCOL.md)。** +> 下方 B1.1~B1.7 的协商点已在新协议中逐条落实:通道形式(§1)、响应节奏(§1 心跳/实时推送)、 +> 部分成交(§5.4/§5.5)、撤单(§4.3)、拒绝码(§7.3)、市价语义(§4.2,改为**限价必填**)、建表归属(不再需要)。 + +### ~~B1 `pms_order_request` —— 带数量的统一买卖指令(建议表结构,可等价改为 Redis 流)~~ ```sql CREATE TABLE pms_order_request ( @@ -57,7 +66,7 @@ CREATE TABLE pms_order_request ( | # | 事项 | 说明 | 答复 | |---|---|---|---| | C1 | 幂等与重复防护 | instruction_id 唯一约束由表/流层保证;下游对同一 instruction_id 只执行一次 | | -| C2 | 旧通道停用清单 | 切换生效后,下游**停止**:①直接执行决策系统的卖出指令与盘中 ENTRY/EXIT 信号 ②(若 A5 确认)轮询 trading_buy_plan 自动挂单。此后下游只接受 B1 通道指令。请确认停用方式与时点 | | +| C2 | 旧通道停用清单 | 切换生效后,下游**停止**:①直接执行决策系统的卖出指令与盘中 ENTRY/EXIT 信号 ②(若 A5 确认)轮询 trading_buy_plan 自动挂单。此后下游只接受 WebSocket 通道指令。请确认停用方式与时点 | ⚠️ **本轮答复未涉及,是当前最大的未决项**(新协议 Q1)。不定死时点,切换当天两套系统会对同一只票同时下单。PMS 侧已完成信号订阅改造(PMS 现已自行消化决策系统信号),下游停用的前置条件已具备 | | C3 | 灰度共存期 | 切换初期建议双轨观察 N 个交易日(旧通道只读不执行、B1 实际执行),请确认可行性 | | | C4 | 时钟与代码格式 | 双方统一点式代码(600000.SH)与服务器时钟(NTP);日期时间字段时区 Asia/Shanghai | | | C5 | 故障约定 | 下游不可用时 PMS 指令停发并告警(PMS 侧守成);下游恢复后不补执行已过期指令 | | diff --git a/QMT_WS_PROTOCOL.md b/QMT_WS_PROTOCOL.md new file mode 100644 index 0000000..a0127eb --- /dev/null +++ b/QMT_WS_PROTOCOL.md @@ -0,0 +1,531 @@ +# PMS ↔ QMT · WebSocket 指令与回报协议 + +> 版本:**V1.0 定稿**(2026-07-28)· Q1~Q11 与 R1/R2 均已答复回填(见 §10),双方据此实现。 +> 后续变更走版本号递增,不再直接改 V1.0 正文。 +> 双方:**PMS**(tradingSystem,持仓管理系统,指令发起方)· **QMT**(券商终端侧执行服务,指令执行方) +> 本文档取代 `QMT_INTERFACE_REQUIREMENTS.md` 中 B 部分的表通道方案。A 部分(只读数据)与 C 部分(切换约定)继续有效。 + +--- + +## 0. 一句话说清分工 + +PMS 决定**买卖什么、多少股、什么价、有效到几点**;QMT 只负责**把这条指令报到交易所,并把发生的一切如实回报**。QMT 不做数量决策、不做价格决策、不做要不要执行的判断——拒绝除外(涨跌停、停牌、资金不足等硬约束)。 + +这条分工是 PMS 三条铁律里「分工不越权」的落地。任何让 QMT「自行判断」的设计都不要写进协议。 + +--- + +## 1. 传输与连接 + +| 项 | 约定 | +|---|---| +| 协议 | **明文 WebSocket(`ws://`),不启用 TLS**(双方商定)。仅限可信内网使用,**不得跨公网暴露该端口** | +| 角色 | **QMT 侧监听,PMS 侧主动连接并负责重连**。QMT 机器 IP 固定,PMS 跑在容器里 IP 会变 | +| 端点 | **`ws://192.168.16.98:8080`**(QMT 侧已提供) | +| 连接数 | 单条长连接双向复用。PMS 不开第二条连接(避免指令乱序) | +| 编码 | UTF-8 JSON,每帧一条完整消息,不分片 | +| 心跳 | PMS 每 **5 秒**发 `ping`,QMT 立即回 `pong`。任一侧 **15 秒**未收到对端消息即主动断开重连 | +| 重连退避 | 1s → 2s → 5s → 10s → 30s 封顶,无限重试 | +| 时钟 | 双方 NTP 对时,偏差需 < 5 秒。时间字段一律 **epoch 毫秒**(整数),展示口径 Asia/Shanghai | +| 访问控制 | 不用 TLS 意味着没有传输层身份校验,因此:①QMT 侧监听端口**必须绑定内网网卡并加 IP 白名单**,只放行 PMS 所在主机 ②消息层的**双向签名从「加固」升格为「必需」**,见第 2 节 | + +> **关于不启用 TLS 的取舍**:明文传输意味着同网段的抓包者能看到持仓、资金与委托内容,也意味着任何能连到该端口的人都可以尝试发指令。第一点是可接受的信息泄露(内网、自有设备);第二点则由第 2 节的签名机制兜住——没有私钥就伪造不出一条能通过验签的指令。换句话说,**去掉 TLS 之后,签名不再是可选项**,它同时承担了身份认证的职责。 + +--- + +## 2. 安全:签名、幂等、防重放 + +关于原始需求里说的「公钥对称加密」——这里实际要解决的是**两件不同的事**,拆开各自用合适的手段更稳妥,也更好实现: + +**其一,别人不能冒充 PMS 下单,也不能篡改指令内容。** 这靠**签名**,不靠加密。采用 **Ed25519**:PMS 持私钥签名,QMT 只持公钥验签——即使 QMT 这台机器被翻了,拿到公钥也伪造不出一条指令。这正好是「公钥」的用法。 + +**其二,网络抖动重发不能造成重复下单。** 这靠**幂等键 + 去重表**,与加密无关。 + +**其三,内容不被中间人看到。** 本次商定不启用 TLS,即**放弃传输机密性**,靠内网边界与端口白名单控制风险(见第 1 节)。业务层不再自行加一层加密——自研加密协议出错的概率远高于它挡住的风险。 + +综上,业务层要做的是**签名 + 幂等**,不是「加密」。由于没有 TLS,签名同时是**唯一的身份认证手段**,双向必签、验签失败即丢弃,不设「宽松模式」。 + +### 2.1 签名 + +签名对象为**规范化字符串**: + +``` +canonical = v + "\n" + type + "\n" + msg_id + "\n" + ts + "\n" + nonce + "\n" + sha256_hex(payload_json) +sig = base64( Ed25519_sign(private_key, canonical) ) +``` + +- `payload_json` 为 payload 对象的**紧凑 JSON**:分隔符用 `,` 与 `:`(无空格)、键按 Unicode 码点升序、中文**不转义**(UTF-8 原文,即 Python 的 `ensure_ascii=False`、Java 的默认行为)。 +- `sha256_hex` 为小写十六进制;`ts` 与 `v` 以十进制整数的字符串形式参与拼接,无前导零。 +- 上下行**分别使用不同密钥对**:PMS 签下行指令,QMT 签上行回报。互不复用。 +- 验签失败 → 直接丢弃并回 `reject{code: "SIG_INVALID"}`,**不执行任何业务动作**。 + +### 2.1.1 测试向量(双方各自实现后先对这一组,对上再联调) + +规范化串的拼接方式是本协议最容易两边写不一致的地方,用一组固定向量把它钉死。测试密钥仅供联调,**不得用于生产**。 + +``` +私钥 seed (hex) 00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff +公钥 (base64) PM0kHP/Js2GARLl9A22GFFk9iwF8NA8d7odzOFUXZUs= +``` + +输入 payload(注意 `note` 字段含中文与全角间隔号,专门用来验证 UTF-8 与不转义): + +```json +{"instruction_id":"INS-20260728-a3f19c04","ts_code":"600000.SH","side":"sell", + "qty":2000,"limit_price":12.35,"valid_until":1769512500000, + "intent":"TRIM","note":"保垫减仓·垫厚先收"} +``` + +信封字段:`v=1`,`type="place_order"`,`msg_id="m-20260728-000123"`,`ts=1769500000123`,`nonce="9f2c8a1b7d3e4056"` + +**中间结果(逐步核对,哪一步对不上就知道差在哪)** + +``` +payload_json {"instruction_id":"INS-20260728-a3f19c04","intent":"TRIM","limit_price":12.35,"note":"保垫减仓·垫厚先收","qty":2000,"side":"sell","ts_code":"600000.SH","valid_until":1769512500000} +payload_sha256 9fa3f56b92ef9d79290b46416899bdbebd93e7fdddbfe5bee5b5e09380bb7c63 +canonical 1\nplace_order\nm-20260728-000123\n1769500000123\n9f2c8a1b7d3e4056\n9fa3f56b92ef9d79290b46416899bdbebd93e7fdddbfe5bee5b5e09380bb7c63 +sig NzCILYw7ampmMtg41EBMubtlnDwr/jjGO+1cMTIxVdqaSaT779Kop5DAdADHL2aSbqrw2sp8hCQb+apCACIYCw== +``` + +`\n` 为单个换行符 `0x0A`,串尾无换行。`limit_price` 序列化为 `12.35`(不是 `12.350000000000001`,实现时若用浮点请确认序列化结果,必要时改用字符串或定点数传输)。 + +**参考实现(Python,可直接跑)** + +```python +import base64, hashlib, json +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +def canonical(v, type_, msg_id, ts, nonce, payload): + pj = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + ph = hashlib.sha256(pj.encode("utf-8")).hexdigest() + return f"{v}\n{type_}\n{msg_id}\n{ts}\n{nonce}\n{ph}" + +def sign(seed_hex, canon): + sk = Ed25519PrivateKey.from_private_bytes(bytes.fromhex(seed_hex)) + return base64.b64encode(sk.sign(canon.encode("utf-8"))).decode() +``` + +### 2.2 幂等(防重复下单) + +| 机制 | 说明 | +|---|---| +| `instruction_id` | 由 **PMS 生成**、全局唯一、长度 ≤ 64。这是幂等键,也是贯穿全流程的关联键 | +| 去重表 | QMT 侧持久化已受理的 `instruction_id`,保留期 ≥ **7 个自然日**(跨周末) | +| 重复处理 | 收到已存在的 `instruction_id` → **不再下单**,回 `ack{duplicate: true, ...当前最新状态}`。这就是「重复则跳过」的落地方式:跳过执行,但仍要回一个状态,好让 PMS 知道原单现在怎么样了 | +| 时间窗 | `ts` 与 QMT 本地时钟偏差 > **30 秒**的消息一律拒绝(`code: "TS_SKEW"`),防止旧包重放 | +| `nonce` | 每条消息随机 16 字节 hex,仅参与签名,QMT 无需存储(时间窗已足够) | + +`instruction_id` 建议格式:`INS--<8位随机>`,例如 `INS-20260728-a3f19c04`。撤单指令另用 `cancel_id`,格式同理,前缀 `CXL-`。 + +--- + +## 3. 消息信封 + +所有消息共用一个信封: + +```json +{ + "v": 1, + "type": "place_order", + "msg_id": "m-20260728-000123", + "ts": 1769500000123, + "nonce": "9f2c8a1b7d3e4056", + "seq": 10231, + "corr_id": "INS-20260728-a3f19c04", + "payload": { }, + "sig": "base64..." +} +``` + +| 字段 | 必填 | 说明 | +|---|---|---| +| `v` | 是 | 协议版本,当前恒为 `1`。对端收到不认识的版本回 `reject{code:"VERSION"}` | +| `type` | 是 | 消息类型,见第 4、5 节 | +| `msg_id` | 是 | 单条消息唯一,仅用于日志追踪与重传识别,**不是**业务幂等键 | +| `ts` | 是 | 发送时刻,epoch 毫秒 | +| `nonce` | 是 | 随机串,参与签名 | +| `seq` | 上行必填 | **仅 QMT→PMS 方向**。全局单调递增整数,QMT 侧持久化,跨重启不回退。断线补发靠它 | +| `corr_id` | 视类型 | 关联的 `instruction_id`(或 `cancel_id`)。所有与某条指令相关的回报都必须带上 | +| `payload` | 是 | 业务体 | +| `sig` | 是 | 签名 | + +--- + +## 4. 下行:PMS → QMT + +### 4.1 `hello` —— 握手(连接建立后 PMS 发的第一条) + +```json +{ "client": "pms", "last_seq": 10230, "protocol": 1 } +``` + +`last_seq` 是 PMS 已持久化的最后一条上行消息序号。QMT 据此补发(见 6.1)。首次连接或本地无记录时传 `0`。 + +### 4.2 `place_order` —— 下单 + +建仓、加仓、减仓、清仓**共用这一条消息**,用 `side` + `qty` 表达,`intent` 只作归类与人工看盘用,不改变执行行为。 + +```json +{ + "instruction_id": "INS-20260728-a3f19c04", + "ts_code": "600000.SH", + "side": "sell", + "qty": 2000, + "limit_price": 12.35, + "valid_until": 1769512500000, + "intent": "TRIM", + "note": "保垫减仓·垫厚先收" +} +``` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `instruction_id` | string | 幂等键,见 2.2 | +| `ts_code` | string | **点式**代码,如 `600000.SH` / `000001.SZ`。与 `trading_*` 表现用格式一致 | +| `side` | string | `buy` / `sell` | +| `qty` | int | **股数**,非金额。买入恒为整百;卖出通常整百,**清仓时可能带零股尾数**(如 2130),QMT 需支持 | +| `limit_price` | number | **必填**,2 位小数。PMS 的择时模块负责定价,QMT 不做价格决策。不接受 null | +| `valid_until` | int | 有效期截止,epoch 毫秒。到期仍未全成,QMT **自动撤单**并回 `EXPIRED` 终态 | +| `intent` | string | `OPEN`/`FILL`/`ADD`/`DCA`/`TRIM`/`EXIT`/`T0`,仅归类 | +| `note` | string | ≤ 200 字,人工看盘用,QMT 原样落库即可 | + +`valid_until` 是这套设计里替代「10 秒轮询撤单」的关键:PMS 不必盯着撤,到点 QMT 自己收。 + +### 4.3 `cancel_order` —— 撤单 + +```json +{ "cancel_id": "CXL-20260728-71b2ef39", "instruction_id": "INS-20260728-a3f19c04" } +``` + +QMT 撤在途部分,已成交部分保留。撤单完成后该指令进入 `CANCELLED` 终态(若撤单前已全成,则回 `reject{code:"ALREADY_FINAL"}` 并附当前状态)。 + +### 4.4 查询类 + +| type | payload | 回应 | +|---|---|---| +| `query_positions` | `{}` | `snapshot`(`kind: "positions"`) | +| `query_funds` | `{}` | `snapshot`(`kind: "funds"`) | +| `query_orders` | `{"date":"2026-07-28"}` 或 `{"instruction_id":"..."}` | `snapshot`(`kind: "orders"`) | + +查询消息同样需要签名,但**不需要** `instruction_id`(无副作用,天然幂等)。 + +### 4.5 `ack_seq` —— 上行消息的累积确认 + +```json +{ "seq": 10450 } +``` + +**累积确认**:表示 `seq ≤ 10450` 的上行消息 PMS 已全部落库,QMT 可安全清理 Redis 中这部分消息。语义同 TCP 的累积 ACK——确认 10450 即隐含确认了之前所有消息,中间漏发一条不会被误确认。 + +发送时机:PMS **每落库 20 条或每 2 秒**(取先到)发一次,只发当前连续水位。**确认前必须已持久化**,不能收到就 ack——否则 QMT 清理了消息、PMS 又崩在落库前,那段数据就永久丢了。 + +断线重连时以 `hello.last_seq` 为准,`ack_seq` 只用于让 QMT 及时释放存储,两者不冲突。 + +### 4.6 `ping` + +`payload: {}`。QMT 立即回 `pong`。 + +--- + +## 5. 上行:QMT → PMS + +所有上行消息带 `seq`,PMS 按 `seq` 顺序消费并持久化水位。 + +### 5.1 `hello_ack` + +```json +{ "server_version": "...", "server_seq": 10450, "resume_from": 10231, "resync_required": false } +``` + +`resync_required: true` 表示 QMT 无法补齐 PMS 请求的区间(日志已滚动),PMS 收到后必须走全量快照对账(见 6.2)。 + +### 5.2 `ack` —— 指令已受理 + +```json +{ "instruction_id": "INS-...", "accepted": true, "duplicate": false, + "broker_order_id": "QMT-88123", "status": "ACCEPTED" } +``` + +`duplicate: true` 时 `status` 反映**首次执行**该指令的当前状态,`broker_order_id` 也是首次那笔的。 + +### 5.3 `reject` —— 指令被拒 + +```json +{ "instruction_id": "INS-...", "code": "LIMIT_UP", "reason": "涨停价无法买入", "retryable": false } +``` + +### 5.4 `order_update` —— 委托状态变更 + +每次状态发生变化推一条,**终态必须推且只推一次**。 + +```json +{ + "instruction_id": "INS-...", "broker_order_id": "QMT-88123", + "status": "PARTIAL", "final": false, + "cum_qty": 600, "cum_avg_price": 12.34, "leaves_qty": 1400, + "update_time": 1769500123456 +} +``` + +| 字段 | 说明 | +|---|---| +| `cum_qty` | **本委托**累计成交股数。口径明确为本委托,不是当日、不是该股票持仓 | +| `cum_avg_price` | **本委托**累计成交均价,2 位小数 | +| `leaves_qty` | 未成交剩余 = `qty - cum_qty`。撤单/过期后应为 0 | +| `final` | 是否终态。终态后该 `instruction_id` 不再有任何 `order_update` | + +> 这三个字段是为了消灭原 `trading_log` 示例里 `total_filled=5000` 而 `order_quantity=600` 那种口径歧义。凡是累计量,一律以「本委托」为准。 + +### 5.5 `trade` —— 逐笔成交 + +**每一笔成交推一条**,只描述这一笔,不含累计: + +```json +{ + "instruction_id": "INS-...", "broker_order_id": "QMT-88123", + "trade_no": "QMT-88123#1", + "ts_code": "600000.SH", "side": "sell", + "qty": 600, "price": 12.34, "amount": 7404.00, + "fee": 3.21, "fee_estimated": true, "trade_time": 1769500123456 +} +``` + +| 字段 | 说明 | +|---|---| +| `trade_no` | 该笔成交的唯一编号。**格式固定为 `{broker_order_id}#{n}`**,`n` 为该委托内成交序号,从 1 起递增。券商原生只提供委托号(`order_id`),一次委托多次成交时委托号相同、无法区分,故由 QMT 按此规则拼出笔号。跨重启唯一性由「委托号唯一 + n 从该委托已回报笔数续接」保证 | +| `qty` / `price` | **本笔**成交股数与价格 | +| `amount` | `price × qty`,QMT 给出,PMS 会校验一致性(差异 > 0.01 元告警) | +| `fee` | 本笔手续费(佣金 + 印花税 + 过户费合计)。QMT 侧按费率**估算**,与实际扣费可能有误差 | +| `fee_estimated` | `true` 表示 `fee` 为估算值。PMS 据此决定是否把它计入成本 | + +**关于手续费的处理口径**(QMT 侧已说明逐笔费用未必精确):PMS **不把 `fee` 摊进持仓成本**,成本价只由 `price × qty` 决定;费用单独记为一笔现金流出。这样估算误差不会污染摊薄成本,进而不会污染安全垫(cushion)与保垫减仓的判断——这是 PMS 里最不能被噪声干扰的一条计算链。真实费用在日终用资金快照反推校准(总资产变动 − 成交净额 = 当日实际费用),只入现金账,不回溯改成本。 + +**PMS 的账本以 `trade` 为唯一入账依据**,`order_update` 只用于状态跟踪与最终核对(用终态的 `cum_qty` 校验逐笔加总是否相等,不等则告警)。这样即使某条 `order_update` 丢失也不会记错账。 + +去重是双层的:**第一层按 `seq`**(补发时同一条消息 `seq` 不变,见 §6.1),**第二层按 `trade_no`**。任一层命中即丢弃。第二层的意义是防住 `seq` 实现出 bug 的场景,两层都命中才入账。 + +### 5.6 `persist_result` —— 落库结果 + +QMT 把成交写入 `trading_order` / `trading_position` / `trading_log` 之后回一条,供 PMS 确认下游表已同步(trading_service 的可视化统计依赖这些表)。 + +```json +{ + "instruction_id": "INS-...", "scope": "trade", + "trade_no": "T-20260728-000991", + "ok": true, + "tables": { "trading_order": "QMT-88123", "trading_log": 88231, "trading_position": "600000.SH" }, + "error": null +} +``` + +- `scope`: `trade`(逐笔落库)/ `final`(该指令终态落库完成) +- `ok: false` 时 `error` 给出原因。**落库失败不影响成交事实**,PMS 照常入账,但会记一条下游不一致告警。 + +### 5.7 `snapshot` —— 查询回应 + +```json +{ "kind": "positions", "as_of": 1769500000000, "items": [ + { "ts_code": "600000.SH", "total_qty": 6000, "avail_qty": 4000, "frozen_qty": 0, + "cost_price": 11.80, "market_price": 12.34 } +]} +``` + +```json +{ "kind": "funds", "as_of": 1769500000000, "data": { + "total_asset": 2013456.78, "available_cash": 312450.10, + "frozen_cash": 12000.00, "sell_return_today": 48900.00, "market_value": 1701006.68 +}} +``` + +`sell_return_today`(当日卖出回笼,T+0 可用)是 PMS 买入前资金校验必需的,请务必提供。 + +### 5.8 `position_update` / `funds_update` —— 主动推送 + +QMT 侧已确认支持。有变化即推,字段同 `snapshot` 的单项。PMS 的 5 分钟 `query_*` 轮询退为兜底手段,不再是主要来源。 + +### 5.9 `pong` + +`payload: {}`。 + +--- + +## 6. 断线、补发与对账 + +WebSocket 相比数据库轮询,最大的代价是**断线期间的消息没地方补读**。因此以下三条是本协议的安全底座,缺一不可。 + +### 6.1 序号补发 + +QMT 侧为每条上行消息分配单调递增 `seq` 并**持久化到 Redis**(跨重启、跨交易日不回退)。PMS 重连时在 `hello` 里带 `last_seq`,QMT 从 `last_seq + 1` 起按序补发,补完再推实时消息。 + +补发的消息与首次推送**完全一致**(同 `seq`、同 `msg_id`、同内容、**同签名**),PMS 侧按 `seq` 与 `trade_no` 双重去重。 + +**消息保留策略**:QMT 收到 `ack_seq{seq: N}`(见 §4.5)后可清理 `seq ≤ N` 的消息。除此之外还需一条时间下限兜底——**已确认的消息也至少保留 7 天**,防止 PMS 侧数据库回滚或误删后无从追溯。 + +> 联调/测试期 QMT 侧 Redis 不设过期时间(双方已确认),上线前需按上述策略配置,并确认 Redis 本身开启持久化(AOF 或 RDB)——`seq` 与消息体都在 Redis 里,Redis 一旦丢数据,补发能力就没了,只能退回全量对账。 + +### 6.2 快照对账(兜底) + +即使有补发,PMS 仍会:盘中每 **5 分钟**发一次 `query_positions` + `query_funds`,与本地账本比对;日终收盘后做一次全量对账。任何一次比对不一致,PMS 记录差异并按严重度决定是否冻结自主动作(这是 PMS 侧既有的对账引擎,QMT 只需保证快照准确)。 + +`resync_required: true` 或发现 `seq` 缺口不可补时,PMS 强制走全量对账,对不齐则停止一切自主动作、告警等人工裁决。 + +### 6.3 故障时双方的行为(「故障即守成」) + +| 情形 | PMS 行为 | QMT 行为 | +|---|---|---| +| PMS 断连 | 重连并补发 | **不做任何自主决策**。在途委托按各自 `valid_until` 到期自动撤单,不新下单、不替 PMS 判断 | +| QMT 断连/不可用 | **停发一切增持指令**,仅保留减持路径;告警 | — | +| 对账不一致 | 冻结自主动作,等人工 | 配合提供快照 | + +减持在故障期仍放行,是 PMS 既有口径(冻结与刹车只挡增持不挡减持),这里保持一致。 + +--- + +## 7. 状态机与枚举 + +### 7.1 协议状态(本协议使用的正式枚举) + +``` + ┌──────────────┐ + place_order → │ ACCEPTED │ QMT 已受理,未报盘 + └──────┬───────┘ + ↓ + ┌──────────────┐ + │ SUBMITTED │ 已报盘,交易所已接受 + └──────┬───────┘ + ┌─────────┼─────────┐ + ↓ ↓ ↓ + ┌─────────┐ ┌────────┐ ┌───────────┐ + │ PARTIAL │→│ FILLED │ │ CANCELLED │ ← cancel_order 或 valid_until + └────┬────┘ └────────┘ └───────────┘ + └──────────→ ┌──────────┐ + │ EXPIRED │ valid_until 到期,QMT 自动撤 + └──────────┘ + 任意阶段失败 → ┌──────────┐ + │ REJECTED │ + └──────────┘ +``` + +**终态**:`FILLED` / `CANCELLED` / `EXPIRED` / `REJECTED`。终态唯一、不可再变、必须推送一次 `order_update{final:true}`。 + +`CANCELLED` 与 `EXPIRED` 均可能带部分成交(`cum_qty > 0`),这是正常情况,PMS 会如实入账。 + +### 7.2 与现有 `trading_order.order_status` 的映射 + +QMT 侧落库时沿用现有表字段,映射关系固定如下: + +| 协议状态 | `trading_order.order_status` | 备注 | +|---|---|---| +| `ACCEPTED` | `pending` | 挂单成功、QMT 尚无响应 | +| `SUBMITTED` | `submitted` | 已提交 | +| `PARTIAL` | `filled` | ⚠️ 现有表里 `filled` 表示**部分成交**,命名易误解,协议层不复用该词 | +| `FILLED` | `completed` | 已完成 | +| `CANCELLED` | `cancelled` | 表已有 `cancel_time` 列,请同时写入 | +| `EXPIRED` | `cancelled` | 落库层**不区分**主动撤与到期撤,见下方说明 | +| `REJECTED` | `failed` | 委托失败 | + +> 提醒:现有表里 `filled` = 部分成交这个命名,未来接手的人几乎必然会读成「已全部成交」。协议层用 `PARTIAL` / `FILLED` 把它区分开,落库时再映射回去。**PMS 不再直接读 `order_status` 判断成交**,一律以 `trade` 消息为准。 + +**关于 `CANCELLED` 与 `EXPIRED` 的区分**:落库层两者都写 `cancelled`,靠「成交数量 + 订单状态」无法区分——两种情况都可能带部分成交,组合完全一样。但这不需要 QMT 额外配合,因为**PMS 自己知道**:主动撤是 PMS 发过 `cancel_order` 的,到期撤是 PMS 没发过撤单却收到了终态。PMS 侧按本地是否发过撤单来标注,不依赖下游表。 + +因此**协议层 `order_update.status` 仍必须区分 `CANCELLED` 与 `EXPIRED`**(QMT 知道自己是被要求撤的还是到点自动撤的,如实填写即可,成本为零),只有落库时才合并。事后统计以 PMS 账本为准。 + +### 7.3 拒绝码 `code` + +| code | 含义 | `retryable` | +|---|---|---| +| `SIG_INVALID` | 签名验证失败 | 否 | +| `TS_SKEW` | 时间戳超出容忍窗口 | 是(对时后重发) | +| `VERSION` | 协议版本不支持 | 否 | +| `DUP_INSTRUCTION` | 幂等键重复(正常经 `ack{duplicate}` 表达,此码仅用于异常场景) | 否 | +| `BAD_PARAM` | 字段缺失/类型错/数量非整百/价格精度超限 | 否 | +| `UNKNOWN_CODE` | 股票代码不存在 | 否 | +| `SUSPENDED` | 停牌 | 否 | +| `LIMIT_UP` / `LIMIT_DOWN` | 涨/跌停无法成交该方向 | 是(换价重发) | +| `INSUFFICIENT_CASH` | 可用资金不足 | 是 | +| `INSUFFICIENT_POSITION` | 可用持仓不足(T+1 未解冻/已冻结) | 是 | +| `NOT_TRADING_TIME` | 非交易时段 | 是 | +| `ALREADY_FINAL` | 指令已终态,无法撤单 | 否 | +| `BROKER_ERROR` | 券商柜台返回错误,`reason` 带原文 | 视情况 | +| `INTERNAL` | QMT 内部错误 | 是 | + +QMT 侧如有本表未覆盖的拒绝场景,请在定稿时补充,**不要归入 `INTERNAL`**——PMS 会对 `INTERNAL` 触发降级告警。 + +--- + +## 8. 数量、价格与代码口径(易错点集中说明) + +| 项 | 约定 | +|---|---| +| 数量单位 | **股**,不是手、不是金额。`trading_buy_plan.buy_amount` 那套金额口径在本通道不再使用 | +| 整百规则 | 买入必整百;卖出整百,**清仓允许零股尾数** | +| 价格精度 | 2 位小数。超出精度直接 `BAD_PARAM`,不做四舍五入(避免双方舍入方向不一致) | +| 累计口径 | 凡 `cum_*` 一律「本委托」口径 | +| 金额 | `amount = price × qty`,不含费用;`fee` 单列 | +| 股票代码 | 点式 `600000.SH`。PMS 内部与本协议统一使用点式 | +| 时间 | epoch 毫秒整数;文字展示为 Asia/Shanghai | +| 一条指令 = 一张委托 | QMT **不得**自行把一条指令拆成多张委托。确需拆单请在定稿前提出,协议要相应扩展 | + +--- + +## 9. 联调与灰度 + +| 阶段 | 内容 | 通过标准 | +|---|---|---| +| S1 握手与心跳 | 连接、签名、`ping`/`pong`、断线重连 | 断网 60 秒后自动恢复,`seq` 无缺口 | +| S2 影子模式 | PMS 正常发指令,QMT **只回报不下单**(`ack` + 模拟 `order_update`) | 全链路消息格式互通,PMS 账本与模拟回报一致 | +| S3 小额实盘 | 单笔 ≤ 100 股,覆盖:全成、部分成交、主动撤单、到期过期、各类拒绝 | 五类场景各至少 1 次,账本与快照对账零差异 | +| S4 灰度并行 | 旧通道只读不执行,新通道实际执行,观察 N 个交易日 | 日终对账连续 N 日一致 | +| S5 切换 | 旧通道**已于 2026-07-28 停用**,本阶段即新通道全量承接 | — | + +> 旧通道已先行停掉,因此 S4 的「灰度并行」不再有旧通道可比对——过渡期实际形态是:PMS 影子运行 + 人工在 QMT 侧执行。S2/S3 通过后即可切 `PMS_DISPATCH_MODE=ws`,S4 改为「小仓位实跑数日、日终对账连续一致」再放开全量。 + +--- + +## 10. Q1~Q11 答复与处置(QMT 侧已答复,2026-07-28) + +| # | 事项 | QMT 答复 | 处置 | +|---|---|---|---| +| Q1 | 旧通道停用时点 | **旧通道停止**,时点为**立即** | ✅ 已闭环,见 §10.1 R1。停用期间 PMS 影子运行,需人工执行 | +| Q2 | `filled_quantity` 是否可靠 | 可靠,但**只作故障校验**,主依据仍是 `order_update` | ✅ 与本协议设计一致。PMS 账本以 `trade` 为准,`order_update` 作状态跟踪,该列仅在对账出现分歧时作第三方参照 | +| Q3 | `trading_log` 的 JSON 落在哪列、`total_filled` 口径 | 落在 **`extra_data`** 列;`total_filled` 是**单笔订单的下单数量** | ⚠️ **答复与示例数据矛盾**:示例第二条 `total_filled=5000` 而 `order_quantity=600`,若它是「单笔下单数量」两者应相等。处置见 §10.2 | +| Q4 | `EXPIRED` 如何落库 | 用 order 表的已成交数量 + 订单状态**联合判断** | ⚠️ 该组合区分不了主动撤与到期撤(两者都是 `cancelled` 且都可能带部分成交)。**已自行化解**:PMS 按本地是否发过 `cancel_order` 自记,不依赖下游表;协议层 `status` 仍区分。见 §7.2 | +| Q5 | 签名方案 | **Ed25519** | ✅ 已锁定。公钥带外交换列入部署清单 §10.1.1 | +| Q6 | 逐笔手续费能否提供 | 逐笔手续费与印花税**不一定准确**,与实际费率可能有误差 | ✅ 已按此调整口径:**费用不摊进持仓成本**,只记现金流出,日终用资金快照反推校准。避免估算误差污染安全垫。见 §5.5。新增 `fee_estimated` 字段 | +| Q7 | `trade_no` 券商是否原生提供 | 原生有 **`order_id`** | ⚠️ `order_id` 是**委托号**不是成交笔号——Q11 已确认一次委托多次回报,同一委托各笔的 `order_id` 相同,无法逐笔去重。**已定规则**:`trade_no = {broker_order_id}#{n}`,QMT 拼接,成本极低。见 §5.5 | +| Q8 | 上行消息保留与 `seq` 持久化 | 消息序列存 **Redis**;ws 中断后下次上线**按最后未 ack 的消息**续发;测试期 Redis 不设过期 | ⚠️ 「按未 ack 续发」意味着需要 PMS 逐条确认,而**原协议没有定义 ack 机制**——已补:新增下行消息 **`ack_seq`(累积确认)**,见 §4.5;保留策略与 Redis 持久化要求见 §6.1 | +| Q9 | 服务地址、端口、白名单 | **`ws://192.168.16.98:8080`** | ✅ 已闭环,见 §10.1 R2 与 §10.1.1 部署清单 | +| Q10 | 是否支持主动推送 | **支持** | ✅ `position_update` / `funds_update` 按 §5.8 实现,PMS 的 5 分钟轮询退为兜底 | +| Q11 | 一条指令是否会被拆成多张委托 | **一次委托,多次返回**(成交信息 + 最终状态) | ✅ 确认「一指令一委托」成立,多次返回正是 §5.5 逐笔成交 + §5.4 状态更新的模型。`broker_order_id` 保持单值 | + +### 10.1 R1 / R2 答复(2026-07-28,已闭环) + +| # | 事项 | 答复 | 处置 | +|---|---|---|---| +| R1 | 旧通道停用时点 | **现在直接停掉** | ✅ 即刻生效。下游停止:①直接执行决策系统卖出信号 ②轮询 `trading_buy_plan` 自动挂单。**注意由此产生的空窗**:新通道实现前 PMS 处于影子运行(`PMS_DISPATCH_MODE=shadow`),指令照常生成、过闸、记账,但需**人工在 QMT 侧执行**,成交由回放认领。这段时间没有任何系统会自动下单——这是安全的,但要知道它是这个状态 | +| R2 | 服务端点与白名单 | **`ws://192.168.16.98:8080`**,白名单即该地址 | ✅ 已写入 §1 与 `config/settings.py` 的 `PMS_QMT_WS_URL`。**部署期还需两步**:①把 PMS 宿主机的内网 IP 报给 QMT 侧加入其入站白名单(PMS 跑在容器里,须用 host 网络或固定出口 IP,否则重启换 IP 会被挡)②双方交换 Ed25519 公钥(各自 `ssh`/U 盘等带外方式送达,私钥不出机器) | + +### 10.1.1 部署前的检查清单 + +- [ ] PMS 宿主机内网 IP 已确定并报给 QMT 侧加白 +- [ ] 双方 Ed25519 公钥已交换,用 §2.1.1 的测试向量互验通过 +- [ ] QMT 侧 `192.168.16.98:8080` 已绑定内网网卡、未暴露公网 +- [ ] QMT 侧 Redis 已开启持久化(AOF/RDB),过期策略按 §6.1 配置 +- [ ] PMS 侧 `PMS_QMT_SIGN_SEED_HEX` / `PMS_QMT_PEER_PUBKEY_B64` 已从 `.env` 注入(**不入库、不进 ParamStore、不写进代码**) + +### 10.2 `total_filled` 的处置(PMS 侧自行规避,不再追问) + +对方答「`total_filled` 是单笔订单的下单数量」,但示例数据里 `total_filled=5000` 而 `order_quantity=600`、`traded_volume=600`,三者对不上。同时可以验证:`47.02 × 600 = 28212 = traded_amount`,说明 `traded_price` / `traded_volume` / `traded_amount` 三个字段**自洽且可信**。 + +由于主链路已走 WebSocket、账本以 `trade` 消息为准,`trading_log` 仅用于事后人工核对,因此不再为此多耗一轮:**PMS 事后核对只读 `traded_volume` / `traded_price` / `traded_amount`,不读 `total_filled`**。该列口径存疑,已在此记录,避免后来者踩坑。 + +--- + +## 11. 变更记录 + +| 版本 | 日期 | 变更 | +|---|---|---| +| V0.9 | 2026-07-28 | 初稿。依据 QMT 侧对《QMT_INTERFACE_REQUIREMENTS.md》六项问询的答复,将指令通道由表轮询改为 WebSocket 双向长连接;补齐签名、幂等、序号补发与对账兜底 | +| V0.9.1 | 2026-07-28 | 按双方最终意见调整:①**不启用 TLS**,改为明文 `ws://` + 内网 IP 白名单,并补充取舍说明与相应的安全补偿(签名升格为必需、承担身份认证职责)②`hello` 去掉 `client_version` ③签名方案锁定 Ed25519(原 Q5 二选一取消)④补齐 §2.1.1 测试向量与参考实现,消除规范化串的实现歧义 | +| **V1.0** | 2026-07-28 | **定稿**。回填 R1/R2:旧通道**即刻停用**、端点定为 `ws://192.168.16.98:8080`;补 §10.1.1 部署前检查清单(白名单、公钥交换、Redis 持久化、密钥只走 `.env`);调整 §9 灰度策略(旧通道已停,无从并行,改为小仓位实跑) | +| V0.9.2 | 2026-07-28 | 回填 QMT 侧 Q1~Q11 答复(§10),并据此补齐四处:①新增下行消息 **`ack_seq` 累积确认**(§4.5)——对方的「按最后未 ack 的消息续发」实现依赖它,原协议缺失 ②`trade_no` 定为 `{broker_order_id}#{n}`(§5.5)——券商原生只有委托号,一委托多成交时无法逐笔去重 ③手续费**不摊入持仓成本**、新增 `fee_estimated` 标志(§5.5)——对方明确逐笔费用可能有误差,避免污染安全垫 ④`CANCELLED`/`EXPIRED` 改由 PMS 侧自记(§7.2),不再要求下游区分。另记录 `total_filled` 口径存疑及规避方式(§10.2)。剩余阻塞项收敛为 R1/R2 两条(§10.1) | diff --git a/README.md b/README.md index 870bd13..5b72f38 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,9 @@ | 文件 | 内容 | |---|---| | `POSITION_MGMT_DESIGN.md` | 总体设计 **V0.4(定稿,开发启动)**:命令系统与管理页面/账本/仓位框架/动作引擎/两道关口/择时执行/下游通道。功能一次性开发,上线按依赖分三步切换 | -| `QMT_INTERFACE_REQUIREMENTS.md` | 与 QMT 侧下游系统协商用的数据与接口需求清单(含资金快照、统一指令通道建议 DDL),按编号答复回填 | -| `ddl_pms_v1.sql` | PMS 全部自有表建表语句(153 代理侧,10 张) | +| `QMT_WS_PROTOCOL.md` | **PMS ↔ QMT WebSocket 指令与回报协议 V1.0(定稿)**:传输与重连、Ed25519 签名与幂等、消息集、状态机、断线补发与对账兜底、部署前检查清单。**这是下发通道的唯一实现依据** | +| `QMT_INTERFACE_REQUIREMENTS.md` | 与 QMT 侧的数据与接口需求清单 **V2.0**:A 部分(只读数据)与 C 部分(切换约定)有效;**B 部分的表通道已废止**,改由上面的 ws 协议承担 | +| `ddl_pms_v1.sql` | PMS 全部自有表建表语句(153 代理侧,**13 张**:设计 §11 的 10 张 + ws 通道 3 张) | | `config/settings.py` | 配置(基础设施键名对齐 bionic;业务参数为初值,页面调参持久化到 `pms_runtime_param` 后优先) | ## 模块地图 @@ -30,29 +31,33 @@ app/ action_engine.py 动作引擎: FILL 回踩补足 / ADD 盈利加仓 / DCA 补仓 / TRIM 保垫减仓 signal_rules.py 决策系统两条信号流的解析与消化口径 (含置信度尺度归一) tradedays.py 交易日历: 调度守卫与执行窗口计算 + ws_codec.py QMT 协议编解码: 规范化串 / Ed25519 签名验签 / 信封 / seq 水位推进 db/session.py 三库连接 + **严格单表访问守卫** (JOIN/逗号连表/跨表子查询一律拒绝) - repo/ 单表数据访问: pms_repo (自有 10 表) / downstream_repo (下游只读三表) + repo/ 单表数据访问: pms_repo (自有 10 表) / qmt_repo (ws 通道 3 表) + / downstream_repo (下游只读三表) services/ 编排层 param_store.py 运行参数中心 (表值优先于 settings 初值, 页面调参即时生效) portfolio.py 组合快照 (账本+行情+行业 → 方案/规则闸/页面的统一输入) command_service.py 命令下达→校验→冲突→生效/规划→进度推进→撤销 executor.py 方案→指令→分日出手→窗口收口 (规则闸与择时的编排落点) - dispatcher.py 下发通道三适配器: shadow(默认) / plan_x / channel_y + dispatcher.py 下发通道两适配器: shadow(默认) / ws(落 pms_qmt_order 出口表) proposal_service.py 自主提议: 扫描→规则闸→研判闸→按自主档位分流 (执行/入队) judge.py 研判闸客户端 (决策系统未接通时自动降级为人工确认) signal_service.py 盘中信号订阅 (db2 广播 + db3 风控卖出) → 卖出指令或提议 ledger_service.py 成交回放 / 对账 / 除权 / 盘前 / 日终结算 / 运营日报 market.py 行情 (Redis db13) 与参考位 (决策系统主口径 + 兜底自算) industry.py 行业划分可插拔适配器 (custom_table / gp_stock_category / 停用) + ws/runner.py **常驻连接进程 (pms-ws)**: 握手/心跳/重连/补发 + 出口出栈 + 上行落库确认 web/ FastAPI + 单页 (Vue3 + ElementPlus),页面四块 + 运维/日报抽屉 scheduler.py Celery beat 调度总表 (设计 §10 八个调度位 + 三条守卫) scripts/ run_tests.py 一次跑完全部单测 (见下方「Docker 部署」) test_core_units.py 仓位与安全垫核心逻辑 14 例 test_batch2_units.py 命令 / 方案 / 回放对账 纯逻辑 35 例 - test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 18 例 + test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 19 例 test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 11 例 test_batch5_units.py 决策系统信号流解析与消化口径 8 例 + test_batch6_units.py ws 通道: 协议测试向量/签名/水位/单表守卫 44 例 test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 32 例 init_db.py 建表 (应用 ddl_pms_v1.sql, 幂等, 默认演练) check_db.py 实机连通性与表结构自检 (需真实 .env) @@ -88,6 +93,9 @@ curl http://127.0.0.1:38100/health # 健康检查 + 配置装载自证 + docker compose --profile sched up -d # 启用调度器 (beat + worker) docker compose logs -f pms-beat pms-worker +docker compose --profile ws up -d pms-ws # 启用 QMT 直连 (先配好 .env 里的两把密钥) +docker compose logs -f pms-ws + # 日常更新 git pull && docker compose build && docker compose up -d ``` @@ -115,13 +123,36 @@ git pull && docker compose build && docker compose up -d | 模式 | 行为 | 什么时候用 | |---|---|---| -| `shadow`(默认) | 指令照常过规则闸、照常置 DISPATCHED,但**不写下游**。你在 QMT 侧人工执行,成交由回放按 FIFO 认领回账本 | 直连服务就绪前的一期口径(设计 §9:命令类降仓/清仓由用户人工执行、PMS 记账跟踪) | -| `plan_x` | 买入写 `trading_buy_plan` | ⚠️ **已作废**:架构已定 trading_service 全量退出业务,此模式不再使用(代码暂留,勿在实盘开启) | -| `channel_y` | 写统一指令表 `pms_order_request` | 新 QMT 直连服务就绪后启用,由它消费本表 | +| `shadow`(默认) | 指令照常过规则闸、照常置 DISPATCHED,但**不写下游**。你在 QMT 侧人工执行,成交由回放按 FIFO 认领回账本 | **当前仍是这个状态**(切 ws 需先配密钥并跑完 S1/S2 联调) | +| `ws` | WebSocket 直连 QMT 执行服务,协议见 `QMT_WS_PROTOCOL.md` V1.0 | 目标形态。**通道已实现**(2026-07-28),联调通过即可切 | -> **目标架构(2026-07-28 已定)**:`trading_service` 全量退出业务,只保留看板与统计展示;新写一个 QMT 直连服务承担挂单与订单/持仓/资金回写;PMS 只管决策与账本。三者之间的数据接口待协定后另行成文。 +**ws 模式的进程边界**(理解这条通道的关键):连接是一条常驻长连接,且协议 §1 规定 PMS 只准开一条(多开会让指令乱序),所以它由独立的 `pms-ws` 进程独占;而 `executor` 跑在 celery worker 这种短命任务进程里。两者**只经数据库耦合**: -影子模式下的完整闭环:页面下命令 → 方案落表 → 方案转指令 → 择时按日配额给出「今天该出多少、什么价」→ 你照着在 QMT 下单 → 5 分钟一次的回放把成交认领回批次账本 → 命令进度自动推进。整条链路除了「人手下单」这一步,其余与实盘接管后完全一致。 +``` +executor (worker) ──写 pms_qmt_order(QUEUED)──▶ pms-ws ──签名──▶ QMT + │ +QMT ──trade/order_update──▶ pms-ws ──落 pms_qmt_inbox──▶ 回放任务(worker) ──▶ 账本 +``` + +出口队列落在业务表而不是内存或 Redis,是因为「先记账后动作」这条铁律在这里可以字面成立——**落表就是记账**;ws 进程崩了重启队列还在,页面查 `pms_qmt_order` 就能看到在途委托,也不引入任何新中间件。代价是亚秒级轮询延迟,而择时本就是分钟级节奏。 + +`pms-ws` **只做通道**,不碰账本:成交入账仍然发生在 worker 里,账本变更保持单一来源,也不至于让一个慢查询把心跳拖到 15 秒超时断连。 + +**放不放行**(协议 §6.3「故障即守成」的落点): + +| ws 进程 | 连接 | 买入 | 卖出 | +|---|---|---|---| +| 心跳陈旧(进程没了) | — | 拒发 | 拒发(排进队列也没人发) | +| 在线 | 断开/重连中 | 拒发 | **照常入队**,重连后立即发出 | +| 在线 | 已连接 | 放行 | 放行 | + +> 原 `plan_x` / `channel_y` 两个适配器已于 2026-07-28 删除。前者写 `trading_buy_plan(is_active=6)`,那个 6 本身就是错的;后者写 `pms_order_request` 表由下游轮询,在通道改定 WebSocket 后作废。`pms_order_request` 表保留未用,不必删表。留着作废路径比删掉更危险——后来者会以为它可用。 + +**目标架构(2026-07-28 已定)**:`trading_service` 全量退出业务,只保留看板与统计展示;新写一个 QMT 直连服务承担挂单与订单/持仓/资金回写,PMS 经 WebSocket 与它直连;PMS 只管决策与账本。 + +**当前处境(重要)**:旧通道(下游直接执行决策系统卖出信号、轮询 `trading_buy_plan` 自动挂单)已按双方约定**即刻停用**。新的 ws 通道代码已就绪,但 `PMS_DISPATCH_MODE` 仍是 `shadow` 且 `PMS_QMT_WS_ENABLED=False`,所以现在**依然没有任何系统会自动下单**——PMS 影子运行,指令照常生成、过闸、记账,但需人工在 QMT 侧执行。这是安全的状态,但要知道它是这个状态。切 ws 是**两个开关加一次联调**,不是再写代码。 + +影子模式下的完整闭环:页面下命令 → 方案落表 → 方案转指令 → 择时按日配额给出「今天该出多少、什么价、挂到几点」→ 你照着在 QMT 下单 → 5 分钟一次的回放把成交认领回批次账本 → 命令进度自动推进。整条链路除了「人手下单」这一步,其余与实盘接管后完全一致。 ## 自主提议的分流(设计 §6 / §7) @@ -135,16 +166,62 @@ git pull && docker compose build && docker compose up -d ## 已实现 / 待开发 -**已实现**:建表 DDL 与建表脚本;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口);三模式下发通道;**动作引擎四类自主动作 + 研判闸客户端 + 提议分流**;**决策系统信号消化**(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器八个调度位;单测 118 例。 +**已实现**:建表 DDL 与建表脚本;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器八个调度位;**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 163 例**。 -**待开发**:T0 做T(二期)、择时实现 A(委托决策系统盘中择时,等 bionic 侧接口)、新 QMT 直连服务的对接(等接口协定)。研判闸客户端已就位,等 bionic 侧 `process_intraday_audit` 新增 PMS 请求 direction 后,在页面填 `PMS_JUDGE_API_BASE` 即接通。 +### 下一步(按可动工顺序) -**待外部协商**:`QMT_INTERFACE_REQUIREMENTS.md` 的 A/B/C/D 各项——尤其 A1(`trading_position` 完整 DDL 与可用数量列)、A2(`trading_order` 状态枚举与**来源标识**)、B1(统一指令通道)。在来源标识到位前,回放按「同股同向 + 下发早于成交 + FIFO」贪心认领,认领不上即判外部成交并告警;持仓数量列用候选名探测,探测结果可经页面「运维 → 导出下游表结构」查看,也是回填 D1 的现成材料。 +| # | 事项 | 状态 | +|---|---|---| +| 1 | **ws 通道的账本侧改造**(清单 4~6) | 可立即开工,见下方「ws 通道实现清单」 | +| 2 | ws 通道联调(协议 §9 的 S1/S2/S3) | 需 QMT 侧配合:公钥交换 + IP 加白 | +| 3 | T0 做T(二期) | 可做,设计已有,无外部依赖 | +| 4 | 择时实现 A(委托决策系统盘中择时) | 阻塞:等 bionic 侧接口 | +| 5 | 研判闸接通 | 阻塞:等 bionic 侧 `process_intraday_audit` 新增 PMS 请求 direction。客户端已就位,接口好了在页面填 `PMS_JUDGE_API_BASE` 即通 | +| 6 | bionic 侧配套改造(出口改道 + PMS direction) | 另一仓库 | + +### ws 通道实现清单 + +协议 `QMT_WS_PROTOCOL.md` V1.0 已定稿,端点 `ws://192.168.16.98:8080`,明文 ws + Ed25519 双向签名。 + +1. ✅ **常驻连接进程** —— `app/ws/runner.py` + compose 的 `pms-ws`(`--profile ws`,**绝不可扩副本**,协议 §1 只准一条连接)。四个协程:存活心跳 / 收帧落库 / 5 秒 ping / 0.5 秒出口轮询。SIGTERM 优雅退出(停取新单 → 刷水位 → 最后一次 ack → 关连接 → 置 STOPPED 并清心跳),`stop_grace_period: 25s`。所有 DB 调用走 `to_thread`,不阻塞事件循环。 +2. ✅ **`dispatcher._ws()`** —— 落 `pms_qmt_order` 出口表置 QUEUED 即返回,签名与发送由 ws 进程做。参数不合协议(非整百、限价缺失、代码非点式)在本地就拦下。撤单把父指令名下所有在途子单标 `cancel_state=REQUESTED`。 +3. ✅ **上行消费与 seq 水位** —— `pms_qmt_inbox` 落库(seq 主键 + `trade_no` 唯一索引 = 协议 §6.1/§5.5 的双层去重),`pms_ws_state` 存连续水位,按 20 条 / 2 秒发 `ack_seq`。**落库失败绝不 ack**——落不了库就主动断线,让对端从 `last_seq+1` 重发,用协议自带的补发机制而不是自攒重试队列。 +4. 🔜 **`recon` 改为吃 `trade` 消息入账** —— 从 `pms_qmt_inbox` 里 `processed=0` 的 trade 行消费;FIFO 贪心认领退化为只处理外部/人工成交;`downstream_repo.FILLED_STATUSES` 降为旁路校验,不再作为成交判据。 +5. 🔜 **手续费口径** —— `fee` 不摊进持仓成本(对方明确逐笔费用可能有误差),只记现金流出,日终用资金快照反推校准。避免污染安全垫。 +6. 🔶 **`CANCELLED` / `EXPIRED` 自记** —— 通道层已做:`pms_qmt_order.cancel_state` 记本地是否发过撤单,与对端回的 `status` 不一致时告警。账本侧的口径随第 4 条一起落。 + +部署前另有一份检查清单在协议 §10.1.1(白名单、公钥交换、Redis 持久化、密钥只走 `.env`)。 + +### 切 ws 通道的操作顺序 + +```bash +# 1. 生成本端密钥对, seed 填进 .env, 公钥带外交给 QMT 侧 +docker compose run --rm pms-web python -c "\ + import secrets; from app.core import ws_codec as w; \ + s=secrets.token_hex(32); print('seed:', s); print('pubkey:', w.public_key_b64(s))" +# 2. 把 QMT 侧公钥填进 .env 的 PMS_QMT_PEER_PUBKEY_B64; 把本机内网 IP 报给对方加白 +# 3. 双方各自用协议 §2.1.1 的测试向量互验 —— 单测已覆盖本端: +docker compose run --rm pms-web python scripts/test_batch6_units.py +# 4. 建新表 (幂等) 并自检 +docker compose run --rm pms-web python scripts/init_db.py --yes +docker compose run --rm pms-web python scripts/check_db.py # [6] 段看通道状态 +# 5. 起进程 (此时 PMS_DISPATCH_MODE 仍是 shadow, 只连不发) +docker compose --profile ws up -d pms-ws && docker compose logs -f pms-ws +# 6. 页面把 PMS_QMT_WS_ENABLED 打开, 观察 /api/ws-channel 的 conn_state 与 seq 水位 +# 7. S2/S3 联调通过后, 页面把 PMS_DISPATCH_MODE 改成 ws +``` + +任一步不放心都可以退回去:把 `PMS_DISPATCH_MODE` 改回 `shadow` 即恢复人工执行,`pms-ws` 停掉也只是让指令拒发(保持原状),不会产生半截状态。 + +**待外部协商(剩余)**:`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)。 ## 开发约定 - 开发机与服务器经 git 同步代码;**构建与运行统一走 Docker**(`docker compose build` → 容器内 `python scripts/run_tests.py` → `up -d`),测试结果回传后迭代。 - 153 代理侧数据库严格单表访问;该纪律已落到 `app/db/session.py` 的静态守卫,违规 SQL 在执行前抛 `MultiTableSQL`。持仓系统内部代码统一 Tushare 点式(`600000.SH`),读决策系统结论表时转前缀式。 + > 2026-07-28 修了守卫本身的一个误判:`ON DUPLICATE KEY UPDATE` 后面跟的是列名不是表名,原来会被当成第二张表,于是**所有 upsert 一执行就抛 `MultiTableSQL`**——页面改参数、`ensure_position`、日报落库、行业映射导入四条路径全中。单测走内存桩不经过该函数,所以一直没暴露。回归用例在 `test_batch6_units.py` 的 [G] 组。 - 配置分两层:基础设施连接串在 `.env`(服务器手工维护,不入库,模板见 `.env.example`);业务参数在 `config/settings.py` 只是初值,上线后经管理页面修改并持久化到 `pms_runtime_param` 表。**业务代码禁止直接读 settings 取业务参数**,一律走 `services/param_store.py`。 -- 新增纯逻辑一律进 `app/core/`(零外部依赖 + 配套单测);需要连库的编排进 `app/services/`,并保证连库失败时降级而非崩页。 + > 例外是**密钥**:`PMS_QMT_SIGN_SEED_HEX` / `PMS_QMT_PEER_PUBKEY_B64` 只走 `.env`(协议 §10.1.1),已列入 `param_store.SECRET_KEYS`——页面读不到、改不了、快照里也不出现。新增密钥类配置记得同步加进那个元组,否则它会被当成普通业务参数显示在参数设置页上。 +- 新增纯逻辑一律进 `app/core/`(零外部依赖 + 配套单测);需要连库的编排进 `app/services/`,并保证连库失败时降级而非崩页。ws 通道的协议编解码在 `app/core/ws_codec.py`,改动后**必须先跑通协议 §2.1.1 的测试向量**(`test_batch6_units.py` 的 [A] 组)——规范化串两边写不一致的话,联调只会告诉你"签名验不过",看不出差在哪一段。 - 里程碑(设计定稿、建表、各期上线)及时 git 提交。 +- **接手前先读三份**:本文件 → `POSITION_MGMT_DESIGN.md`(V0.4 定稿,**勿改设计**)→ `QMT_WS_PROTOCOL.md`(V1.0 定稿,下发通道的唯一依据)。三份读完即可开工,不需要额外的口头背景。 diff --git a/app/core/exec_timing.py b/app/core/exec_timing.py index 721d275..605b946 100644 --- a/app/core/exec_timing.py +++ b/app/core/exec_timing.py @@ -161,6 +161,36 @@ def _fmt(m: int) -> str: return f"{m // 60:02d}:{m % 60:02d}" +def add_trade_minutes(start_min: int, minutes: int) -> int: + """从 start_min 起推进 N 个「有效交易分钟」, 返回当日分钟数 (收盘封顶)。 + + 午休 11:30~13:00 不计入 —— 挂单在午休不撮合, 把这 90 分钟算进有效期 + 等于凭空把有效期砍掉一大截。11:25 下的单给 10 分钟, 应该活到 13:05, + 而不是 11:35 就被撤掉。 + """ + m = max(int(start_min), OPEN_MIN) + left = max(0, int(minutes)) + if LUNCH_START < m < LUNCH_END: # 起点落在午休里, 从下午开盘算 + m = LUNCH_END + if m <= LUNCH_START and m + left > LUNCH_START: + left -= (LUNCH_START - m) # 用掉上午剩余部分 + m = LUNCH_END + return min(m + left, CLOSE_MIN) + + +def slice_deadline(now, *, ttl_min: int = 10, forced: bool = False) -> int: + """单个下发分片的有效期截止 (当日分钟数)。下游到点未成交即自动撤单。 + + 普通分片只给 ttl_min 个交易分钟: run_tick 每分钟重评一次, 撤掉重下比挂着更好 + —— 限价是按下发那一刻的价算的, 挂久了价已经不是那个价, 还白占可用资金/持仓。 + 兜底单 (14:45 之后的 forced) 直接给到收盘: 那是「今天必须走掉」的单, 不能被 + TTL 撤回来。 + """ + if forced: + return CLOSE_MIN + return add_trade_minutes(hm_to_min(now), ttl_min) + + def window_verdict(*, remaining_qty: int, tdays_left: int, is_command: bool) -> dict: """窗口耗尽时的收口 (设计 §8 末句)。 diff --git a/app/core/ws_codec.py b/app/core/ws_codec.py new file mode 100644 index 0000000..0315beb --- /dev/null +++ b/app/core/ws_codec.py @@ -0,0 +1,391 @@ +# -*- coding: utf-8 -*- +""" +QMT WebSocket 协议编解码 (纯逻辑, 零 IO, 可单测) +================================================ +对应 `QMT_WS_PROTOCOL.md` V1.0 的 §2 (签名与幂等) 与 §3 (消息信封)。 + +本模块只回答一件事: **一条消息怎么变成字节、字节怎么变回一条消息**。 +不碰连接 (在 app/ws/runner.py)、不碰库 (在 app/repo/qmt_repo.py)、不碰业务 +(在 app/services/dispatcher.py)。 + +三处最容易两边写不一致的地方, 在这里钉死: + 1. payload 的紧凑 JSON —— 分隔符 "," ":" 无空格、键按 Unicode 码点升序、中文不转义。 + 2. 规范化串 —— 六段用**单个** \\n (0x0A) 连接, 串尾无换行; v/ts 以十进制整数字符串参与。 + 3. 价格 —— 上线前必须已经是 2 位小数, 且序列化结果得是 "12.35" 而不是 + "12.350000000000001"。协议 §8 规定 QMT 收到超精度价格直接 BAD_PARAM 且不替我们 + 四舍五入 —— 定价权在 PMS, 就得由 PMS 在出栈这一步收干净。 + +协议 §2.1.1 给了一组测试向量, `scripts/test_batch6_units.py` 逐字节核对上述四个中间 +结果 (payload_json / sha256 / canonical / sig)。**改动本模块后必须先跑通那一组**, 否则 +联调时会卡在"签名验不过"而看不出差在哪一段。 + +-------------------------------------------------------------------------------- +为什么 PMS **不**校验上行消息的时间窗 (这条容易踩, 单列出来) +-------------------------------------------------------------------------------- +§2.2 的「ts 偏差 > 30 秒即拒绝」是 **QMT 校验 PMS** 的规则。反过来 PMS 不能照抄: +§6.1 规定断线补发的消息与首次推送**完全一致** —— 同 seq、同 msg_id、同内容、**同签名**, +也就是补发时 ts 仍是原来那条的发送时刻。断网十分钟后重连, 补发的第一批消息 ts 已经差了 +十分钟, 若按时间窗校验会被整批丢弃, 补发机制当场失效, 而且失效得很安静。 + +PMS 侧防重放改靠两层: **seq 水位** (seq ≤ last_seq 一律丢弃, 见 §6.1) + **trade_no 去重** +(§5.5)。签名保证内容没被篡改, 序号保证同一条不会入账两次 —— 时间窗在这里是多余且有害的。 +""" +from __future__ import annotations + +import base64 +import hashlib +import json +import secrets +import time +from decimal import ROUND_HALF_UP, Decimal + +try: # cryptography 在 requirements.txt 里, 正常一定有 + from cryptography.exceptions import InvalidSignature + from cryptography.hazmat.primitives.asymmetric.ed25519 import (Ed25519PrivateKey, + Ed25519PublicKey) + CRYPTO_AVAILABLE = True +except ImportError: # 缺库时不让 import 就炸 —— 由调用方拿到明确报错 + CRYPTO_AVAILABLE = False + InvalidSignature = Exception # type: ignore + Ed25519PrivateKey = Ed25519PublicKey = None # type: ignore + +PROTOCOL_VERSION = 1 + +# ---- 消息类型 (§4 下行 / §5 上行) ---- +T_HELLO, T_PLACE, T_CANCEL = "hello", "place_order", "cancel_order" +T_Q_POS, T_Q_FUNDS, T_Q_ORDERS = "query_positions", "query_funds", "query_orders" +T_ACK_SEQ, T_PING = "ack_seq", "ping" +DOWNSTREAM_TYPES = (T_HELLO, T_PLACE, T_CANCEL, T_Q_POS, T_Q_FUNDS, T_Q_ORDERS, + T_ACK_SEQ, T_PING) + +T_HELLO_ACK, T_ACK, T_REJECT = "hello_ack", "ack", "reject" +T_ORDER_UPDATE, T_TRADE, T_PERSIST = "order_update", "trade", "persist_result" +T_SNAPSHOT, T_POS_UPDATE, T_FUNDS_UPDATE, T_PONG = ("snapshot", "position_update", + "funds_update", "pong") +UPSTREAM_TYPES = (T_HELLO_ACK, T_ACK, T_REJECT, T_ORDER_UPDATE, T_TRADE, T_PERSIST, + T_SNAPSHOT, T_POS_UPDATE, T_FUNDS_UPDATE, T_PONG) + +# 只有 trade 需要 worker 入账 (§5.5「账本以 trade 为唯一入账依据」), 其余上行消息 +# 由常驻进程自己消化完就落库存档。这个集合是 inbox 里 processed 初值的判据。 +NEEDS_LEDGER = (T_TRADE,) + +# 指令状态 (§7.1)。终态唯一、不可再变。 +ST_ACCEPTED, ST_SUBMITTED, ST_PARTIAL = "ACCEPTED", "SUBMITTED", "PARTIAL" +ST_FILLED, ST_CANCELLED, ST_EXPIRED, ST_REJECTED = ("FILLED", "CANCELLED", "EXPIRED", + "REJECTED") +FINAL_STATUSES = (ST_FILLED, ST_CANCELLED, ST_EXPIRED, ST_REJECTED) +PROTOCOL_STATUSES = (ST_ACCEPTED, ST_SUBMITTED, ST_PARTIAL) + FINAL_STATUSES + +# 拒绝码 (§7.3)。retryable=True 的换个价/等一等还能再来, False 的重发多少次都一样。 +REJECT_RETRYABLE = { + "SIG_INVALID": False, "TS_SKEW": True, "VERSION": False, "DUP_INSTRUCTION": False, + "BAD_PARAM": False, "UNKNOWN_CODE": False, "SUSPENDED": False, + "LIMIT_UP": True, "LIMIT_DOWN": True, "INSUFFICIENT_CASH": True, + "INSUFFICIENT_POSITION": True, "NOT_TRADING_TIME": True, "ALREADY_FINAL": False, + "BROKER_ERROR": True, "INTERNAL": True, +} + +INTENTS = ("OPEN", "FILL", "ADD", "DCA", "TRIM", "EXIT", "T0") + + +class CodecError(ValueError): + """编解码/校验失败。带 code 便于直接映射到协议 §7.3 的拒绝码。""" + + def __init__(self, code: str, message: str = ""): + super().__init__(f"{code}: {message}" if message else code) + self.code = code + self.message = message or code + + +# ================================================================ 序列化 +def payload_json(payload: dict) -> str: + """payload 的紧凑 JSON —— 签名对象的第 6 段就是它的 sha256。 + + 三个选项一个都不能改: sort_keys 按 Unicode 码点升序、separators 无空格、 + ensure_ascii=False 让中文以 UTF-8 原文出现 (对方 Java 侧是默认行为)。 + """ + return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def payload_sha256(payload: dict) -> str: + return hashlib.sha256(payload_json(payload).encode("utf-8")).hexdigest() + + +def canonical(*, v: int, type_: str, msg_id: str, ts: int, nonce: str, + payload_hash: str) -> str: + """规范化串 (§2.1)。六段, 单个换行连接, 串尾无换行。""" + return f"{int(v)}\n{type_}\n{msg_id}\n{int(ts)}\n{nonce}\n{payload_hash}" + + +def q2(x) -> float: + """价格收成 2 位小数 (四舍五入), 且保证 json 序列化出来是 "12.35" 这种短表示。 + + 择时模块算出来的限价常带一长串尾数 (现价 × 0.998 之类), 协议 §8 要求 2 位小数且 + QMT 收到超精度**直接 BAD_PARAM 不替我们舍入** —— 定价权在 PMS, 就得在出栈这一步收 + 干净。走 Decimal 而不是内置 round(), 是为了避开 round(2.675, 2) = 2.67 那类 + 银行家舍入 + 二进制表示的双重意外。 + """ + d = Decimal(str(x)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + return float(d) + + +def jsonable(v): + """把 Decimal / date / 自定义对象收敛成 JSON 原生类型 (payload 里不许出现别的)。""" + if isinstance(v, dict): + return {str(k): jsonable(x) for k, x in v.items()} + if isinstance(v, (list, tuple)): + return [jsonable(x) for x in v] + if isinstance(v, bool) or v is None or isinstance(v, (int, str)): + return v + if isinstance(v, Decimal): + return float(v) + if isinstance(v, float): + return v + return str(v) + + +# ================================================================ 签名 +def _seed_bytes(seed_hex: str) -> bytes: + s = (seed_hex or "").strip().lower().replace(" ", "") + if len(s) != 64: + raise CodecError("SIG_INVALID", f"私钥 seed 应为 32 字节 hex (64 字符), 收到 {len(s)} 字符") + try: + return bytes.fromhex(s) + except ValueError as e: + raise CodecError("SIG_INVALID", f"私钥 seed 非法 hex: {e}") from e + + +def sign(canon: str, seed_hex: str) -> str: + if not CRYPTO_AVAILABLE: + raise CodecError("INTERNAL", "缺少 cryptography 依赖, 无法签名 (requirements.txt)") + sk = Ed25519PrivateKey.from_private_bytes(_seed_bytes(seed_hex)) + return base64.b64encode(sk.sign(canon.encode("utf-8"))).decode() + + +def public_key_b64(seed_hex: str) -> str: + """由私钥 seed 推出公钥 —— 部署时把它带外送给 QMT 侧 (§10.1.1 第 2 条)。""" + if not CRYPTO_AVAILABLE: + raise CodecError("INTERNAL", "缺少 cryptography 依赖") + sk = Ed25519PrivateKey.from_private_bytes(_seed_bytes(seed_hex)) + return base64.b64encode(sk.public_key().public_bytes_raw()).decode() + + +def verify(canon: str, sig_b64: str, pubkey_b64: str) -> bool: + """验签。任何异常 (公钥非法/签名非法/长度不对/base64 烂) 一律 False —— 验不过就是验不过, + 不区分「为什么验不过」, 免得给探测方留下侧信道。""" + if not CRYPTO_AVAILABLE: + raise CodecError("INTERNAL", "缺少 cryptography 依赖, 无法验签") + try: + pk = Ed25519PublicKey.from_public_bytes(base64.b64decode(pubkey_b64)) + pk.verify(base64.b64decode(sig_b64), canon.encode("utf-8")) + return True + except Exception: # noqa: BLE001 —— 见上, 故意不细分 + return False + + +# ================================================================ 标识符 +def new_nonce() -> str: + """每条消息随机 16 字节 hex (§2.2)。仅参与签名, 对端不必存。""" + return secrets.token_hex(16) + + +def new_msg_id(ymd: int, seq: int) -> str: + """单条消息唯一, 仅供日志追踪 —— **不是**业务幂等键 (§3)。""" + return f"m-{int(ymd)}-{int(seq) % 1000000:06d}" + + +def new_instruction_id(ymd: int) -> str: + """幂等键 (§2.2)。格式 INS--<8位随机>, 长度 21 ≤ 64。""" + return f"INS-{int(ymd)}-{secrets.token_hex(4)}" + + +def new_cancel_id(ymd: int) -> str: + return f"CXL-{int(ymd)}-{secrets.token_hex(4)}" + + +def now_ms(clock=None) -> int: + """epoch 毫秒 (§1: 时间字段一律整数毫秒)。clock 可注入, 便于单测。""" + return int(clock) if clock is not None else int(time.time() * 1000) + + +# ================================================================ 组包 / 拆包 +def build(type_: str, payload: dict, *, seed_hex: str, msg_id: str, ts: int = None, + nonce: str = None, corr_id: str = None, seq: int = None) -> dict: + """组一条下行消息 (含签名)。返回 dict, 由调用方 json.dumps 后 send。 + + seq 只在上行必填 (§3), 下行留 None 即不带该字段 —— 别为了"对称"给下行编号, + 那会让对方的补发逻辑多一个歧义来源。 + """ + body = jsonable(payload or {}) + ts = now_ms() if ts is None else int(ts) + nonce = nonce or new_nonce() + canon = canonical(v=PROTOCOL_VERSION, type_=type_, msg_id=msg_id, ts=ts, nonce=nonce, + payload_hash=payload_sha256(body)) + env = {"v": PROTOCOL_VERSION, "type": type_, "msg_id": msg_id, "ts": ts, + "nonce": nonce, "payload": body, "sig": sign(canon, seed_hex)} + if corr_id: + env["corr_id"] = corr_id + if seq is not None: + env["seq"] = int(seq) + return env + + +def dumps(env: dict) -> str: + """整条消息落到线上的字节。只有 payload 需要规范化, 信封本身怎么排都行 —— + 但仍用同一套紧凑参数, 免得日志里两种风格混着看。""" + return json.dumps(env, separators=(",", ":"), ensure_ascii=False) + + +def parse(raw, *, peer_pubkey_b64: str) -> dict: + """拆一条上行消息并验签。验不过抛 CodecError, **调用方直接丢弃且不做任何业务动作** + (§2.1: 验签失败 → 丢弃并回 reject{SIG_INVALID})。 + + 这里不做时间窗校验 —— 原因见模块头部那段。 + """ + if isinstance(raw, (bytes, bytearray)): + try: + raw = raw.decode("utf-8") + except UnicodeDecodeError as e: + raise CodecError("BAD_PARAM", f"非 UTF-8 帧: {e}") from e + try: + env = json.loads(raw) + except (ValueError, TypeError) as e: + raise CodecError("BAD_PARAM", f"非法 JSON: {e}") from e + if not isinstance(env, dict): + raise CodecError("BAD_PARAM", "消息顶层不是对象") + + v = env.get("v") + if v != PROTOCOL_VERSION: + raise CodecError("VERSION", f"不支持的协议版本 {v!r} (本端 {PROTOCOL_VERSION})") + type_ = env.get("type") + if not type_ or not isinstance(type_, str): + raise CodecError("BAD_PARAM", "缺少 type") + for k in ("msg_id", "ts", "nonce", "sig"): + if env.get(k) in (None, ""): + raise CodecError("BAD_PARAM", f"缺少信封字段 {k}") + payload = env.get("payload") + if payload is None: + payload = {} + if not isinstance(payload, dict): + raise CodecError("BAD_PARAM", "payload 不是对象") + + canon = canonical(v=v, type_=type_, msg_id=str(env["msg_id"]), ts=int(env["ts"]), + nonce=str(env["nonce"]), payload_hash=payload_sha256(payload)) + if not peer_pubkey_b64: + raise CodecError("SIG_INVALID", "未配置对端公钥 (PMS_QMT_PEER_PUBKEY_B64), 无法验签") + if not verify(canon, str(env["sig"]), peer_pubkey_b64): + raise CodecError("SIG_INVALID", f"上行消息验签失败 type={type_} msg_id={env['msg_id']}") + + env["payload"] = payload + if env.get("seq") is not None: + env["seq"] = int(env["seq"]) + return env + + +# ================================================================ 各类下行 payload +def place_order_payload(*, instruction_id: str, ts_code: str, side: str, qty: int, + limit_price, valid_until: int, intent: str = "OPEN", + note: str = "") -> dict: + """§4.2。落地前把易错项一次性校验干净 —— 宁可在本地报错, 不要换 QMT 一个 BAD_PARAM。""" + side = str(side or "").lower() + if side not in ("buy", "sell"): + raise CodecError("BAD_PARAM", f"side 只能是 buy/sell, 收到 {side!r}") + qty = int(qty or 0) + if qty <= 0: + raise CodecError("BAD_PARAM", f"qty 必须为正整数股, 收到 {qty}") + # 整百规则 (§8): 买入必整百; 卖出通常整百, 清仓允许零股尾数 —— 故只拦买入。 + if side == "buy" and qty % 100: + raise CodecError("BAD_PARAM", f"买入数量必须整百, 收到 {qty}") + if limit_price in (None, ""): + raise CodecError("BAD_PARAM", "limit_price 必填 (协议不接受 null, 定价权在 PMS)") + px = q2(limit_price) + if px <= 0: + raise CodecError("BAD_PARAM", f"limit_price 必须为正, 收到 {limit_price!r}") + if not str(ts_code or "").strip(): + raise CodecError("BAD_PARAM", "缺少 ts_code") + if "." not in str(ts_code): + raise CodecError("BAD_PARAM", f"ts_code 必须是点式 (600000.SH), 收到 {ts_code!r}") + intent = str(intent or "OPEN").upper() + if intent not in INTENTS: + intent = "OPEN" + if len(str(instruction_id or "")) > 64 or not instruction_id: + raise CodecError("BAD_PARAM", f"instruction_id 长度须在 1~64, 收到 {instruction_id!r}") + return {"instruction_id": str(instruction_id), "ts_code": str(ts_code), "side": side, + "qty": qty, "limit_price": px, "valid_until": int(valid_until), + "intent": intent, "note": str(note or "")[:200]} + + +def cancel_order_payload(*, cancel_id: str, instruction_id: str) -> dict: + return {"cancel_id": str(cancel_id), "instruction_id": str(instruction_id)} + + +def hello_payload(last_seq: int) -> dict: + """§4.1。首次连接或本地无记录传 0。""" + return {"client": "pms", "last_seq": int(last_seq or 0), "protocol": PROTOCOL_VERSION} + + +def ack_seq_payload(seq: int) -> dict: + return {"seq": int(seq)} + + +# ================================================================ 上行 payload 读取 +def dedup_key(type_: str, payload: dict): + """第二层去重键 (§5.5)。目前只有 trade 有 —— trade_no 唯一, 命中即丢弃。 + + 第一层是 seq (补发时同一条消息 seq 不变)。两层都设是为了防住 seq 实现出 bug 的场景。 + """ + if type_ == T_TRADE: + tn = (payload or {}).get("trade_no") + return f"trade:{tn}" if tn else None + return None + + +def trade_amount_ok(payload: dict, tol: float = 0.01) -> bool: + """§5.5: amount 应等于 price × qty, 差异 > 0.01 元告警。""" + try: + px, qty, amt = float(payload["price"]), int(payload["qty"]), float(payload["amount"]) + except (KeyError, TypeError, ValueError): + return False + return abs(px * qty - amt) <= tol + + +def is_final(status: str) -> bool: + return str(status or "").upper() in FINAL_STATUSES + + +def cold_start_baseline(last_seq: int, first_seq: int) -> tuple: + """对端序号不从 1 开始时, 本端水位该从哪里起算。返回 (基线, 是否真缺口)。 + + **这是本协议最安静的一种死法, 值得多写几行。** QMT 的 seq 是跨重启、跨交易日都不回退 + 的全局计数器 (§6.1), 我们接上去的时候它可能早就跑到几万了。而 PMS 首次连接按 §4.1 传 + `last_seq=0` —— 若照着「水位必须连续」的规矩死等 seq 1、2、3…, 水位就永远推不动: + ack_seq 发不出去 → 对端的消息永远清理不掉 → 重连时又从头补发一遍。整个过程**不抛任何 + 异常**, 日志上只有一行「上行乱序」在刷屏, 而成交其实一条都没确认。 + + 分两种情形: + last_seq == 0 冷启动。本端从没收过任何消息, 谈不上"丢" —— 以 first_seq-1 为起点。 + 这不是缺口, 这是起点。 + last_seq > 0 真缺口, 中间那段再也拿不到了。基线同样要推上去 (卡死只会更糟), + 但必须置 resync 标记走全量对账 —— §6.2。 + """ + if int(first_seq) <= int(last_seq) + 1: + return int(last_seq), False + return int(first_seq) - 1, int(last_seq) > 0 + + +def next_watermark(last_seq: int, arrived: set, seq: int) -> tuple: + """收到 seq 后推进连续水位。返回 (新水位, 新的乱序暂存集合)。 + + 水位必须是**连续前缀**的末位: 确认 10450 隐含确认之前所有 (§4.5 累积确认语义), + 中间缺一条就不能往前跨 —— 跨过去那条就永久丢了, 而且丢得毫无痕迹。 + 正常情况下 QMT 按序补发, 这个集合始终是空的; 它存在是为了「万一乱序」不出错。 + """ + arrived = set(arrived or ()) + seq = int(seq) + if seq <= last_seq: + return last_seq, arrived # 已确认过, 重复帧 + arrived.add(seq) + while (last_seq + 1) in arrived: + last_seq += 1 + arrived.discard(last_seq) + return last_seq, arrived diff --git a/app/db/session.py b/app/db/session.py index 9b28943..05e4490 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -57,12 +57,20 @@ _JOIN_RE = re.compile(r"\bjoin\b", re.I) _FROM_CLAUSE_RE = re.compile( r"\bfrom\s+(.+?)(?=\bwhere\b|\bgroup\b|\border\b|\blimit\b|\bhaving\b|\bunion\b|\bon\b|\)|;|$)", re.I | re.S) +# `ON DUPLICATE KEY UPDATE` 里的 UPDATE 后面跟的是**列名**不是表名。不先摘掉这一段, +# 下面的 _TABLE_RE 会把第一个被赋值的列当成第二张表, 于是所有 upsert 一律误判成多表。 +# 2026-07-28 修: 四处 upsert 全部命中该误判 —— set_param (页面改参数)、ensure_position +# (建仓落账本第一步)、upsert_report (日报落库)、upsert_industry (行业映射导入), +# 一执行就抛 MultiTableSQL。单测走内存桩不经过本函数, 所以一直没暴露。 +# 见 scripts/test_batch6_units.py 的「单表守卫」一组。 +_ODKU_RE = re.compile(r"\bon\s+duplicate\s+key\s+update\b", re.I) def assert_single_table(sql: str) -> str: """静态检查: 一条 SQL 只能碰一张表, 且不得出现 JOIN / 逗号连表 / 跨表子查询。""" s = re.sub(r"--[^\n]*", " ", str(sql)) s = re.sub(r"/\*.*?\*/", " ", s, flags=re.S) + s = _ODKU_RE.sub(" ", s) # 见上: 摘掉 upsert 尾巴, 它不引入新表 if _JOIN_RE.search(s): raise MultiTableSQL(f"严格单表访问: SQL 含 JOIN —— {s[:120]}") for clause in _FROM_CLAUSE_RE.findall(s): diff --git a/app/repo/qmt_repo.py b/app/repo/qmt_repo.py new file mode 100644 index 0000000..d438996 --- /dev/null +++ b/app/repo/qmt_repo.py @@ -0,0 +1,387 @@ +# -*- coding: utf-8 -*- +""" +ws 直连通道三表的数据访问 (pms_qmt_order / pms_qmt_inbox / pms_ws_state) +======================================================================= +与 pms_repo 同纪律: 每个函数只碰一张表, SQL 全部经 db.session 的单表守卫, +可更新列走白名单。表结构与"为什么是三张表"见 ddl_pms_v1.sql 尾部。 + +三张表分别对应协议里的三件事: + pms_qmt_order §4.2 place_order / §4.3 cancel_order 的出口队列 + 委托状态跟踪 + pms_qmt_inbox §4.5「确认前必须已持久化」的那个"库" + §6.1 双层去重 + pms_ws_state §6.1 seq 水位 (跨重启不回退) + 常驻进程存活心跳 +""" +from __future__ import annotations + +import json +from datetime import datetime + +from app.db.session import execute, fetch_all, fetch_one + +_NOW = lambda: datetime.now() # noqa: E731 (容器时区 Asia/Shanghai) + +STATE_ID = 1 # pms_ws_state 恒一行 + +# 本地出口状态 (还没进协议状态机) +OS_QUEUED, OS_SENDING, OS_SENT = "QUEUED", "SENDING", "SENT" +OS_SEND_FAILED, OS_ABORTED = "SEND_FAILED", "ABORTED" +# 协议状态 (§7.1) +OS_ACCEPTED, OS_SUBMITTED, OS_PARTIAL = "ACCEPTED", "SUBMITTED", "PARTIAL" +OS_FILLED, OS_CANCELLED, OS_EXPIRED, OS_REJECTED = ("FILLED", "CANCELLED", "EXPIRED", + "REJECTED") +FINAL = (OS_FILLED, OS_CANCELLED, OS_EXPIRED, OS_REJECTED, OS_SEND_FAILED, OS_ABORTED) +# 「在途」= 还可能成交或还能撤的。撤单只对这些有意义。 +LIVE = (OS_QUEUED, OS_SENDING, OS_SENT, OS_ACCEPTED, OS_SUBMITTED, OS_PARTIAL) + +CANCEL_NONE, CANCEL_REQUESTED, CANCEL_SENT = "NONE", "REQUESTED", "SENT" + +ORDER_COLS = { + "status", "broker_order_id", "cum_qty", "cum_avg_price", "leaves_qty", "cancel_state", + "cancel_id", "cancel_req_at", "reject_code", "reject_reason", "send_attempts", + "sent_at", "final_at", "note", +} + + +def _dumps(v): + return json.dumps(v, ensure_ascii=False) if not isinstance(v, (str, type(None))) else v + + +def _loads(v, default=None): + if v in (None, ""): + return default + if isinstance(v, (dict, list)): + return v + try: + return json.loads(v) + except (ValueError, TypeError): + return default + + +def _in_clause(values, prefix: str, params: dict) -> str: + keys = [] + for i, v in enumerate(values): + keys.append(f":{prefix}{i}") + params[f"{prefix}{i}"] = v + return ", ".join(keys) + + +# ================================================================ pms_ws_state +def get_state() -> dict: + r = fetch_one("SELECT * FROM pms_ws_state WHERE id = :i", {"i": STATE_ID}) + if not r: + return {"id": STATE_ID, "last_seq": 0, "acked_seq": 0, "server_seq": 0, + "conn_state": "INIT", "heartbeat_at": None, "resync_flag": 0, + "connected_at": None, "last_error": None, "stat": {}} + d = dict(r) + d["stat"] = _loads(d.pop("stat_json", None), {}) + return d + + +def ensure_state() -> int: + """建表脚本已插过一行; 这里兜底 (库是别人手工建的/被清过 也不至于全线报错)。""" + return execute( + "INSERT INTO pms_ws_state (id, last_seq, acked_seq, server_seq, conn_state, " + "updated_at) VALUES (:i, 0, 0, 0, 'INIT', :ts) " + "ON DUPLICATE KEY UPDATE updated_at = :ts", {"i": STATE_ID, "ts": _NOW()}) + + +def save_watermark(last_seq: int, acked_seq=None) -> int: + """水位只进不退 —— GREATEST 兜住并发/乱序写回, 回退一格就意味着重复入账。""" + sets = ["last_seq = GREATEST(last_seq, :ls)"] + p = {"ls": int(last_seq), "i": STATE_ID, "ts": _NOW()} + if acked_seq is not None: + sets.append("acked_seq = GREATEST(acked_seq, :as_)") + p["as_"] = int(acked_seq) + return execute(f"UPDATE pms_ws_state SET {', '.join(sets)}, updated_at = :ts " + f"WHERE id = :i", p) + + +def set_conn(conn_state: str, *, connected_at=None, last_error=None, server_seq=None, + resync=None, stat=None, beat: bool = True) -> int: + sets, p = ["conn_state = :cs"], {"cs": conn_state, "i": STATE_ID, "ts": _NOW()} + if beat: + sets.append("heartbeat_at = :ts") + if connected_at is not None: + sets.append("connected_at = :ca") + p["ca"] = connected_at + if last_error is not None: + sets.append("last_error = :le") + p["le"] = str(last_error)[:300] + if server_seq is not None: + sets.append("server_seq = :ss") + p["ss"] = int(server_seq) + if resync is not None: + sets.append("resync_flag = :rf") + p["rf"] = 1 if resync else 0 + if stat is not None: + sets.append("stat_json = :sj") + p["sj"] = _dumps(stat) + return execute(f"UPDATE pms_ws_state SET {', '.join(sets)}, updated_at = :ts " + f"WHERE id = :i", p) + + +def mark_stopped(note: str = "") -> int: + """优雅退出: 置 STOPPED 并**清空心跳**。 + + 清心跳是关键一步 —— 不清的话, 停机后的 stale 窗口 (默认 15 秒) 里 dispatcher 仍 + 认为进程活着, 卖出指令还会继续往队列里排, 而已经没人会发它们了。 + """ + return execute("UPDATE pms_ws_state SET conn_state = 'STOPPED', heartbeat_at = NULL, " + "last_error = :le, updated_at = :ts WHERE id = :i", + {"le": str(note)[:300], "i": STATE_ID, "ts": _NOW()}) + + +def beat(stat=None) -> int: + """常驻进程存活心跳。dispatcher 看这个时间戳判断「ws 进程还在不在」—— + 连接断了是一回事 (还能重连), 进程没了是另一回事 (队列永远发不出去)。""" + sets, p = ["heartbeat_at = :ts"], {"i": STATE_ID, "ts": _NOW()} + if stat is not None: + sets.append("stat_json = :sj") + p["sj"] = _dumps(stat) + return execute(f"UPDATE pms_ws_state SET {', '.join(sets)}, updated_at = :ts " + f"WHERE id = :i", p) + + +# ================================================================ pms_qmt_inbox +PUT_NEW, PUT_DUP_SEQ, PUT_DUP_KEY = "NEW", "DUP_SEQ", "DUP_KEY" + + +def inbox_put(*, seq: int, msg_id: str, msg_type: str, payload: dict, msg_ts: int, + corr_id=None, dedup_key=None, processed: int = 2, note=None) -> str: + """落一条上行消息。返回 NEW / DUP_SEQ / DUP_KEY。 + + 双层去重 (§6.1 + §5.5) 在这里靠两个唯一键实现: 主键 seq 是第一层, 唯一索引 + dedup_key (trade_no) 是第二层。 + + **DUP_KEY 这条分支容易漏, 单独说明**: trade_no 撞了但 seq 是新的 —— 说明 QMT 用 + 新序号重推了一条我们已入过账的成交。这一笔不能再入账, 但**这个 seq 仍必须占住一行**, + 否则连续水位永远卡在它前面, ack_seq 再也推不动, QMT 那边的消息也就永远清理不掉。 + 所以这里补插一行 dedup_key=NULL、processed=2 的存档行。 + """ + now = _NOW() + p = {"s": int(seq), "mi": str(msg_id)[:64], "mt": str(msg_type)[:24], + "ci": (str(corr_id)[:64] if corr_id else None), + "dk": (str(dedup_key)[:80] if dedup_key else None), + "pj": _dumps(payload or {}), "mts": int(msg_ts or 0), "ts": now, + "pc": int(processed), "nt": (str(note)[:300] if note else None), + "pa": now if int(processed) != 0 else None} + sql = ("INSERT INTO pms_qmt_inbox (seq, msg_id, msg_type, corr_id, dedup_key, " + "payload_json, msg_ts, received_at, processed, processed_at, process_note) " + "VALUES (:s, :mi, :mt, :ci, :dk, :pj, :mts, :ts, :pc, :pa, :nt) " + "ON DUPLICATE KEY UPDATE seq = seq") + if execute(sql, p): + return PUT_NEW + if fetch_one("SELECT seq FROM pms_qmt_inbox WHERE seq = :s", {"s": int(seq)}): + return PUT_DUP_SEQ + # 见 docstring: trade_no 重复但 seq 是新的 —— 占位存档, 让水位能继续往前推 + p["dk"] = None + p["pc"] = 2 + p["pa"] = now + p["nt"] = f"重复成交 (dedup_key={dedup_key}), 不入账, 仅占位以推进 seq 水位" + execute(sql, p) + return PUT_DUP_KEY + + +def inbox_recover_watermark(stored_last_seq: int, limit: int = 20000) -> int: + """用 inbox 重算真正的连续水位。 + + last_seq 落库是按批次刷的 (每 20 条 / 2 秒), 崩溃时可能落后于实际已落库的消息。 + inbox 行才是事实, 这里从 stored 往后走连续段 —— 少 ack 一点只是让 QMT 多留一会儿, + 多 ack 一点会让数据永久丢失, 所以宁可从保守值往前推。 + """ + cur = int(stored_last_seq or 0) + rows = fetch_all("SELECT seq FROM pms_qmt_inbox WHERE seq > :n ORDER BY seq ASC LIMIT :m", + {"n": cur, "m": int(limit)}) + for r in rows: + if int(r["seq"]) == cur + 1: + cur += 1 + else: + break + return cur + + +def inbox_pending(limit: int = 500) -> list: + rows = fetch_all("SELECT * FROM pms_qmt_inbox WHERE processed = 0 ORDER BY seq ASC " + "LIMIT :n", {"n": int(limit)}) + for r in rows: + r["payload"] = _loads(r.get("payload_json"), {}) + return rows + + +def inbox_mark(seqs: list, processed: int = 1, note=None) -> int: + if not seqs: + return 0 + p = {"pc": int(processed), "ts": _NOW(), "nt": (str(note)[:300] if note else None)} + return execute(f"UPDATE pms_qmt_inbox SET processed = :pc, processed_at = :ts, " + f"process_note = :nt WHERE seq IN ({_in_clause(seqs, 's', p)})", p) + + +def inbox_list(*, msg_type=None, corr_id=None, limit: int = 200) -> list: + where, p = [], {"n": int(limit)} + if msg_type: + where.append("msg_type = :mt") + p["mt"] = msg_type + if corr_id: + where.append("corr_id = :ci") + p["ci"] = corr_id + sql = "SELECT * FROM pms_qmt_inbox" + if where: + sql += " WHERE " + " AND ".join(where) + sql += " ORDER BY seq DESC LIMIT :n" + rows = fetch_all(sql, p) + for r in rows: + r["payload"] = _loads(r.get("payload_json"), {}) + return rows + + +def inbox_pending_count() -> int: + r = fetch_one("SELECT COUNT(*) AS n FROM pms_qmt_inbox WHERE processed = 0") + return int((r or {}).get("n") or 0) + + +# ================================================================ pms_qmt_order +def enqueue_order(*, instruction_id, parent_id, ts_code, side, qty, limit_price, + valid_until, intent="OPEN", note=None) -> int: + """把一张待发委托落进出口队列 —— 这一步就是「先记账」, ws 进程随后才「后动作」。""" + now = _NOW() + return execute( + "INSERT INTO pms_qmt_order (instruction_id, parent_id, ts_code, side, qty, " + "limit_price, valid_until, intent, note, status, cancel_state, cum_qty, " + "send_attempts, created_at, updated_at) VALUES (:iid, :pid, :code, :side, :qty, " + ":px, :vu, :it, :nt, 'QUEUED', 'NONE', 0, 0, :ts, :ts)", + {"iid": instruction_id, "pid": parent_id, "code": ts_code, "side": side, + "qty": int(qty), "px": float(limit_price), "vu": int(valid_until), + "it": intent, "nt": (str(note)[:200] if note else None), "ts": now}) + + +def get_order(instruction_id: str): + return fetch_one("SELECT * FROM pms_qmt_order WHERE instruction_id = :iid", + {"iid": instruction_id}) + + +def list_orders(*, statuses=None, parent_id=None, ts_code=None, limit: int = 200) -> list: + where, p = [], {"n": int(limit)} + if statuses: + where.append(f"status IN ({_in_clause(list(statuses), 'st', p)})") + if parent_id: + where.append("parent_id = :pid") + p["pid"] = parent_id + if ts_code: + where.append("ts_code = :code") + p["code"] = ts_code + sql = "SELECT * FROM pms_qmt_order" + if where: + sql += " WHERE " + " AND ".join(where) + sql += " ORDER BY id DESC LIMIT :n" + return fetch_all(sql, p) + + +def next_queued(limit: int = 20, side=None) -> list: + """取待发委托。按 id 升序 = 先进先发, 保证同一只票的分笔不乱序 (§1「不开第二条连接」 + 是为了避免乱序, 出口这一端也得守住)。""" + p = {"n": int(limit)} + sql = "SELECT * FROM pms_qmt_order WHERE status = 'QUEUED'" + if side: + sql += " AND side = :side" + p["side"] = side + sql += " ORDER BY id ASC LIMIT :n" + return fetch_all(sql, p) + + +def claim_order(instruction_id: str) -> bool: + """QUEUED → SENDING 的原子认领。返回 False 说明被别人抢走了/状态已变, **不要再发**。 + + 正常只有一个 ws 进程, 但滚动重启会有两个进程短暂并存 —— 那一瞬间靠这条 CAS 兜住, + 而不是靠"我们约定只起一个"。 + """ + n = execute("UPDATE pms_qmt_order SET status = 'SENDING', updated_at = :ts " + "WHERE instruction_id = :iid AND status = 'QUEUED'", + {"iid": instruction_id, "ts": _NOW()}) + return bool(n) + + +def mark_sent(instruction_id: str) -> int: + return execute("UPDATE pms_qmt_order SET status = 'SENT', sent_at = :ts, " + "send_attempts = send_attempts + 1, updated_at = :ts " + "WHERE instruction_id = :iid", {"iid": instruction_id, "ts": _NOW()}) + + +def requeue_order(instruction_id: str, error: str = "", max_attempts: int = 3, + count_attempt: bool = True) -> int: + """发送失败退回队列; 试满次数置 SEND_FAILED 等人工 —— 不无限重试 (设计 §13 + 「指令下发失败/超时 → 不自动重发」的折中: 网络抖动允许有限重试, 但不能一直撞)。 + + count_attempt=False 用于**连接断了**的场景: 那是通道的问题不是这张单的问题, 不该 + 记在它头上, 否则一次几秒的抖动就能把待发单全烧成 SEND_FAILED。 + + 注: MySQL 的 UPDATE ... SET 按书写顺序求值且后项可见前项新值 —— 故 status 必须写在 + send_attempts **之前** (用旧值 + inc 判断), 顺序不可调换。同 pms_repo.close_lot_qty。 + """ + return execute( + "UPDATE pms_qmt_order SET " + "status = CASE WHEN send_attempts + :inc >= :mx THEN 'SEND_FAILED' " + " ELSE 'QUEUED' END, " + "send_attempts = send_attempts + :inc, " + "reject_reason = :err, updated_at = :ts WHERE instruction_id = :iid", + {"iid": instruction_id, "mx": int(max_attempts), "err": str(error)[:300], + "inc": 1 if count_attempt else 0, "ts": _NOW()}) + + +def reset_stuck_sending() -> int: + """进程启动时把 SENDING 退回 QUEUED。 + + SENDING 意味着"认领了但没记到 SENT" —— 可能已经发出去了, 也可能没有。重发是安全的: + 协议 §2.2 规定重复 instruction_id 不会二次下单, 只回 ack{duplicate:true} 带当前状态。 + 这正是幂等键存在的意义, 该用就用, 别为了"怕重复"把单子丢在半路。 + """ + return execute("UPDATE pms_qmt_order SET status = 'QUEUED', updated_at = :ts " + "WHERE status = 'SENDING'", {"ts": _NOW()}) + + +def abort_order(instruction_id: str, note: str) -> int: + """未发出即本地作废 (典型: 排队期间 valid_until 已过, 发出去也只会立刻 EXPIRED)。""" + now = _NOW() + return execute("UPDATE pms_qmt_order SET status = 'ABORTED', reject_code = 'LOCAL_ABORT', " + "reject_reason = :nt, final_at = :ts, updated_at = :ts " + "WHERE instruction_id = :iid", + {"iid": instruction_id, "nt": str(note)[:300], "ts": now}) + + +def update_order(instruction_id: str, **fields) -> int: + cols = [c for c in fields if c in ORDER_COLS] + if not cols: + return 0 + p = {c: fields[c] for c in cols} + p.update({"iid": instruction_id, "ts": _NOW()}) + clause = ", ".join(f"{c} = :{c}" for c in cols) + return execute(f"UPDATE pms_qmt_order SET {clause}, updated_at = :ts " + f"WHERE instruction_id = :iid", p) + + +def request_cancel(*, parent_id: str, cancel_id: str) -> int: + """把某父指令名下所有在途子单标为待撤。ws 进程扫到后发 cancel_order。 + + cancel_state 同时是**区分 CANCELLED 与 EXPIRED 的本地依据** (协议 §7.2): 下游落库层 + 两者都写 cancelled, 但 PMS 自己知道有没有发过撤单。 + """ + p = {"pid": parent_id, "cid": cancel_id, "ts": _NOW()} + return execute( + f"UPDATE pms_qmt_order SET cancel_state = 'REQUESTED', cancel_id = :cid, " + f"cancel_req_at = :ts, updated_at = :ts WHERE parent_id = :pid " + f"AND cancel_state = 'NONE' AND status IN ({_in_clause(list(LIVE), 'lv', p)})", p) + + +def next_cancel_requests(limit: int = 20) -> list: + p = {"n": int(limit)} + return fetch_all( + f"SELECT * FROM pms_qmt_order WHERE cancel_state = 'REQUESTED' " + f"AND status IN ({_in_clause(list(LIVE), 'lv', p)}) ORDER BY id ASC LIMIT :n", p) + + +def mark_cancel_sent(instruction_id: str) -> int: + return execute("UPDATE pms_qmt_order SET cancel_state = 'SENT', updated_at = :ts " + "WHERE instruction_id = :iid", {"iid": instruction_id, "ts": _NOW()}) + + +def queue_depth() -> dict: + rows = fetch_all("SELECT status, COUNT(*) AS n FROM pms_qmt_order GROUP BY status") + return {r["status"]: int(r["n"]) for r in rows} diff --git a/app/services/dispatcher.py b/app/services/dispatcher.py index cdf26c1..1a87267 100644 --- a/app/services/dispatcher.py +++ b/app/services/dispatcher.py @@ -2,32 +2,64 @@ """ 指令下发通道 (设计 §9 权限移交的落点) ====================================== -三个适配器, 由参数 `PMS_DISPATCH_MODE` 切换, **默认 shadow**: +两个适配器, 由参数 `PMS_DISPATCH_MODE` 切换, **默认 shadow**: - shadow 影子运行 —— 只记账不下发。指令照常过规则闸、照常置 DISPATCHED, - 等用户人工在 QMT 侧执行, 成交由回放按 FIFO 认领回来。 - 这是设计 §9 的一期口径:「通道未通前由用户人工执行、PMS 记账跟踪」。 - plan_x 过渡兼容 —— 买入沿用 `trading_buy_plan` (置 is_active=6 待挂单, - 署名 approved_by='pms')。**卖出无对应通道**, 自动退回 shadow。 - 列清单按下游现表推断, QMT 侧确认前请勿在实盘开启。 - channel_y 推荐方案 —— 写统一指令表 `pms_order_request` (DDL 见需求清单 B1)。 - 该表归属与形态仍在协商 (B1.1/B1.7), 表未建时会明确报错而非静默吞掉。 + shadow 影子运行 —— 只记账不下发。指令照常过规则闸、照常置 DISPATCHED, + 等用户人工在 QMT 侧执行, 成交由回放按 FIFO 认领回来。 + 这是设计 §9 的一期口径:「通道未通前由用户人工执行、PMS 记账跟踪」。 + ws WebSocket 长连接直连 QMT 执行服务, 协议见 QMT_WS_PROTOCOL.md V1.0。 -无论哪种模式, **指令先落 pms_instruction 再下发** (先记账后动作), 本模块只负责 -「往下游递一手」, 不改指令状态 —— 状态由 executor 统一推进。 +ws 模式的进程边界 (这一段是理解本模块的关键) +-------------------------------------------- +ws 是**一条常驻长连接**, 而 executor 跑在 celery worker 这种短命任务进程里, 且协议 §1 +明确「PMS 不开第二条连接」(避免指令乱序)。所以连接由独立的 `pms-ws` 进程持有 +(app/ws/runner.py), 本模块**不碰 socket** —— `_ws()` 只做一件事: + + 把这张委托写进 pms_qmt_order 置 QUEUED, 然后就返回。 + +出口队列落在业务表上而不是内存或 Redis, 是因为「先记账后动作」这条铁律在这里可以字面 +成立: **落表就是记账**。ws 进程崩了重启队列还在; 页面查 pms_qmt_order 就能看到在途委托; +不引入任何新中间件。代价是亚秒级的轮询延迟 —— 择时本来就是分钟级节奏, 无感。 + +放不放行的判断 (对应协议 §6.3「故障即守成」) +------------------------------------------- + ws 进程心跳陈旧 (进程没了) → 一律拒发。队列里的单子谁也发不出去, 排进去只是假装干活。 + 进程在、连接断 (非 ONLINE) → **买入拒发, 卖出照常入队**, 重连后立即发出。 + 这是既有口径「冻结与刹车只挡增持不挡减持」的延续。 + 进程在、连接通 → 放行。 + +无论哪种模式, 本模块只负责「往下游递一手」, 不改父指令状态 —— 状态由 executor 统一推进。 + +历史包袱清理 (2026-07-28) +------------------------- +原有 plan_x / channel_y 两个适配器已删除, 原因: + + plan_x 买入写 trading_buy_plan(is_active=6)。这个 6 本身就是错的 + (下游语义 0待审/1已激活/2择时监测/5盘中观察, 没有 6); 且该表唯一约束 + 是 (stock_code, trading_time)、buy_amount 是金额不是股数, 单股分批 + 必撞约束。架构定案后该路径整体作废。 + channel_y 写 pms_order_request 表由下游轮询。双方已改定 WebSocket 直连, + 表通道作废 (见 QMT_INTERFACE_REQUIREMENTS.md V2 的 B 部分)。 + `pms_order_request` 表保留未用, 不必删表。 + +留着作废路径比删掉更危险 —— 后来者会以为它可用。 """ from __future__ import annotations import logging from datetime import datetime -from app.db.session import execute +from app.core import ws_codec as wsc +from app.repo import qmt_repo from app.services import param_store logger = logging.getLogger("pms.dispatch") -MODE_SHADOW, MODE_PLAN_X, MODE_CHANNEL_Y = "shadow", "plan_x", "channel_y" -MODES = (MODE_SHADOW, MODE_PLAN_X, MODE_CHANNEL_Y) +MODE_SHADOW, MODE_WS = "shadow", "ws" +MODES = (MODE_SHADOW, MODE_WS) + +# ws 进程心跳超过这个时长没更新, 就认为进程已经没了 (它每 2 秒写一次) +DEFAULT_STALE_SEC = 15 def mode() -> str: @@ -35,30 +67,73 @@ def mode() -> str: return m if m in MODES else MODE_SHADOW +# ================================================================ 通道健康 +def channel_status() -> dict: + """ws 通道当前能不能用。页面运维抽屉与下发前置校验共用同一份判断。""" + stale = param_store.get_int("PMS_QMT_HEARTBEAT_STALE_SEC", DEFAULT_STALE_SEC) + out = {"process_alive": False, "conn_state": "UNKNOWN", "online": False, + "heartbeat_age_sec": None, "last_seq": 0, "acked_seq": 0, + "resync_required": False, "queue": {}, "inbox_pending": 0, "error": None} + try: + st = qmt_repo.get_state() + except Exception as e: + out["error"] = f"通道状态读取失败: {type(e).__name__}: {e}" + return out + hb = st.get("heartbeat_at") + if hb: + age = (datetime.now() - hb).total_seconds() + out["heartbeat_age_sec"] = round(age, 1) + out["process_alive"] = age <= stale + out.update({"conn_state": st.get("conn_state") or "INIT", + "last_seq": int(st.get("last_seq") or 0), + "acked_seq": int(st.get("acked_seq") or 0), + "resync_required": bool(st.get("resync_flag")), + "connected_at": str(st.get("connected_at") or ""), + "last_error": st.get("last_error"), "stat": st.get("stat") or {}}) + out["online"] = out["process_alive"] and out["conn_state"] == "ONLINE" + try: + out["queue"] = qmt_repo.queue_depth() + out["inbox_pending"] = qmt_repo.inbox_pending_count() + except Exception as e: # 统计失败不影响放行判断 + out["error"] = f"队列统计失败: {type(e).__name__}: {e}" + return out + + def describe() -> dict: m = mode() - return {"mode": m, "shadow": m == MODE_SHADOW, "modes": list(MODES), - "hint": { - MODE_SHADOW: "影子运行: 指令只记账不下发, 请在 QMT 侧人工执行, " - "成交由回放自动认领回账本", - MODE_PLAN_X: "过渡通道: 买入写 trading_buy_plan(is_active=6), 卖出退回影子", - MODE_CHANNEL_Y: "统一通道: 写 pms_order_request, 由下游轮询执行", - }[m]} + out = {"mode": m, "shadow": m == MODE_SHADOW, "modes": list(MODES)} + if m == MODE_SHADOW: + out["hint"] = ("影子运行: 指令只记账不下发, 请在 QMT 侧人工执行, " + "成交由回放自动认领回账本") + return out + ch = channel_status() + out["channel"] = ch + if not ch["process_alive"]: + out["hint"] = (f"WebSocket 直连: pms-ws 常驻进程未在线 (心跳 " + f"{ch['heartbeat_age_sec']}s 前), 指令一律拒发。" + f"启动: docker compose --profile ws up -d pms-ws") + elif not ch["online"]: + out["hint"] = (f"WebSocket 直连: 进程在线但连接 {ch['conn_state']} —— " + f"买入拒发, 卖出仍可入队待重连后发出 (协议 §6.3)") + else: + out["hint"] = (f"WebSocket 直连 QMT: 在线, 已确认水位 seq={ch['acked_seq']}, " + f"出口队列 {ch['queue'].get('QUEUED', 0)} 张待发") + if ch.get("resync_required"): + out["hint"] += " · 注意: 对端补发不全, 需走全量对账 (协议 §6.2)" + return out +# ================================================================ 下发 def dispatch(*, instruction_id: str, ts_code: str, side: str, qty: int, limit_price=None, - valid_until=None, stock_name=None) -> dict: + valid_until=None, stock_name=None, intent=None, parent_id=None, + note=None) -> dict: """递一手给下游。返回 {ok, ref, mode, note, error}; ok=False 时 executor 不改指令状态。""" m = mode() try: - if m == MODE_CHANNEL_Y: - return _channel_y(instruction_id, ts_code, side, qty, limit_price, valid_until) - if m == MODE_PLAN_X and str(side).lower() == "buy": - return _plan_x(instruction_id, ts_code, qty, limit_price, stock_name) - if m == MODE_PLAN_X: - r = _shadow(instruction_id, side) - r["note"] = "plan_x 无卖出通道, 本单退回影子运行 (待 QMT B1 落地)" - return r + if m == MODE_WS: + return _ws(instruction_id=instruction_id, ts_code=ts_code, side=side, qty=qty, + limit_price=limit_price, valid_until=valid_until, + intent=intent, parent_id=parent_id, note=note) return _shadow(instruction_id, side) except Exception as e: logger.exception("下发失败 %s", instruction_id) @@ -72,45 +147,97 @@ def _shadow(instruction_id: str, side: str) -> dict: f"成交由回放认领", "error": None} -def _channel_y(instruction_id, ts_code, side, qty, limit_price, valid_until) -> dict: - now = datetime.now() - execute( - "INSERT INTO pms_order_request (instruction_id, ts_code, side, qty, limit_price, " - "valid_until, source, status, cancel_flag, create_time, update_time) VALUES " - "(:iid, :code, :side, :qty, :lp, :vu, 'pms', 'NEW', 0, :ts, :ts)", - {"iid": instruction_id, "code": ts_code, "side": str(side).lower(), "qty": int(qty), - "lp": limit_price, "vu": valid_until or now, "ts": now}) - return {"ok": True, "ref": instruction_id, "mode": MODE_CHANNEL_Y, - "note": "已写入 pms_order_request, 等下游轮询执行", "error": None} +def _to_epoch_ms(valid_until) -> int: + """valid_until 统一成 epoch 毫秒 (协议 §1: 时间字段一律整数毫秒)。 + + executor 传的是 datetime, 页面或脚本可能直接传毫秒 —— 两种都收。 + 给不出有效期时兜底为「当日 14:57」而不是无限期: 挂单不设终点等于把撤单责任又留回 + 自己身上, 而 valid_until 存在的全部意义就是把它交给 QMT (协议 §4.2)。 + """ + if isinstance(valid_until, datetime): + return int(valid_until.timestamp() * 1000) + if isinstance(valid_until, (int, float)) and valid_until > 0: + v = int(valid_until) + return v if v > 10 ** 12 else v * 1000 # 传秒的也认 + fallback = datetime.now().replace(hour=14, minute=57, second=0, microsecond=0) + return int(fallback.timestamp() * 1000) -def _plan_x(instruction_id, ts_code, qty, limit_price, stock_name) -> dict: - """买入走上游既有计划表。amount 由 数量×限价 反算 (下游按 buy_amount 挂单)。""" - px = float(limit_price or 0) - if px <= 0: - return {"ok": False, "ref": None, "mode": MODE_PLAN_X, "note": "", - "error": "plan_x 通道要求限价 (下游按 target_price 挂单)"} - now = datetime.now() - execute( - "INSERT INTO trading_buy_plan (stock_code, stock_name, target_price, buy_amount, " - "is_active, trading_time, create_time, update_time, approved_by, change_reason) " - "VALUES (:code, :name, :px, :amt, 6, :ts, :ts, :ts, 'pms', :rsn)", - {"code": ts_code, "name": stock_name or ts_code, "px": px, - "amt": round(qty * px, 2), "ts": now, - "rsn": f"PMS 指令 {instruction_id}"}) - return {"ok": True, "ref": f"buy_plan:{instruction_id}", "mode": MODE_PLAN_X, - "note": "已写 trading_buy_plan(is_active=6) 待下游挂单", "error": None} +def _parent_of(instruction_id: str, parent_id=None) -> str: + """子单 id 形如 {父指令}_D01 (见 executor._child_id)。显式传入的父 id 优先。""" + if parent_id: + return parent_id + iid = str(instruction_id or "") + tail = iid.rsplit("_D", 1) + return tail[0] if len(tail) == 2 and tail[1].isdigit() else iid +def _ws(*, instruction_id, ts_code, side, qty, limit_price, valid_until, intent=None, + parent_id=None, note=None) -> dict: + """ws 直连: 落出口队列即返回, 实际发送由 pms-ws 常驻进程完成。 + + 这里**不等** QMT 的 ack —— executor 一跳是分钟级节奏, 不该被一次网络往返卡住; + 受理结果由 ws 进程回写 pms_qmt_order.status, 页面与对账都看那里。 + """ + side = str(side or "").lower() + ch = channel_status() + if not ch["process_alive"]: + return {"ok": False, "ref": None, "mode": MODE_WS, "note": "", + "error": f"pms-ws 常驻进程未在线 (心跳 {ch['heartbeat_age_sec']}s 前), " + f"拒绝下发 —— 排进队列也发不出去"} + if not ch["online"] and side != "sell": + # 协议 §6.3: QMT 断连 → 停发一切增持指令, 仅保留减持路径 + return {"ok": False, "ref": None, "mode": MODE_WS, "note": "", + "error": f"QMT 连接 {ch['conn_state']}, 按协议 §6.3 暂停买入下发 " + f"(卖出不受此限)"} + + try: + payload = wsc.place_order_payload( + instruction_id=instruction_id, ts_code=ts_code, side=side, qty=qty, + limit_price=limit_price, valid_until=_to_epoch_ms(valid_until), + intent=(intent or "OPEN"), note=(note or "")) + except wsc.CodecError as e: + # 参数不合规就在本地拦下 —— 换 QMT 一个 BAD_PARAM 是白跑一趟, 还污染对方日志 + return {"ok": False, "ref": None, "mode": MODE_WS, "note": "", + "error": f"指令不合协议要求 ({e.code}): {e.message}"} + + qmt_repo.enqueue_order( + instruction_id=payload["instruction_id"], + parent_id=_parent_of(instruction_id, parent_id), + ts_code=payload["ts_code"], side=payload["side"], qty=payload["qty"], + limit_price=payload["limit_price"], valid_until=payload["valid_until"], + intent=payload["intent"], note=payload["note"]) + logger.info("[ws] 入队 %s %s %s %s股 @%.2f 有效至 %s", payload["instruction_id"], + payload["ts_code"], payload["side"], payload["qty"], + payload["limit_price"], + datetime.fromtimestamp(payload["valid_until"] / 1000).strftime("%H:%M:%S")) + hint = "" if ch["online"] else " (连接未就绪, 重连后立即发出)" + return {"ok": True, "ref": payload["instruction_id"], "mode": MODE_WS, + "note": f"已入 ws 出口队列{hint}", "error": None} + + +# ================================================================ 撤单 def cancel(*, instruction_id: str, dispatch_ref=None) -> dict: - """请求撤单。shadow/plan_x 无撤单语义, 只回执由 executor 置本地状态。""" + """请求撤单。shadow 无下游撤单语义, 只回执由 executor 置本地状态。 + + ws 模式下入参 instruction_id 是**父指令**, 名下可能有多张已发出的子单 —— 全部标记 + 待撤, 由 ws 进程逐张发 cancel_order。已终态的子单不动 (协议 §4.3: 撤单前已全成会回 + reject{ALREADY_FINAL})。 + """ m = mode() - if m != MODE_CHANNEL_Y: + if m != MODE_WS: return {"ok": True, "mode": m, "note": "本模式无下游撤单动作, 仅本地置撤销"} try: - n = execute("UPDATE pms_order_request SET cancel_flag = 1, update_time = :ts " - "WHERE instruction_id = :iid AND status IN ('NEW', 'ACCEPTED', 'EXECUTING')", - {"iid": instruction_id, "ts": datetime.now()}) - return {"ok": True, "mode": m, "note": f"已置 cancel_flag, 影响 {n} 行"} + cancel_id = wsc.new_cancel_id(int(datetime.now().strftime("%Y%m%d"))) + n = qmt_repo.request_cancel(parent_id=instruction_id, cancel_id=cancel_id) except Exception as e: + logger.exception("撤单请求落表失败 %s", instruction_id) return {"ok": False, "mode": m, "error": f"{type(e).__name__}: {e}"} + if not n: + return {"ok": True, "mode": m, "cancelled": 0, + "note": "该指令名下没有在途子单, 仅本地置撤销"} + note = f"已标记 {n} 张在途委托待撤 (cancel_id={cancel_id})" + ch = channel_status() + if not ch["online"]: + note += f"; 连接当前 {ch['conn_state']}, 重连后发出" + return {"ok": True, "mode": m, "cancelled": n, "cancel_id": cancel_id, "note": note} diff --git a/app/services/executor.py b/app/services/executor.py index 49a26c7..0daae92 100644 --- a/app/services/executor.py +++ b/app/services/executor.py @@ -115,6 +115,7 @@ def run_tick(*, now=None, dry_run: bool = False) -> dict: "no_chase_ma5": param_store.get_float("PMS_NO_CHASE_MA5", 0.06), } slices = param_store.get_int("PMS_EXEC_SLICES", 1) + order_ttl = param_store.get_int("PMS_ORDER_TTL_MIN", 10) # 单个分片挂单有效期(交易分钟) brake_active = td.ymd() < param_store.get_int("PMS_BRAKE_UNTIL", 0) ymd_today = td.ymd() @@ -193,16 +194,24 @@ def run_tick(*, now=None, dry_run: bool = False) -> dict: "reason": d["reason"], "dry_run": True}) continue + dl_min = et.slice_deadline(now, ttl_min=order_ttl, forced=d.get("forced", False)) + # intent / parent_id / note 是 ws 通道要的 (协议 §4.2): intent 只作归类, + # parent_id 让子单能回指父指令, note 原样落到 QMT 侧供人工看盘。 + # shadow 模式忽略这三项, 传着不碍事 —— 两种模式共用同一个调用点。 res = dispatcher.dispatch( instruction_id=_child_id(ins["instruction_id"], len(children) + 1), + parent_id=ins["instruction_id"], intent=ins.get("action"), ts_code=code, side=side, qty=qty, limit_price=d["limit_price"], - valid_until=now) + note=d.get("reason"), + valid_until=now.replace(hour=dl_min // 60, minute=dl_min % 60, + second=0, microsecond=0)) if not res.get("ok"): out["errors"].append(f"{ins['instruction_id']} 下发失败: {res.get('error')}") continue children.append({"ymd": ymd_today, "at": now.strftime("%H:%M:%S"), "qty": qty, "limit": d["limit_price"], "mode": res["mode"], "ref": res["ref"], + "valid_until": et._fmt(dl_min), "forced": d.get("forced", False), "reason": d["reason"]}) prog["children"] = children prog.setdefault("dispatched_at", now.strftime("%Y-%m-%d %H:%M:%S")) diff --git a/app/services/param_store.py b/app/services/param_store.py index dd9f65b..7a81093 100644 --- a/app/services/param_store.py +++ b/app/services/param_store.py @@ -29,6 +29,12 @@ CACHE_TTL = 5.0 # 秒; 页面改参后最迟 5 秒全进程可见 (worker 多 # 不允许页面修改的基础设施键 (连接串等只在 .env 维护) INFRA_PREFIX = ("PROXY_DB", "SOURCE_DB", "DB_MYSQL", "SIGNAL_REDIS", "PMS_REDIS", "PMS_WEB") +# 密钥: 既不可改, 也**不可读** —— 连页面快照里都不出现。 +# 协议 QMT_WS_PROTOCOL.md §10.1.1 明确要求「只走 .env, 不入库、不进 ParamStore、 +# 不写进代码」。它们同样以 PMS_ 开头, 不单列的话会被 _editable_keys() 当成普通业务参数: +# 页面能改 (于是私钥 seed 落进 pms_runtime_param 表), snapshot() 还会把它原文显示出来。 +SECRET_KEYS = ("PMS_QMT_SIGN_SEED_HEX", "PMS_QMT_PEER_PUBKEY_B64") + # 运行态开关: key -> (默认值, 类型, 说明) RUNTIME_EXTRA = { "PMS_GLOBAL_BUY_HALT": (False, bool, "全局暂停买入 (HALT_BUY 命令置位)"), @@ -64,9 +70,9 @@ DESC = { "PMS_EOD_FORCE_TIME": "当日配额兜底时点", "PMS_EOD_FORCE_DISCOUNT": "兜底限价系数 (卖出)", "PMS_MIN_LOT_MERGE": "一手检查: 批次自动合并", "PMS_DISPATCH_EXPIRE_MIN": "指令下发后未被接受的过期时间 (分钟)", - "PMS_DISPATCH_MODE": "下发通道: shadow=只记账待人工 / plan_x=买入走 trading_buy_plan / " - "channel_y=写 pms_order_request", + "PMS_DISPATCH_MODE": "下发通道: shadow=只记账待人工 / ws=WebSocket 直连 QMT (待实现)", "PMS_EXEC_SLICES": "当日配额分几笔出手", + "PMS_ORDER_TTL_MIN": "单笔挂单有效期 (交易分钟), 到点下游自动撤; 兜底单不受限", "PMS_RISK_WARN_ENTRY": "单笔敞口告警线 (占规模)", "PMS_RISK_WARN_PORTFOLIO": "组合敞口告警线", "PMS_BRAKE_DRAWDOWN": "组合刹车: 自高水位回撤", "PMS_BRAKE_DAYS": "刹车持续交易日", "PMS_STOP_ATR_MULT": "自算止损参考: 成本 − N×ATR", @@ -85,6 +91,17 @@ DESC = { "PMS_SIGNAL_SELL_CONF_MIN": "卖出信号消化门槛 (低于此不动)", "PMS_SIGNAL_AUTO_EXIT_CONF": "卖出信号直接清仓门槛 (之间则落提议)", "PMS_SIGNAL_TRIM_RATIO": "中等置信度卖出信号的减仓比例", + "PMS_QMT_WS_URL": "QMT 执行服务 WebSocket 端点", + "PMS_QMT_WS_ENABLED": "pms-ws 常驻进程总开关 (关=空转不连接, 此时一律拒发)", + "PMS_QMT_ACK_BATCH": "累积确认: 每落库 N 条发一次 ack_seq", + "PMS_QMT_ACK_INTERVAL_SEC": "累积确认: 或每 N 秒发一次 (与条数取先到)", + "PMS_QMT_HEARTBEAT_SEC": "协议 ping 间隔 (秒)", + "PMS_QMT_IDLE_TIMEOUT_SEC": "超过此秒数未收到对端任何消息即断开重连", + "PMS_QMT_OUTBOX_POLL_SEC": "出口队列轮询间隔 (秒)", + "PMS_QMT_HEARTBEAT_DB_SEC": "ws 进程写存活心跳的间隔 (秒)", + "PMS_QMT_HEARTBEAT_STALE_SEC": "心跳陈旧超此秒数 → 判定 ws 进程已死, 指令一律拒发", + "PMS_QMT_CONNECT_TIMEOUT_SEC": "建连超时 (秒)", + "PMS_QMT_SEND_MAX_ATTEMPTS": "单张委托发送重试上限, 试满置 SEND_FAILED 等人工", } # loaded 标记必不可少: 不能用「data 是否为空」判断缓存是否有效 —— @@ -95,12 +112,12 @@ _lock = threading.Lock() def _editable_keys() -> dict: - """可调业务参数 = settings 中 PMS_ 开头且非基础设施的字段。""" + """可调业务参数 = settings 中 PMS_ 开头、非基础设施、且非密钥的字段。""" out = {} for name, field in type(settings).model_fields.items(): if not name.startswith("PMS_"): continue - if any(name.startswith(p) for p in INFRA_PREFIX): + if any(name.startswith(p) for p in INFRA_PREFIX) or name in SECRET_KEYS: continue out[name] = field return out @@ -147,7 +164,13 @@ def refresh(force: bool = False) -> dict: def get(key: str, default=None): - """取参数当前值: 表值优先 → settings 初值 → RUNTIME_EXTRA 默认 → default。""" + """取参数当前值: 表值优先 → settings 初值 → RUNTIME_EXTRA 默认 → default。 + + 密钥一律返回空串 —— 想拿签名密钥只有一条路: 直接读 settings (即 .env)。 + 堵死这里是为了让「密钥不进 ParamStore」这句话在代码里成立, 而不只是写在文档上。 + """ + if key in SECRET_KEYS: + return "" rows = refresh() t = _type_of(key) if key in rows: @@ -233,8 +256,8 @@ def _range_check(key, v): return "PMS_AUTONOMY 只能是 full / propose_only / off" if key == "PMS_SECTOR_SOURCE" and v not in ("", "custom_table", "gp_stock_category"): return "PMS_SECTOR_SOURCE 只能是 空 / custom_table / gp_stock_category" - if key == "PMS_DISPATCH_MODE" and v not in ("shadow", "plan_x", "channel_y"): - return "PMS_DISPATCH_MODE 只能是 shadow / plan_x / channel_y" + if key == "PMS_DISPATCH_MODE" and v not in ("shadow", "ws"): + return "PMS_DISPATCH_MODE 只能是 shadow / ws" lo_hi = _RANGES.get(key) if lo_hi and isinstance(v, (int, float)) and not isinstance(v, bool): lo, hi = lo_hi diff --git a/app/web/main.py b/app/web/main.py index 8e68871..a317006 100644 --- a/app/web/main.py +++ b/app/web/main.py @@ -68,9 +68,23 @@ def health(): "web_port": settings.PMS_WEB_PORT, }, "sector": industry.status(), + "dispatch": _dispatch_health(), } +def _dispatch_health() -> dict: + """下发通道健康快照。通道读不到不能让 /health 挂 —— 容器健康检查靠它。""" + try: + from app.services import dispatcher + d = dispatcher.describe() + ch = d.get("channel") or {} + return {"mode": d["mode"], "online": ch.get("online"), + "conn_state": ch.get("conn_state"), "queued": (ch.get("queue") or {}).get("QUEUED", 0), + "hint": d.get("hint")} + except Exception as e: + return {"error": f"{type(e).__name__}: {e}"} + + @app.get("/") def index(): path = os.path.join(STATIC_DIR, "index.html") @@ -335,6 +349,44 @@ def api_dispatch_mode(): return ok(lambda: {"ok": True, **dispatcher.describe(), "judge": judge.status()}) +@app.get("/api/ws-channel") +def api_ws_channel(limit: int = Query(50)): + """ws 通道运维视图: 连接状态 / seq 水位 / 出口队列 / 最近上行消息。 + + 这四样凑一起才看得出通道到底"通没通" —— 心跳说明进程在、conn_state 说明连接在、 + 水位说明消息没断层、队列深度说明指令有没有卡住。少看一样都可能误判。 + """ + from app.repo import qmt_repo + from app.services import dispatcher + + def _view(): + out = {"ok": True, "mode": dispatcher.mode(), + "channel": dispatcher.channel_status()} + try: + out["orders"] = qmt_repo.list_orders(limit=limit) + out["inbox"] = qmt_repo.inbox_list(limit=limit) + except Exception as e: + out["error"] = f"{type(e).__name__}: {e}" + return out + return ok(_view) + + +@app.post("/api/ws-channel/clear-resync") +def api_clear_resync(): + """人工确认全量对账已做完后清 resync 标记 (协议 §6.2)。 + + 这个标记只能人来清 —— 它的含义是「对端补发不全, 中间那段消息我们永远拿不到了」, + 自动清等于假装没发生过。 + """ + from app.repo import qmt_repo + + def _clear(): + st = qmt_repo.get_state() + qmt_repo.set_conn(st.get("conn_state") or "OFFLINE", resync=False, beat=False) + return {"ok": True, "message": "已清除 resync 标记 —— 请确认全量对账确实已完成"} + return ok(_clear) + + @app.get("/api/ops/downstream-schema") def api_downstream_schema(): """导出下游三表的实际列定义 —— 用于回填 QMT_INTERFACE_REQUIREMENTS D1。""" diff --git a/app/ws/__init__.py b/app/ws/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ws/runner.py b/app/ws/runner.py new file mode 100644 index 0000000..852b658 --- /dev/null +++ b/app/ws/runner.py @@ -0,0 +1,635 @@ +# -*- coding: utf-8 -*- +""" +QMT WebSocket 常驻连接进程 (pms-ws) +==================================== +协议: `QMT_WS_PROTOCOL.md` V1.0 · 端点 `ws://192.168.16.98:8080` · 明文 ws + Ed25519 双向签名 + +启动: docker compose --profile ws up -d pms-ws + (本地调试: python -m app.ws.runner) + +为什么单独一个进程 +------------------ +既有调度是 Celery beat 的「定时起一个短命任务」模型, 而 ws 要求一条**常驻**长连接, 且 +协议 §1 明确「PMS 不开第二条连接」——否则指令会乱序。所以连接由本进程独占持有: + + executor (celery worker) --写表--> pms_qmt_order(QUEUED) --轮询--> 本进程 --> QMT + QMT --> 本进程 --落表--> pms_qmt_inbox --> ledger 任务 (celery worker) --入账--> 账本 + +本进程只做**通道**的事: 连接、签名、序号、补发、出口出栈、上行落库与确认。 +**不碰账本** —— 成交入账仍然发生在 worker 里 (设计 §4「成交回放与对账」那条链路), +这样账本变更只有一个来源, 也不至于让一个 DB 慢查询把心跳拖到 15 秒超时断连。 + +四个协程 +-------- + _beat_loop 每 2 秒写一次存活心跳 + 刷参数快照。dispatcher 靠这个心跳判断 + 「进程还在不在」—— 连接断了是一回事 (还能重连), 进程没了是另一回事。 + _reader_loop 收帧 → 验签 → 落 inbox → 推进 seq 水位。**recv 带 15 秒超时**, + 超时即视为对端失联, 抛出去让外层重连 (协议 §1 的 idle 规则)。 + _pinger_loop 每 5 秒一条 ping。 + _outbox_loop 每 0.5 秒扫 pms_qmt_order 的 QUEUED 与待撤单, 签名下发。 + +三条容易写错的地方, 都在对应位置有注释: + 1. **落库失败绝不能 ack** —— 落不了库就主动断线重连, 让 QMT 从 last_seq+1 重发。 + 2. **水位是连续前缀**, 不是收到的最大 seq。中间缺一条就不能往前跨。 + 3. **所有 DB 调用走 to_thread** —— SQLAlchemy 是同步的, 直接在事件循环里调用会连带 + 把心跳和 recv 一起阻塞掉, 表现为莫名其妙的周期性断连。 +""" +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +import signal +import sys +from datetime import datetime + +from config.settings import settings +from app.core import ws_codec as wsc +from app.repo import qmt_repo +from app.services import param_store + +logging.basicConfig(level=logging.INFO, + format="%(asctime)s %(levelname)s [%(name)s] %(message)s") +logger = logging.getLogger("pms.ws") + +BACKOFF = (1, 2, 5, 10, 30) # 协议 §1 重连退避, 30 秒封顶, 无限重试 + + +class PersistFailed(RuntimeError): + """上行消息落库失败。**必须**冒泡到重连逻辑 —— 见 _handle_upstream 的注释。""" + + +class HandshakeFailed(RuntimeError): + pass + + +async def _db(fn, *a, **kw): + """所有阻塞 DB 调用的唯一入口。见模块头第 3 条。""" + return await asyncio.to_thread(fn, *a, **kw) + + +class WsRunner: + def __init__(self): + self._stop = asyncio.Event() + self._ws = None + self._msg_seq = 0 + self._last_seq = 0 # 已落库的连续水位 + self._acked_seq = 0 # 已发出 ack_seq 的水位 + self._pending_seq = set() # 乱序暂存 (正常恒空) + self._unacked = 0 # 距上次 ack 又落了几条 + self._baselined = False # 是否已对齐对端序号起点 (见 _set_baseline) + self._params = {} + self._stat = {"rx": 0, "tx": 0, "trades": 0, "rejects": 0, "reconnects": 0, + "last_rx_at": None, "last_tx_at": None} + + # ============================================================ 配置 + def _p(self, key, default): + return self._params.get(key, default) + + async def _refresh_params(self): + """业务参数走 ParamStore (页面可调, 5 秒缓存); 密钥与端点只走 .env。""" + def _load(): + return { + "enabled": param_store.get_bool("PMS_QMT_WS_ENABLED", False), + "url": param_store.get("PMS_QMT_WS_URL", settings.PMS_QMT_WS_URL), + "heartbeat_sec": param_store.get_int("PMS_QMT_HEARTBEAT_SEC", 5), + "idle_timeout_sec": param_store.get_int("PMS_QMT_IDLE_TIMEOUT_SEC", 15), + "ack_batch": param_store.get_int("PMS_QMT_ACK_BATCH", 20), + "ack_interval_sec": param_store.get_float("PMS_QMT_ACK_INTERVAL_SEC", 2.0), + "outbox_poll_sec": param_store.get_float("PMS_QMT_OUTBOX_POLL_SEC", 0.5), + "beat_sec": param_store.get_int("PMS_QMT_HEARTBEAT_DB_SEC", 2), + "max_attempts": param_store.get_int("PMS_QMT_SEND_MAX_ATTEMPTS", 3), + "connect_timeout": param_store.get_int("PMS_QMT_CONNECT_TIMEOUT_SEC", 10), + } + try: + self._params = await _db(_load) + except Exception as e: # 参数表读不到就用上一份 / 文件初值 + if not self._params: + self._params = {"enabled": settings.PMS_QMT_WS_ENABLED, + "url": settings.PMS_QMT_WS_URL, "heartbeat_sec": 5, + "idle_timeout_sec": 15, "ack_batch": 20, + "ack_interval_sec": 2.0, "outbox_poll_sec": 0.5, + "beat_sec": 2, "max_attempts": 3, "connect_timeout": 10} + logger.warning("参数刷新失败, 沿用上一份: %s", e) + + @staticmethod + def _secrets() -> tuple: + """(私钥 seed, 对端公钥)。只从 .env 注入 —— 协议 §10.1.1: 不入库、不进 ParamStore。""" + return ((settings.PMS_QMT_SIGN_SEED_HEX or "").strip(), + (settings.PMS_QMT_PEER_PUBKEY_B64 or "").strip()) + + # ============================================================ 主循环 + async def run(self): + loop = asyncio.get_running_loop() + for sig in (signal.SIGTERM, signal.SIGINT): + with contextlib.suppress(NotImplementedError): + loop.add_signal_handler(sig, self._request_stop, sig) + + await self._refresh_params() + await self._boot() + beat = asyncio.create_task(self._beat_loop(), name="beat") + try: + await self._connection_loop() + finally: + beat.cancel() + with contextlib.suppress(asyncio.CancelledError): + await beat + await self._shutdown() + + def _request_stop(self, sig): + logger.info("收到 %s, 开始优雅退出 (停止取新单 → 刷水位 → 最后一次 ack → 关连接)", + getattr(sig, "name", sig)) + self._stop.set() + + async def _boot(self): + """启动自检: 恢复水位 + 把上次崩在半路的 SENDING 退回队列。""" + try: + await _db(qmt_repo.ensure_state) + st = await _db(qmt_repo.get_state) + stored = int(st.get("last_seq") or 0) + # last_seq 落库是按批刷的, 可能落后于实际已落库的消息 —— 以 inbox 为准重算。 + # 少 ack 只是让 QMT 多留一会儿; 多 ack 会让数据永久丢失, 故从保守值往前推。 + self._last_seq = await _db(qmt_repo.inbox_recover_watermark, stored) + self._acked_seq = min(int(st.get("acked_seq") or 0), self._last_seq) + if self._last_seq != stored: + logger.warning("水位由 inbox 重算: 落库值 %s → 实际 %s", stored, self._last_seq) + n = await _db(qmt_repo.reset_stuck_sending) + if n: + # 重发是安全的: 协议 §2.2 规定重复 instruction_id 不会二次下单, + # 只回 ack{duplicate:true} 带当前状态。幂等键就是为这一刻准备的。 + logger.warning("有 %s 张委托上次卡在 SENDING, 已退回队列重发 (幂等键兜底)", n) + except Exception as e: + logger.exception("启动自检失败: %s", e) + + def _operable(self) -> bool: + """通道是否**有可能**工作 (已启用 + 密钥齐)。不满足就不写心跳 —— 见 _beat_loop。""" + seed, peer = self._secrets() + return bool(self._p("enabled", False) and seed and peer) + + async def _beat_loop(self): + """存活心跳。**连不上 QMT 也照跳** —— 它证明的是「进程在」而不是「连接通」, + dispatcher 靠这两者的区别决定卖出要不要继续入队 (协议 §6.3 减持不挡)。 + + 但通道压根没启用或密钥没配齐时**故意不跳**: 那种状态下队列里的单子永远发不出去, + 让 dispatcher 直接判定进程不可用、一律拒发, 比让它把卖出排进一个死队列强。 + """ + while not self._stop.is_set(): + try: + await self._refresh_params() + if self._operable(): + await _db(qmt_repo.beat, self._stat) + except Exception as e: + logger.warning("心跳写入失败 (库不可用?): %s", e) + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(self._stop.wait(), timeout=self._p("beat_sec", 2)) + + async def _connection_loop(self): + attempt = 0 + while not self._stop.is_set(): + if not self._p("enabled", False): + await self._idle_note("PMS_QMT_WS_ENABLED=False, 通道未启用 (页面可开)") + continue + seed, peer = self._secrets() + if not seed or not peer: + await self._idle_note( + "缺少 Ed25519 密钥: PMS_QMT_SIGN_SEED_HEX / PMS_QMT_PEER_PUBKEY_B64 " + "须在 .env 注入 (协议 §10.1.1)。未配齐前不连接, 也不写心跳 —— " + "dispatcher 会因此一律拒发, 这是对的", error=True) + continue + + url = self._p("url", settings.PMS_QMT_WS_URL) + try: + await _db(qmt_repo.set_conn, "CONNECTING") + await self._session(url, seed, peer) + attempt = 0 # 正常断开 (优雅退出) 才会走到这 + except asyncio.CancelledError: + raise + except Exception as e: + self._stat["reconnects"] += 1 + delay = BACKOFF[min(attempt, len(BACKOFF) - 1)] + attempt += 1 + logger.warning("连接中断 (%s: %s), %s 秒后重连 [第 %s 次]", + type(e).__name__, e, delay, attempt) + with contextlib.suppress(Exception): + await _db(qmt_repo.set_conn, "OFFLINE", last_error=f"{type(e).__name__}: {e}") + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(self._stop.wait(), timeout=delay) + + async def _idle_note(self, msg: str, error: bool = False, every: int = 60): + """未启用/缺密钥时的空转。**故意不写心跳** —— 让 dispatcher 判定进程不可用。""" + (logger.error if error else logger.info)("[ws 空转] %s", msg) + with contextlib.suppress(Exception): + await _db(qmt_repo.set_conn, "OFFLINE", last_error=msg, beat=False) + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(self._stop.wait(), timeout=every) + + # ============================================================ 一次会话 + async def _session(self, url: str, seed: str, peer: str): + import websockets # 延迟导入: 未启用通道时不强求装这个包 + + logger.info("连接 QMT %s ...", url) + async with websockets.connect( + url, ping_interval=None, # 关掉库自带 ping, 用协议层的 ping/pong + open_timeout=self._p("connect_timeout", 10), + close_timeout=5, max_size=4 * 1024 * 1024) as ws: + self._ws = ws + await self._handshake(seed, peer) + await _db(qmt_repo.set_conn, "ONLINE", connected_at=datetime.now(), + last_error="") + logger.info("握手完成, 通道在线 (本端水位 last_seq=%s)", self._last_seq) + tasks = [asyncio.create_task(self._reader_loop(peer), name="reader"), + asyncio.create_task(self._pinger_loop(seed), name="pinger"), + asyncio.create_task(self._outbox_loop(seed), name="outbox"), + asyncio.create_task(self._ack_loop(seed), name="ack")] + # 停机信号单独占一个 waiter: reader 阻塞在 15 秒 recv 上, 光等它自己醒来会让 + # 优雅退出白白拖满一个 idle 周期。谁先结束就收工, 剩下的直接 cancel。 + stopper = asyncio.create_task(self._stop.wait(), name="stopper") + try: + done, _ = await asyncio.wait(tasks + [stopper], + return_when=asyncio.FIRST_COMPLETED) + for t in done: # 让第一个异常冒泡去触发重连 + if t is not stopper and t.exception(): + raise t.exception() + finally: + for t in tasks + [stopper]: + t.cancel() + await asyncio.gather(*tasks, stopper, return_exceptions=True) + self._ws = None + + async def _handshake(self, seed: str, peer: str): + """§4.1 hello → §5.1 hello_ack。last_seq 决定 QMT 从哪一条开始补发。""" + await self._send(wsc.T_HELLO, wsc.hello_payload(self._last_seq), seed) + try: + raw = await asyncio.wait_for(self._ws.recv(), timeout=15) + except asyncio.TimeoutError as e: + raise HandshakeFailed("等 hello_ack 超时 15 秒") from e + env = wsc.parse(raw, peer_pubkey_b64=peer) + if env["type"] != wsc.T_HELLO_ACK: + raise HandshakeFailed(f"握手期收到非 hello_ack 消息: {env['type']}") + pl = env["payload"] + server_seq = int(pl.get("server_seq") or 0) + resync = bool(pl.get("resync_required")) + resume_from = int(pl.get("resume_from") or 0) + if resume_from: + resync = await self._set_baseline(resume_from, "hello_ack.resume_from") or resync + # 注意**不要**在这里就置 _baselined: 对端很可能只是把我们传的 last_seq 加一原样 + # 回填 (冷启动时那就是 1), 而它真正要发的第一条其实是 10001。基线还得靠首条 + # 消息兜一次 —— _set_baseline 本身幂等, 已对齐的话是空操作。 + await _db(qmt_repo.set_conn, "CONNECTING", server_seq=server_seq, resync=resync) + if resync: + # §5.1 / §6.2: 对端补不齐我们要的区间 (日志已滚动)。此时**不能**装作没事 —— + # 中间那段成交我们永远拿不到了, 必须走全量快照对账, 对不齐就停一切自主动作。 + logger.error("resync 触发 (server_seq=%s, resume_from=%s, 本端水位 %s): " + "补发不全, 已置 resync_flag —— 请走全量对账后经页面清除标记", + server_seq, resume_from, self._last_seq) + if env.get("seq") is not None: + await self._persist_upstream(env) + + async def _set_baseline(self, first_seq: int, why: str) -> bool: + """对齐对端的序号起点。返回「是否为真缺口」(真缺口要置 resync)。 + + 对端 seq 跨重启不回退, 首次对接时它可能已经是几万了; 而我们按 §4.1 传 last_seq=0。 + 不对齐基线的话水位会永远卡在 0 —— 具体后果见 ws_codec.cold_start_baseline 的注释, + 那是一种完全静默的死法, 所以这里宁可多打两行日志。 + """ + baseline, is_gap = wsc.cold_start_baseline(self._last_seq, first_seq) + if baseline == self._last_seq: + return False + old = self._last_seq + self._last_seq = baseline + self._pending_seq = {s for s in self._pending_seq if s > baseline} + self._acked_seq = max(self._acked_seq, baseline) + with contextlib.suppress(Exception): + await _db(qmt_repo.save_watermark, baseline, self._acked_seq) + if is_gap: + logger.error("序号缺口: 本端水位 %s, 对端从 %s 起 (%s) —— 中间 %s 条永久缺失。" + "基线已推到 %s 保证通道继续可用, 但必须走全量对账", old, first_seq, + why, first_seq - old - 1, baseline) + else: + logger.info("冷启动: 本端无历史水位, 按对端起点 %s (%s) 建立基线 %s", + first_seq, why, baseline) + return is_gap + + # ============================================================ 上行 + async def _reader_loop(self, peer: str): + idle = self._p("idle_timeout_sec", 15) + while not self._stop.is_set(): + try: + raw = await asyncio.wait_for(self._ws.recv(), timeout=idle) + except asyncio.TimeoutError as e: + # §1: 任一侧 15 秒未收到对端消息即主动断开重连。我们每 5 秒发 ping, + # 对方立即回 pong —— 15 秒还静默, 这条连接已经不能用了。 + raise ConnectionError(f"{idle} 秒未收到对端任何消息, 主动断开") from e + self._stat["rx"] += 1 + self._stat["last_rx_at"] = datetime.now().strftime("%H:%M:%S") + try: + env = wsc.parse(raw, peer_pubkey_b64=peer) + except wsc.CodecError as e: + # §2.1: 验签失败直接丢弃, **不执行任何业务动作**。不回 reject —— + # 连不上信任的对端时, 多说一句话只是多给攻击者一个探测面。 + logger.error("上行消息校验失败, 已丢弃 (%s): %s", e.code, e.message) + continue + await self._handle_upstream(env) + + async def _handle_upstream(self, env: dict): + type_, pl = env["type"], env.get("payload") or {} + seq = env.get("seq") + + if seq is None: + # pong 没有业务内容, 不带 seq 很正常, 静默即可 (它的作用是让 recv 不超时)。 + # 其余类型缺 seq 则是对端实现问题: 这条消息进不了水位也就无法确认, 要吼一声。 + if type_ != wsc.T_PONG: + logger.warning("上行 %s 缺少 seq, 无法纳入水位与补发, 仅记日志: %s", type_, + json.dumps(pl, ensure_ascii=False)[:200]) + await self._apply_side_effects(type_, pl, env) + return + if not self._baselined: + # 兜底: 对端 hello_ack 没给 resume_from 时, 用第一条消息的 seq 建立基线。 + # 有 resume_from 的话握手时已经对齐过, 这里是个空操作。 + self._baselined = True + if await self._set_baseline(int(seq), "首条上行消息"): + with contextlib.suppress(Exception): + await _db(qmt_repo.set_conn, "ONLINE", resync=True) + if int(seq) <= self._last_seq: + return # 第一层去重: 补发时同一条消息 seq 不变 + + await self._persist_upstream(env) # 先落库 + await self._apply_side_effects(type_, pl, env) # 再更新通道状态 + + async def _persist_upstream(self, env: dict): + """落 inbox 并推进水位。**落库失败绝不能 ack**。 + + 协议 §4.5 写得很清楚: 确认前必须已持久化, 否则 QMT 清了消息、PMS 又崩在落库前, + 那段数据就永久丢了。所以这里落库失败不是「记个日志继续」, 而是抛 PersistFailed + 主动断线 —— 重连时 hello 带的还是旧 last_seq, QMT 会把这段重新发一遍。 + 协议的补发机制正是为这种情况准备的, 用它比自己攒重试队列稳妥得多。 + """ + type_, pl, seq = env["type"], env.get("payload") or {}, int(env["seq"]) + needs_ledger = type_ in wsc.NEEDS_LEDGER + note = None if needs_ledger else f"{type_} 由通道进程消化, 不入账" + try: + r = await _db(qmt_repo.inbox_put, seq=seq, msg_id=str(env.get("msg_id") or ""), + msg_type=type_, payload=pl, msg_ts=int(env.get("ts") or 0), + corr_id=env.get("corr_id") or pl.get("instruction_id"), + dedup_key=wsc.dedup_key(type_, pl), + processed=0 if needs_ledger else 2, note=note) + except Exception as e: + raise PersistFailed(f"上行 seq={seq} ({type_}) 落库失败, 断线让对端重发: {e}") from e + if r == qmt_repo.PUT_DUP_KEY: + logger.warning("成交 %s 重复推送 (seq=%s), 已占位不入账", + pl.get("trade_no"), seq) + self._last_seq, self._pending_seq = wsc.next_watermark( + self._last_seq, self._pending_seq, seq) + self._unacked += 1 + if self._pending_seq: + logger.warning("上行乱序: 水位卡在 %s, 暂存 %s 条 (待缺口补齐)", + self._last_seq, len(self._pending_seq)) + if self._unacked >= self._p("ack_batch", 20): + await self._flush_ack() + + async def _apply_side_effects(self, type_: str, pl: dict, env: dict): + """把上行消息落到 pms_qmt_order 的状态上。**只动通道状态, 不动账本**。""" + iid = pl.get("instruction_id") or env.get("corr_id") + try: + if type_ == wsc.T_ACK: + await _db(qmt_repo.update_order, iid, + status=str(pl.get("status") or wsc.ST_ACCEPTED).upper(), + broker_order_id=pl.get("broker_order_id")) + if pl.get("duplicate"): + logger.info("[ack] %s 幂等命中 (对端已受理过), 当前状态 %s", + iid, pl.get("status")) + elif type_ == wsc.T_REJECT: + self._stat["rejects"] += 1 + code = str(pl.get("code") or "") + await _db(qmt_repo.update_order, iid, status=qmt_repo.OS_REJECTED, + reject_code=code[:32], reject_reason=str(pl.get("reason") or "")[:300], + final_at=datetime.now()) + # 设计 §13: 指令下发失败不自动重发。可重试码也只是记下来, + # 由 executor 下一跳按最新行情重新决定 —— 换价重发的判断权在择时模块。 + lvl = logger.error if code == "INTERNAL" else logger.warning + lvl("[reject] %s %s: %s (retryable=%s, 不自动重发)", iid, code, + pl.get("reason"), pl.get("retryable")) + elif type_ == wsc.T_ORDER_UPDATE: + await self._on_order_update(iid, pl) + elif type_ == wsc.T_TRADE: + self._stat["trades"] += 1 + if not wsc.trade_amount_ok(pl): + logger.warning("[trade] %s 金额自洽性存疑: price×qty ≠ amount (%s)", + pl.get("trade_no"), json.dumps(pl, ensure_ascii=False)[:200]) + logger.info("[trade] %s %s %s股 @%s (待 worker 入账)", iid, + pl.get("ts_code"), pl.get("qty"), pl.get("price")) + elif type_ == wsc.T_PERSIST: + if not pl.get("ok"): + # §5.6: 落库失败不影响成交事实, 但要留一条下游不一致告警 + logger.error("[persist_result] 下游落库失败 %s scope=%s: %s", iid, + pl.get("scope"), pl.get("error")) + except Exception as e: + # 通道状态更新失败不该拖垮连接 —— 账本不靠它, 靠 inbox 里的 trade。 + logger.warning("通道状态更新失败 %s %s: %s", type_, iid, e) + + async def _on_order_update(self, iid: str, pl: dict): + status = str(pl.get("status") or "").upper() + row = await _db(qmt_repo.get_order, iid) or {} + cur = str(row.get("status") or "").upper() + if wsc.is_final(cur) and status != cur: + # §5.4/§7.1: 终态唯一、不可再变、只推一次。真收到第二条终态说明对端有 bug —— + # 此时**保住第一条**: 账本记的是"到底成交了多少", 被一条迟到的 CANCELLED 覆盖掉 + # 已经 FILLED 的记录, 比丢一条消息严重得多。 + logger.error("[order_update] %s 已是终态 %s, 又收到 %s —— 已忽略, 请核对对端实现", + iid, cur, status) + return + fields = {"status": status, "cum_qty": int(pl.get("cum_qty") or 0)} + if pl.get("cum_avg_price") is not None: + fields["cum_avg_price"] = float(pl["cum_avg_price"]) + if pl.get("leaves_qty") is not None: + fields["leaves_qty"] = int(pl["leaves_qty"]) + if pl.get("final") or wsc.is_final(status): + fields["final_at"] = datetime.now() + requested = bool(row.get("cancel_state") in + (qmt_repo.CANCEL_REQUESTED, qmt_repo.CANCEL_SENT)) + # §7.2: 落库层 CANCELLED/EXPIRED 都写 cancelled, 但协议层对端会如实区分。 + # 这里两边对一下 —— 对不上说明有一侧记错了, 值得看一眼, 但仍以对端为准 + # (我们没发过撤单却收到 CANCELLED, 更可能是我们漏记而不是对方乱填)。 + if status == wsc.ST_CANCELLED and not requested: + logger.warning("[order_update] %s 回 CANCELLED 但本地未发过撤单 —— " + "疑似到期撤 (EXPIRED) 被填成主动撤, 请核对", iid) + elif status == wsc.ST_EXPIRED and requested: + logger.warning("[order_update] %s 回 EXPIRED 但本地已请求撤单 —— " + "撤单可能在到期后才到, 按到期处理", iid) + logger.info("[order_update] %s 终态 %s, 累计成交 %s 股", iid, status, + fields["cum_qty"]) + await _db(qmt_repo.update_order, iid, **fields) + + # ============================================================ 确认 + async def _ack_loop(self, seed: str): + while not self._stop.is_set(): + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(self._stop.wait(), + timeout=self._p("ack_interval_sec", 2.0)) + await self._flush_ack(seed) + + async def _flush_ack(self, seed: str = None): + """§4.5 累积确认: 只发当前**连续**水位。先把水位落库, 再 ack。""" + if self._last_seq <= self._acked_seq: + self._unacked = 0 + return + seed = seed or self._secrets()[0] + try: + await _db(qmt_repo.save_watermark, self._last_seq, self._acked_seq) + except Exception as e: + logger.warning("水位落库失败, 本轮不 ack (宁可让对端多留一会儿): %s", e) + return + try: + await self._send(wsc.T_ACK_SEQ, wsc.ack_seq_payload(self._last_seq), seed) + except Exception as e: + logger.warning("ack_seq 发送失败 (下轮重试): %s", e) + return + self._acked_seq = self._last_seq + self._unacked = 0 + with contextlib.suppress(Exception): + await _db(qmt_repo.save_watermark, self._last_seq, self._acked_seq) + + # ============================================================ 下行 + async def _pinger_loop(self, seed: str): + while not self._stop.is_set(): + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(self._stop.wait(), + timeout=self._p("heartbeat_sec", 5)) + if self._stop.is_set(): + return + await self._send(wsc.T_PING, {}, seed) + + async def _outbox_loop(self, seed: str): + """出口出栈: QUEUED → 签名 → send → SENT。顺带处理待撤单。 + + 连接类异常**往上抛**去触发重连; 其它异常 (取数失败、单条参数不合法) 记日志继续 —— + 「故障即守成」是不产生新指令, 不是一有毛病就把整条通道停掉。 + """ + while not self._stop.is_set(): + try: + await self._drain_outbox(seed) + await self._drain_cancels(seed) + except asyncio.CancelledError: + raise + except Exception as e: + if _is_conn_error(e): + raise + logger.exception("出口轮询异常 (不产生新指令, 下轮继续): %s", e) + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(self._stop.wait(), + timeout=self._p("outbox_poll_sec", 0.5)) + + async def _drain_outbox(self, seed: str): + if self._stop.is_set(): + return # 优雅退出: 不再取新单 + rows = await _db(qmt_repo.next_queued, 20) + now = wsc.now_ms() + for r in rows: + iid = r["instruction_id"] + if int(r["valid_until"] or 0) <= now: + # 排队期间就过期了。发出去只会立刻换回一个 EXPIRED, 白跑一趟还占对端一条 + # 记录 —— 本地作废更干净, executor 下一跳会按新行情重新出手。 + await _db(qmt_repo.abort_order, iid, "排队期间 valid_until 已过, 未发出即作废") + logger.warning("[outbox] %s 有效期已过, 本地作废未发出", iid) + continue + if not await _db(qmt_repo.claim_order, iid): + continue # 被别的进程抢走了 (滚动重启的瞬间) + try: + payload = wsc.place_order_payload( + instruction_id=iid, ts_code=r["ts_code"], side=r["side"], + qty=int(r["qty"]), limit_price=r["limit_price"], + valid_until=int(r["valid_until"]), intent=r.get("intent") or "OPEN", + note=r.get("note") or "") + await self._send(wsc.T_PLACE, payload, seed, corr_id=iid) + except Exception as e: + # 连接断了不算这张单的"失败次数" —— 那是通道的问题不是这张单的问题, + # 否则一次几秒的网络抖动就能把三张待发单全部烧成 SEND_FAILED。 + conn = _is_conn_error(e) + await _db(qmt_repo.requeue_order, iid, f"{type(e).__name__}: {e}", + self._p("max_attempts", 3), not conn) + logger.warning("[outbox] %s 发送失败%s: %s", iid, + " (连接问题, 不计失败次数)" if conn else "", e) + raise + await _db(qmt_repo.mark_sent, iid) + logger.info("[outbox] 已发出 %s %s %s %s股 @%.2f", iid, r["ts_code"], + r["side"], r["qty"], float(r["limit_price"])) + + async def _drain_cancels(self, seed: str): + rows = await _db(qmt_repo.next_cancel_requests, 20) + for r in rows: + iid = r["instruction_id"] + if r["status"] in (qmt_repo.OS_QUEUED, qmt_repo.OS_SENDING): + # 还没发出去就要撤 —— 直接本地作废, 不必跑一趟下游 + await _db(qmt_repo.abort_order, iid, "发出前撤销") + await _db(qmt_repo.mark_cancel_sent, iid) + logger.info("[cancel] %s 尚未发出, 本地作废", iid) + continue + payload = wsc.cancel_order_payload( + cancel_id=r.get("cancel_id") or wsc.new_cancel_id(_ymd()), + instruction_id=iid) + await self._send(wsc.T_CANCEL, payload, seed, corr_id=iid) + await _db(qmt_repo.mark_cancel_sent, iid) + logger.info("[cancel] 已发出撤单 %s", iid) + + async def _send(self, type_: str, payload: dict, seed: str, corr_id=None): + if self._ws is None: + raise ConnectionError(f"连接未就绪, 无法发送 {type_}") + self._msg_seq += 1 + env = wsc.build(type_, payload, seed_hex=seed, + msg_id=wsc.new_msg_id(_ymd(), self._msg_seq), corr_id=corr_id) + await self._ws.send(wsc.dumps(env)) + self._stat["tx"] += 1 + self._stat["last_tx_at"] = datetime.now().strftime("%H:%M:%S") + + # ============================================================ 收尾 + async def _shutdown(self): + """优雅退出: 刷水位 → 最后一次 ack → 关连接 → 置 STOPPED 并**清空心跳**。 + + 清心跳是关键一步: 不清的话, 停机后的 stale 窗口 (默认 15 秒) 里 dispatcher 仍 + 认为进程活着, 卖出指令还会往队列里排 —— 而已经没人会发它们了。 + """ + with contextlib.suppress(Exception): + if self._ws is not None: + await asyncio.wait_for(self._flush_ack(), timeout=3) + with contextlib.suppress(Exception): + await _db(qmt_repo.save_watermark, self._last_seq, self._acked_seq) + with contextlib.suppress(Exception): + if self._ws is not None: + await asyncio.wait_for(self._ws.close(code=1001, reason="pms shutdown"), + timeout=5) + with contextlib.suppress(Exception): + await _db(qmt_repo.mark_stopped, "进程正常退出") + logger.info("已退出 (水位 last_seq=%s / acked=%s, 收 %s 发 %s)", + self._last_seq, self._acked_seq, self._stat["rx"], self._stat["tx"]) + + +def _ymd() -> int: + return int(datetime.now().strftime("%Y%m%d")) + + +def _is_conn_error(e: BaseException) -> bool: + """这个异常是不是"连接没了"。 + + 按类名判断而不是 isinstance, 是为了不在模块顶层 import websockets —— + 通道未启用时那个包可以不装, 而本模块 (以及导入它的 check 脚本) 仍要能加载。 + """ + if isinstance(e, (ConnectionError, OSError, asyncio.IncompleteReadError)): + return True + return type(e).__name__ in ("ConnectionClosed", "ConnectionClosedOK", + "ConnectionClosedError", "WebSocketException", + "InvalidHandshake", "InvalidStatus", "InvalidStatusCode", + "HandshakeFailed", "PersistFailed") + + +async def main(): + logger.info("pms-ws 启动 · 协议 V1.0 · 端点 %s · 模式 %s", settings.PMS_QMT_WS_URL, + "启用" if settings.PMS_QMT_WS_ENABLED else "未启用 (空转)") + await WsRunner().run() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + sys.exit(0) diff --git a/config/settings.py b/config/settings.py index 46ec998..3a7facd 100644 --- a/config/settings.py +++ b/config/settings.py @@ -84,8 +84,28 @@ class Settings(BaseSettings): PMS_EOD_FORCE_DISCOUNT: float = 0.998 # 兜底限价 = 现价 × 此系数 (卖出) PMS_MIN_LOT_MERGE: bool = True # 一手检查: 批次自动合并 PMS_DISPATCH_EXPIRE_MIN: int = 30 # 指令下发后未被接受的过期时间 - PMS_DISPATCH_MODE: str = "shadow" # 下发通道: shadow(影子,默认) / plan_x / channel_y + PMS_DISPATCH_MODE: str = "shadow" # 下发通道: shadow(影子,默认) / ws(直连QMT,待实现) PMS_EXEC_SLICES: int = 1 # 当日配额分几笔出手 (设计「分笔卖出配额」) + PMS_ORDER_TTL_MIN: int = 10 # 单个下发分片的挂单有效期(交易分钟), 到点下游自动撤 + + # --- QMT WebSocket 直连通道 (协议见 QMT_WS_PROTOCOL.md V1.0) --- + # 连接由 pms-ws 常驻进程独占 (app/ws/runner.py); executor 经 pms_qmt_order + # 出口表递单, 详见 app/services/dispatcher.py 头部的「进程边界」一节。 + PMS_QMT_WS_URL: str = "ws://192.168.16.98:8080" + PMS_QMT_WS_ENABLED: bool = False # pms-ws 进程总开关; False = 空转不连接不写心跳 + PMS_QMT_ACK_BATCH: int = 20 # 累积确认: 每落库 N 条发一次 ack_seq + PMS_QMT_ACK_INTERVAL_SEC: float = 2.0 # 累积确认: 或每 N 秒发一次 (取先到) + PMS_QMT_HEARTBEAT_SEC: int = 5 # ping 间隔 + PMS_QMT_IDLE_TIMEOUT_SEC: int = 15 # 超过此时长未收到对端消息即重连 + PMS_QMT_OUTBOX_POLL_SEC: float = 0.5 # 出口队列轮询间隔 (择时本是分钟级, 无需更快) + PMS_QMT_HEARTBEAT_DB_SEC: int = 2 # ws 进程写存活心跳的间隔 + PMS_QMT_HEARTBEAT_STALE_SEC: int = 15 # 心跳陈旧超此秒数 → 判定进程已死, dispatcher 拒发 + PMS_QMT_CONNECT_TIMEOUT_SEC: int = 10 # 建连超时 + PMS_QMT_SEND_MAX_ATTEMPTS: int = 3 # 单张委托发送重试上限, 试满置 SEND_FAILED + # 下面两项是**密钥**: 只从 .env 注入, 不入库、不进 ParamStore、不上页面 (协议 §10.1.1)。 + # param_store.SECRET_KEYS 已把它们挡在可调参数与页面快照之外。 + PMS_QMT_SIGN_SEED_HEX: str = "" # PMS 私钥 seed (Ed25519, 32 字节 hex) + PMS_QMT_PEER_PUBKEY_B64: str = "" # QMT 公钥 (base64), 验上行签名用 # --- 风险披露与刹车 --- PMS_RISK_WARN_ENTRY: float = 0.01 # 单笔敞口告警线 (占规模) diff --git a/ddl_pms_v1.sql b/ddl_pms_v1.sql index 7673629..e793ab6 100644 --- a/ddl_pms_v1.sql +++ b/ddl_pms_v1.sql @@ -2,9 +2,10 @@ -- tradingSystem (PMS) V1 建表 DDL -- 目标库: 153 代理侧 (与 decision_ledger 等同库), 一律经代理严格单表访问 -- 字符集: utf8mb4; 代码格式: Tushare 点式 (600000.SH); 时区: Asia/Shanghai --- 对应设计: POSITION_MGMT_DESIGN.md V0.4 §11 --- 注: pms_order_request (指令通道) 归属待 B1.7 协商, 其 DDL 见 --- QMT_INTERFACE_REQUIREMENTS.md, 不在本文件建立。 +-- 对应设计: POSITION_MGMT_DESIGN.md V0.4 §11 (1~10 号表) +-- QMT_WS_PROTOCOL.md V1.0 (11~13 号表, 2026-07-28 追加) +-- 注: pms_order_request (原表轮询通道) 已作废 —— 双方改定 WebSocket 直连, 见文件尾部 +-- 三张 ws 通道表。该表本就未在此文件建立, 无需处理。 -- ===================================================================== -- 1. 命令表 (参数命令 + 任务命令; 参数命令当前值 = 该类型最新一条 EFFECTIVE 记录) @@ -186,3 +187,97 @@ CREATE TABLE IF NOT EXISTS pms_runtime_param ( updated_by VARCHAR(32) NOT NULL DEFAULT 'user', updated_at DATETIME NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='页面可调参数当前值'; + +-- ===================================================================== +-- ws 直连通道三表 (2026-07-28 追加; 协议 QMT_WS_PROTOCOL.md V1.0) +-- --------------------------------------------------------------------- +-- 为什么是三张而不是塞进已有表: +-- * 出口队列必须**持久**。ws 是常驻进程、executor 在 celery worker 里, 两个进程之间 +-- 递指令得有个落点; 落在业务表上则「先记账后动作」这条铁律字面成立 —— 落表即记账, +-- ws 进程崩了重启队列还在, 页面也能直接看到在途委托。 +-- * 上行消息必须**先落库再 ack** (协议 §4.5)。收到就 ack、然后崩在落库前, 那段数据 +-- QMT 那边已经清了, 永久丢失。所以 inbox 是独立表且 ack 在它之后。 +-- * seq 水位跨重启不能回退, 且更新频率高, 不适合塞 pms_runtime_param +-- (那张表有 5 秒缓存, 且是给页面调参用的)。 +-- ===================================================================== + +-- 11. QMT 委托表 (子单) —— 兼下发出口队列 +-- 一行 = 协议里的一条 place_order = 交易所的一张委托 (§8「一条指令 = 一张委托」)。 +-- pms_instruction 是父指令 (承载一个方案条目的总量), 本表是它分日/分笔出手的子单。 +CREATE TABLE IF NOT EXISTS pms_qmt_order ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + instruction_id VARCHAR(64) NOT NULL UNIQUE COMMENT '协议幂等键 (§2.2), 也是子单主键', + parent_id VARCHAR(64) NOT NULL COMMENT '父指令 pms_instruction.instruction_id', + ts_code VARCHAR(16) NOT NULL COMMENT '点式 600000.SH', + side VARCHAR(8) NOT NULL COMMENT 'buy/sell', + qty INT NOT NULL COMMENT '股数; 买入整百, 清仓允许零股尾数', + limit_price DECIMAL(10,2) NOT NULL COMMENT '限价, 2位小数 (协议不接受 null)', + valid_until BIGINT NOT NULL COMMENT '有效期截止 epoch 毫秒, 到点 QMT 自动撤', + intent VARCHAR(8) NOT NULL DEFAULT 'OPEN' COMMENT 'OPEN/FILL/ADD/DCA/TRIM/EXIT/T0', + note VARCHAR(200) NULL, + status VARCHAR(16) NOT NULL DEFAULT 'QUEUED' + COMMENT '本地: QUEUED待发/SENDING已认领/SENT已发出/SEND_FAILED/ABORTED未发即作废; ' + '协议: ACCEPTED/SUBMITTED/PARTIAL/FILLED/CANCELLED/EXPIRED/REJECTED', + broker_order_id VARCHAR(64) NULL COMMENT 'QMT 回的委托号', + cum_qty INT NOT NULL DEFAULT 0 COMMENT '本委托累计成交股数 (§5.4 口径)', + cum_avg_price DECIMAL(10,3) NULL COMMENT '本委托累计成交均价', + leaves_qty INT NULL COMMENT '未成交剩余', + cancel_state VARCHAR(12) NOT NULL DEFAULT 'NONE' + COMMENT 'NONE/REQUESTED(已请求撤)/SENT(撤单已发出) —— 区分 CANCELLED 与 EXPIRED 的本地依据', + cancel_id VARCHAR(64) NULL COMMENT 'CXL-yyyymmdd-8位随机', + cancel_req_at DATETIME NULL, + reject_code VARCHAR(32) NULL COMMENT '协议 §7.3 拒绝码', + reject_reason VARCHAR(300) NULL, + send_attempts INT NOT NULL DEFAULT 0, + sent_at DATETIME NULL, + final_at DATETIME NULL COMMENT '进终态时刻', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + KEY idx_status (status), + KEY idx_parent (parent_id), + KEY idx_cancel (cancel_state), + KEY idx_broker (broker_order_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='QMT 委托子单 (兼下发出口队列)'; + +-- 12. 上行消息收件箱 —— 「确认前必须已落库」的那个"库" +-- seq 作主键 = 协议 §6.1 第一层去重 (补发时同一条消息 seq 不变); +-- dedup_key 唯一 = 第二层去重 (trade_no)。任一层命中即丢弃。 +CREATE TABLE IF NOT EXISTS pms_qmt_inbox ( + seq BIGINT PRIMARY KEY COMMENT 'QMT 侧全局单调递增序号 (第一层去重)', + msg_id VARCHAR(64) NOT NULL, + msg_type VARCHAR(24) NOT NULL COMMENT 'trade/order_update/ack/reject/snapshot/...', + corr_id VARCHAR(64) NULL COMMENT '关联的 instruction_id 或 cancel_id', + dedup_key VARCHAR(80) NULL UNIQUE COMMENT '第二层去重键, 目前只有 trade:{trade_no}', + payload_json TEXT NOT NULL COMMENT '原样存 payload, 供事后追溯与下批入账消费', + msg_ts BIGINT NOT NULL COMMENT '对端发送时刻 epoch 毫秒', + received_at DATETIME NOT NULL, + processed TINYINT NOT NULL DEFAULT 0 + COMMENT '0=待入账(仅 trade) / 1=已入账 / 2=通道自处理完毕无需入账', + processed_at DATETIME NULL, + process_note VARCHAR(300) NULL, + KEY idx_pending (processed, seq), + KEY idx_corr (corr_id), + KEY idx_type (msg_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='QMT 上行消息收件箱'; + +-- 13. 通道状态 (恒一行, id=1) +-- last_seq 是**连续前缀水位**, 不是收到的最大 seq —— 中间缺一条就不能往前跨, +-- 否则那条就永久丢了 (§4.5 累积确认语义)。真值可由 inbox 重算, 本行是缓存。 +CREATE TABLE IF NOT EXISTS pms_ws_state ( + id TINYINT PRIMARY KEY COMMENT '恒为 1', + last_seq BIGINT NOT NULL DEFAULT 0 COMMENT '已落库的连续水位 (hello.last_seq 取它)', + acked_seq BIGINT NOT NULL DEFAULT 0 COMMENT '已发出 ack_seq 的水位', + server_seq BIGINT NOT NULL DEFAULT 0 COMMENT 'hello_ack 里对端自报的最新序号', + conn_state VARCHAR(12) NOT NULL DEFAULT 'INIT' + COMMENT 'INIT/CONNECTING/ONLINE/OFFLINE/STOPPED —— dispatcher 据此决定放不放行', + connected_at DATETIME NULL, + heartbeat_at DATETIME NULL COMMENT 'ws 进程存活心跳; 陈旧即视为进程已死, 一律拒发', + resync_flag TINYINT NOT NULL DEFAULT 0 COMMENT '1=对端补不齐, 须走全量对账 (§6.2)', + last_error VARCHAR(300) NULL, + stat_json TEXT NULL COMMENT '收发计数/重连次数等, 页面运维抽屉展示', + updated_at DATETIME NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='ws 通道状态与序号水位'; + +INSERT INTO pms_ws_state (id, last_seq, acked_seq, server_seq, conn_state, updated_at) +VALUES (1, 0, 0, 0, 'INIT', NOW()) +ON DUPLICATE KEY UPDATE id = id; diff --git a/docker-compose.yml b/docker-compose.yml index f4e2d39..2fcba39 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,13 @@ # tradingSystem (PMS) 容器编排 # 用法见 README「Docker 部署」。.env 放在本文件同目录 (服务器上手工维护, 不入库)。 -# 当前默认只启动 pms-web; pms-beat / pms-worker 挂在 sched profile 下, -# 调度器代码 (app/scheduler.py) 就绪后用 --profile sched 启用。 +# 三组服务: +# 默认 pms-web 管理页面 (38100) +# --profile sched pms-beat / pms-worker Celery 调度与执行 +# --profile ws pms-ws QMT WebSocket 常驻连接 (协议 V1.0) +# +# pms-ws 与 worker 的关系: worker 里的 executor 把待发委托写进 pms_qmt_order 出口表, +# pms-ws 亚秒轮询取走并发出。两者**只经数据库耦合**, 谁先起谁后起都不影响 —— pms-ws 没起 +# 来时 dispatcher 会因心跳陈旧而拒发, 指令保持原状, 不会静默堆在队列里。 x-pms-base: &pms-base build: . @@ -43,3 +49,16 @@ services: container_name: pms-worker profiles: ["sched"] command: celery -A app.scheduler.celery_app worker -l info -c 2 + + # QMT WebSocket 常驻连接。协议 §1 规定 PMS 只开一条连接 (多开会让指令乱序), + # 所以这个服务**绝不能扩副本** —— 不要 deploy.replicas, 不要 docker compose up --scale。 + # 退出流程见 app/ws/runner.py 的 _shutdown: 刷水位 → 最后一次 ack → 关连接 → 清心跳, + # stop_grace_period 给足 25 秒是为了让这一串跑完; 强杀会丢掉最后一批 ack (不丢数据, + # 只是让 QMT 多留一会儿, 重连后按 last_seq 补发)。 + pms-ws: + <<: *pms-base + container_name: pms-ws + profiles: ["ws"] + command: python -m app.ws.runner + stop_grace_period: 25s + stop_signal: SIGTERM diff --git a/requirements.txt b/requirements.txt index 14b87a9..64c0e3d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,9 @@ fastapi>=0.110 uvicorn>=0.27 requests>=2.31 chinesecalendar>=1.9 +# ---- QMT WebSocket 直连通道 (协议 QMT_WS_PROTOCOL.md V1.0) ---- +# websockets 上限锁在 15 之前: 14.x 起 websockets.connect 已切到新版 asyncio 实现, +# 再往上客户端 API 还会继续动。这条通道是要挂实盘的, 不接受"升级依赖顺手换实现"。 +websockets>=12.0,<15.0 +# Ed25519 签名/验签 (协议 §2.1)。python:3.11-slim 上有 manylinux 轮子, 不需要编译链。 +cryptography>=42.0 diff --git a/scripts/check_db.py b/scripts/check_db.py index cdbfb57..acfc85a 100644 --- a/scripts/check_db.py +++ b/scripts/check_db.py @@ -6,10 +6,11 @@ 检查项: 1. 三个库连通性 (153 代理 / 因子库 / 指数库) - 2. pms_* 十张表是否存在与当前行数 + 2. pms_* 十三张表是否存在与当前行数 3. 下游三表可读性 + 持仓数量列探测结果 (回填 QMT_INTERFACE_REQUIREMENTS A1/D1 用) 4. 行情 Redis (db13) 连通性与样本键 5. 运行参数表当前生效值 + 6. ws 通道: 密钥配置 / 本端公钥 (带外交给 QMT) / 进程心跳 / seq 水位 / 出口队列 全部通过退出码 0; 任一 FAIL 退出码 1 (WARN 不影响退出码)。 """ import os @@ -19,7 +20,9 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) PMS_TABLES = ["pms_command", "pms_plan", "pms_position", "pms_lot", "pms_instruction", "pms_proposal", "pms_action_ledger", "pms_daily_report", "pms_industry_map", - "pms_runtime_param"] + "pms_runtime_param", + # ws 直连通道三表 (协议 QMT_WS_PROTOCOL.md V1.0) + "pms_qmt_order", "pms_qmt_inbox", "pms_ws_state"] DOWNSTREAM = ["trading_position", "trading_order", "trading_buy_plan"] FAILED, WARNED = [], [] @@ -39,6 +42,61 @@ def warn(msg): line("WARN", msg) +def check_ws_channel(): + """通道自检。密钥缺失只报 WARN —— 影子运行期本来就还没配, 不该把自检判成失败。""" + from config.settings import settings + from app.core import ws_codec as wsc + from app.repo import qmt_repo + from app.services import dispatcher, param_store + + mode = param_store.get("PMS_DISPATCH_MODE", "shadow") + enabled = param_store.get_bool("PMS_QMT_WS_ENABLED", False) + line("OK", f"下发模式 PMS_DISPATCH_MODE = {mode}; 通道开关 PMS_QMT_WS_ENABLED = {enabled}") + line("", f"端点 {param_store.get('PMS_QMT_WS_URL', settings.PMS_QMT_WS_URL)}") + + seed = (settings.PMS_QMT_SIGN_SEED_HEX or "").strip() + peer = (settings.PMS_QMT_PEER_PUBKEY_B64 or "").strip() + if not seed: + (warn if mode != "ws" else fail)( + "PMS_QMT_SIGN_SEED_HEX 未配置 —— 无法签名, ws 通道不可用 (见 .env.example)") + else: + try: + # 打印本端公钥: 部署清单 §10.1.1 第 2 条要把它带外交给 QMT 侧 + line("OK", f"本端 Ed25519 公钥 (交给 QMT 侧加白): {wsc.public_key_b64(seed)}") + except Exception as e: + fail(f"PMS_QMT_SIGN_SEED_HEX 非法: {e}") + if not peer: + (warn if mode != "ws" else fail)( + "PMS_QMT_PEER_PUBKEY_B64 未配置 —— 无法验上行签名, 所有上行消息都会被丢弃") + else: + line("OK", f"对端公钥已配置 ({peer[:12]}...)") + + if param_store.get_bool("PMS_QMT_SIGN_SEED_HEX") or param_store.get("PMS_QMT_SIGN_SEED_HEX"): + fail("私钥出现在 ParamStore 可读路径 —— 违反协议 §10.1.1, 请检查 SECRET_KEYS") + + try: + ch = dispatcher.channel_status() + except Exception as e: + fail(f"通道状态读取失败: {type(e).__name__}: {e}") + return + alive = "在线" if ch["process_alive"] else "不在线" + line("OK" if ch["process_alive"] else "INFO", + f"pms-ws 进程 {alive} (心跳 {ch['heartbeat_age_sec']}s 前), 连接 {ch['conn_state']}") + line("", f"seq 水位: 已落库 {ch['last_seq']} / 已确认 {ch['acked_seq']}; " + f"待入账上行 {ch['inbox_pending']} 条") + line("", f"出口队列: {ch['queue'] or '空'}") + if ch.get("resync_required"): + fail("resync_flag=1: 对端补发不全, 必须走全量对账后经页面清除标记 (协议 §6.2)") + if mode == "ws" and not ch["online"]: + warn("已切 ws 模式但通道未在线 —— 买入会被拒发 (卖出仍入队), 先起 pms-ws: " + "docker compose --profile ws up -d pms-ws") + if ch.get("error"): + warn(ch["error"]) + stuck = (ch["queue"] or {}).get("SEND_FAILED", 0) + if stuck: + warn(f"{stuck} 张委托发送失败待人工处理 (pms_qmt_order.status = SEND_FAILED)") + + def main(): from app.db import session as dbs @@ -139,6 +197,9 @@ def main(): except Exception as e: fail(f"参数中心不可用: {type(e).__name__}: {e}") + print("\n[6] ws 直连通道 (协议 QMT_WS_PROTOCOL.md V1.0)") + check_ws_channel() + print("\n" + "-" * 62) if FAILED: print(f"FAILED: {len(FAILED)} 项致命问题, {len(WARNED)} 项告警") diff --git a/scripts/run_tests.py b/scripts/run_tests.py index 4776b0a..7fac3c5 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -7,9 +7,10 @@ 包含: test_core_units.py 仓位规划器 / 安全垫与成本账 (14 例) test_batch2_units.py 命令状态机 / 方案生成器 / 回放对账纯逻辑 (35 例) - test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 (18 例) + test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 (19 例) test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 (11 例) test_batch5_units.py 决策系统信号流解析与消化口径 (8 例) + test_batch6_units.py ws 通道: 协议测试向量/签名/水位/单表守卫 (44 例) test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (32 例) 任一子集失败即整体失败 (退出码 1)。 """ @@ -20,7 +21,8 @@ import sys HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(HERE) SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py", - "test_batch4_units.py", "test_batch5_units.py", "test_wiring.py"] + "test_batch4_units.py", "test_batch5_units.py", "test_batch6_units.py", + "test_wiring.py"] def main(): diff --git a/scripts/test_batch3_units.py b/scripts/test_batch3_units.py index ed42f41..1579842 100644 --- a/scripts/test_batch3_units.py +++ b/scripts/test_batch3_units.py @@ -270,6 +270,25 @@ def _(): assert s["by_reason"].get("LOT_INVALID") == 1 and s["by_reason"].get("BUY_HALT") == 1, s +@case("择时·挂单有效期: 按交易分钟推进, 跳过午休, 收盘封顶, 兜底单给到收盘") +def _(): + # 普通时段: 10:00 + 10 分钟 = 10:10 + assert et.slice_deadline("10:00", ttl_min=10) == et.hm_to_min("10:10") + # 跨午休: 11:25 + 10 分钟, 午休 90 分钟不计入 → 用掉上午 5 分钟, 下午再走 5 分钟 = 13:05 + assert et.slice_deadline("11:25", ttl_min=10) == et.hm_to_min("13:05"), \ + et._fmt(et.slice_deadline("11:25", ttl_min=10)) + # 起点正落在午休里 → 从下午开盘起算 + assert et.slice_deadline("12:00", ttl_min=10) == et.hm_to_min("13:10") + # 尾盘封顶, 不会算出收盘后的时点 + assert et.slice_deadline("14:58", ttl_min=10) == et.hm_to_min("15:00") + # 兜底单直接给到收盘, 不受 TTL 限制 (今天必须走掉的单不能被 TTL 撤回来) + assert et.slice_deadline("14:46", ttl_min=10, forced=True) == et.hm_to_min("15:00") + assert et.slice_deadline("09:35", ttl_min=10, forced=True) == et.hm_to_min("15:00") + # 边界: 恰好到午休开始不顺延; TTL=0 即当刻失效 + assert et.slice_deadline("11:20", ttl_min=10) == et.hm_to_min("11:30") + assert et.slice_deadline("10:00", ttl_min=0) == et.hm_to_min("10:00") + + # ---------------------------------------------------------------- runner def main(): passed, failed = 0, 0 diff --git a/scripts/test_batch6_units.py b/scripts/test_batch6_units.py new file mode 100644 index 0000000..f16e6eb --- /dev/null +++ b/scripts/test_batch6_units.py @@ -0,0 +1,427 @@ +# -*- coding: utf-8 -*- +""" +批次6 单测: ws 直连通道纯逻辑 (零外部依赖, 不连库) +==================================================== +运行: python scripts/test_batch6_units.py + +覆盖: + A. 协议 §2.1.1 **测试向量** —— 逐字节核对 payload_json / sha256 / canonical / sig。 + 这一组是本批最重要的断言: 规范化串的拼接是双方最容易写不一致的地方, 对不上就 + 一定卡在联调的"签名验不过", 而且看不出差在哪一段。 + B. 签名/验签往返、篡改检出、跨密钥拒绝。 + C. 信封解析: 版本、缺字段、非法 JSON、签名不匹配。 + D. place_order 参数校验: 整百规则、限价必填与精度、点式代码、方向。 + E. seq 水位推进: 连续、乱序、重复、缺口。 + F. 双层去重键与成交金额自洽性。 + G. 单表访问守卫: upsert (ON DUPLICATE KEY UPDATE) 不得被误判成多表。 + H. dispatcher 纯助手: valid_until 归一、子单→父指令反推。 +""" +import os +import sys +import traceback + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +PASS, FAIL = [], [] + + +def case(name): + def deco(fn): + try: + fn() + PASS.append(name) + print(f" ok {name}") + except Exception as e: + FAIL.append((name, f"{type(e).__name__}: {e}", traceback.format_exc())) + print(f" FAIL {name} —— {type(e).__name__}: {e}") + return fn + return deco + + +def eq(got, exp, what=""): + if got != exp: + raise AssertionError(f"{what}\n 实际: {got!r}\n 期望: {exp!r}") + + +# ================================================================ A. 测试向量 +# 协议 §2.1.1 原文。测试密钥仅供联调, 不得用于生产。 +VEC_SEED = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" +VEC_PUB = "PM0kHP/Js2GARLl9A22GFFk9iwF8NA8d7odzOFUXZUs=" +VEC_PAYLOAD = {"instruction_id": "INS-20260728-a3f19c04", "ts_code": "600000.SH", + "side": "sell", "qty": 2000, "limit_price": 12.35, + "valid_until": 1769512500000, "intent": "TRIM", + "note": "保垫减仓·垫厚先收"} +VEC_MSG_ID, VEC_TS, VEC_NONCE = "m-20260728-000123", 1769500000123, "9f2c8a1b7d3e4056" +VEC_PJ = ('{"instruction_id":"INS-20260728-a3f19c04","intent":"TRIM","limit_price":12.35,' + '"note":"保垫减仓·垫厚先收","qty":2000,"side":"sell","ts_code":"600000.SH",' + '"valid_until":1769512500000}') +VEC_SHA = "9fa3f56b92ef9d79290b46416899bdbebd93e7fdddbfe5bee5b5e09380bb7c63" +VEC_CANON = f"1\nplace_order\n{VEC_MSG_ID}\n{VEC_TS}\n{VEC_NONCE}\n{VEC_SHA}" +VEC_SIG = ("NzCILYw7ampmMtg41EBMubtlnDwr/jjGO+1cMTIxVdqaSaT779Kop5DAdADHL2aSbqrw2sp8" + "hCQb+apCACIYCw==") + + +def run(): + from app.core import ws_codec as wsc + + print("\n[A] 协议 §2.1.1 测试向量 (逐字节)") + + @case("payload_json: 键序/无空格/中文不转义") + def _(): + eq(wsc.payload_json(VEC_PAYLOAD), VEC_PJ, "紧凑 JSON 与协议向量不一致") + + @case("payload sha256") + def _(): + eq(wsc.payload_sha256(VEC_PAYLOAD), VEC_SHA) + + @case("canonical 六段拼接") + def _(): + got = wsc.canonical(v=1, type_="place_order", msg_id=VEC_MSG_ID, ts=VEC_TS, + nonce=VEC_NONCE, payload_hash=VEC_SHA) + eq(got, VEC_CANON) + eq(got.count("\n"), 5, "必须是 5 个换行 (6 段), 串尾不能有换行") + + @case("Ed25519 签名值") + def _(): + eq(wsc.sign(VEC_CANON, VEC_SEED), VEC_SIG) + + @case("由 seed 推出的公钥") + def _(): + eq(wsc.public_key_b64(VEC_SEED), VEC_PUB) + + @case("limit_price 序列化为 12.35 而非二进制尾数") + def _(): + eq(wsc.payload_json({"p": wsc.q2(12.35)}), '{"p":12.35}') + eq(wsc.payload_json({"p": wsc.q2(12.34 * 1.0008)}), '{"p":12.35}') + + print("\n[B] 签名往返与篡改检出") + + @case("自签自验通过") + def _(): + assert wsc.verify(VEC_CANON, VEC_SIG, VEC_PUB) + + @case("payload 改一个字 → 验签失败") + def _(): + bad = dict(VEC_PAYLOAD, qty=2100) + canon = wsc.canonical(v=1, type_="place_order", msg_id=VEC_MSG_ID, ts=VEC_TS, + nonce=VEC_NONCE, payload_hash=wsc.payload_sha256(bad)) + assert not wsc.verify(canon, VEC_SIG, VEC_PUB), "改了数量还能验过就等于没签名" + + @case("换一把密钥签 → 验签失败") + def _(): + other = "11" * 32 + sig = wsc.sign(VEC_CANON, other) + assert not wsc.verify(VEC_CANON, sig, VEC_PUB) + + @case("垃圾签名/垃圾公钥 → False 而不是抛异常") + def _(): + assert not wsc.verify(VEC_CANON, "not-base64!!", VEC_PUB) + assert not wsc.verify(VEC_CANON, VEC_SIG, "zzz") + + @case("非法 seed 报 SIG_INVALID") + def _(): + try: + wsc.sign(VEC_CANON, "abcd") + raise AssertionError("短 seed 应当报错") + except wsc.CodecError as e: + eq(e.code, "SIG_INVALID") + + print("\n[C] 信封组包与解析") + + @case("build → parse 往返一致") + def _(): + env = wsc.build("ack", {"instruction_id": "INS-1", "accepted": True}, + seed_hex=VEC_SEED, msg_id="m-1", ts=VEC_TS, nonce=VEC_NONCE, + corr_id="INS-1", seq=42) + back = wsc.parse(wsc.dumps(env), peer_pubkey_b64=VEC_PUB) + eq(back["type"], "ack") + eq(back["seq"], 42) + eq(back["corr_id"], "INS-1") + eq(back["payload"]["accepted"], True) + + @case("下行不带 seq (协议 §3: seq 仅上行必填)") + def _(): + env = wsc.build("ping", {}, seed_hex=VEC_SEED, msg_id="m-2") + assert "seq" not in env, "下行擅自编号会给对端补发逻辑增加歧义" + + @case("协议版本不认 → VERSION") + def _(): + env = wsc.build("ping", {}, seed_hex=VEC_SEED, msg_id="m-3") + env["v"] = 2 + try: + wsc.parse(wsc.dumps(env), peer_pubkey_b64=VEC_PUB) + raise AssertionError("应当拒绝未知版本") + except wsc.CodecError as e: + eq(e.code, "VERSION") + + @case("缺信封字段 → BAD_PARAM") + def _(): + env = wsc.build("ping", {}, seed_hex=VEC_SEED, msg_id="m-4") + env.pop("nonce") + try: + wsc.parse(wsc.dumps(env), peer_pubkey_b64=VEC_PUB) + raise AssertionError("应当拒绝缺字段") + except wsc.CodecError as e: + eq(e.code, "BAD_PARAM") + + @case("非法 JSON → BAD_PARAM") + def _(): + try: + wsc.parse("{不是 json", peer_pubkey_b64=VEC_PUB) + raise AssertionError("应当拒绝") + except wsc.CodecError as e: + eq(e.code, "BAD_PARAM") + + @case("未配对端公钥 → SIG_INVALID (不能当成验过)") + def _(): + env = wsc.build("pong", {}, seed_hex=VEC_SEED, msg_id="m-5") + try: + wsc.parse(wsc.dumps(env), peer_pubkey_b64="") + raise AssertionError("没有公钥就必须拒绝, 不能放行") + except wsc.CodecError as e: + eq(e.code, "SIG_INVALID") + + @case("补发消息 ts 很旧仍可解析 (PMS 不做时间窗校验)") + def _(): + # 协议 §6.1 规定补发「同 seq、同 msg_id、同内容、同签名」—— ts 还是原来那条的。 + # 若照抄 §2.2 的 30 秒时间窗, 断网十分钟后补发的消息会被整批丢掉。 + env = wsc.build("trade", {"trade_no": "QMT-1#1"}, seed_hex=VEC_SEED, + msg_id="m-old", ts=1_600_000_000_000, seq=7) + back = wsc.parse(wsc.dumps(env), peer_pubkey_b64=VEC_PUB) + eq(back["seq"], 7) + + print("\n[D] place_order 参数校验 (本地拦下, 不换对端一个 BAD_PARAM)") + + def _po(**kw): + base = dict(instruction_id="INS-20260728-aabbccdd", ts_code="600000.SH", + side="buy", qty=200, limit_price=12.345, valid_until=1769512500000, + intent="OPEN", note="x") + base.update(kw) + return wsc.place_order_payload(**base) + + def _reject(what, **kw): + try: + _po(**kw) + raise AssertionError(f"{what} 应当被拒") + except wsc.CodecError as e: + eq(e.code, "BAD_PARAM", what) + + @case("限价四舍五入到 2 位") + def _(): + eq(_po()["limit_price"], 12.35) + eq(_po(limit_price=2.675)["limit_price"], 2.68) # 不能被银行家舍入吃掉 + + @case("买入必须整百") + def _(): + _reject("买入 150 股", qty=150) + eq(_po(qty=300)["qty"], 300) + + @case("卖出允许零股尾数 (清仓)") + def _(): + eq(_po(side="sell", qty=2130)["qty"], 2130) + + @case("限价不接受 null / 非正数") + def _(): + _reject("限价为 None", limit_price=None) + _reject("限价为 0", limit_price=0) + + @case("代码必须点式") + def _(): + _reject("前缀式代码", ts_code="SH600000") + + @case("方向与数量非法") + def _(): + _reject("方向 hold", side="hold") + _reject("数量 0", qty=0) + + @case("note 截断到 200 字, intent 非法回落 OPEN") + def _(): + eq(len(_po(note="补" * 500)["note"]), 200) + eq(_po(intent="WHATEVER")["intent"], "OPEN") + + print("\n[E] seq 水位推进 (连续前缀语义)") + + @case("按序到达: 逐格前进") + def _(): + last, pend = 10, set() + for s in (11, 12, 13): + last, pend = wsc.next_watermark(last, pend, s) + eq((last, pend), (13, set())) + + @case("缺口: 水位卡住, 后到的先暂存") + def _(): + last, pend = 10, set() + last, pend = wsc.next_watermark(last, pend, 12) + eq(last, 10, "12 到了但 11 没到, 水位不能跨过去 —— 跨了 11 就永久丢了") + eq(pend, {12}) + + @case("缺口补齐: 一次性追上") + def _(): + last, pend = 10, {12, 13} + last, pend = wsc.next_watermark(last, pend, 11) + eq((last, pend), (13, set())) + + @case("重复帧: 水位不动") + def _(): + last, pend = wsc.next_watermark(20, set(), 15) + eq((last, pend), (20, set())) + last, pend = wsc.next_watermark(20, set(), 20) + eq((last, pend), (20, set())) + + # --- 冷启动基线。这一组是 2026-07-28 联调沙箱抓出来的真实缺陷的回归防线: + # 对端 seq 跨重启不回退 (§6.1), 首次对接时它可能已经在 10001; 而我们按 §4.1 传 + # last_seq=0。当时的实现会死等 seq 1、2、3…, 水位永远推不动、ack 永远发不出, + # 且不抛任何异常 —— 日志只有「上行乱序」在刷屏, 成交一条都没确认。 + @case("冷启动: 对端从 10001 起 → 直接以 10000 为基线, 不算缺口") + def _(): + eq(wsc.cold_start_baseline(0, 10001), (10000, False)) + + @case("已有水位且连续 → 基线不动") + def _(): + eq(wsc.cold_start_baseline(10230, 10231), (10230, False)) + eq(wsc.cold_start_baseline(10230, 10200), (10230, False)) # 补发的旧消息 + + @case("已有水位却出现缺口 → 推基线 + 判定为真缺口 (置 resync)") + def _(): + eq(wsc.cold_start_baseline(10230, 10500), (10499, True)) + + @case("冷启动基线接上后水位能正常前进") + def _(): + base, gap = wsc.cold_start_baseline(0, 10001) + assert not gap + last, pend = base, set() + for s in (10001, 10002, 10003): + last, pend = wsc.next_watermark(last, pend, s) + eq((last, pend), (10003, set())) + + print("\n[F] 去重键与成交自洽") + + @case("trade 的第二层去重键取 trade_no") + def _(): + eq(wsc.dedup_key("trade", {"trade_no": "QMT-88123#1"}), "trade:QMT-88123#1") + eq(wsc.dedup_key("order_update", {"instruction_id": "INS-1"}), None) + eq(wsc.dedup_key("trade", {}), None) + + @case("amount = price × qty 校验 (差 > 0.01 告警)") + def _(): + assert wsc.trade_amount_ok({"price": 12.34, "qty": 600, "amount": 7404.00}) + assert not wsc.trade_amount_ok({"price": 12.34, "qty": 600, "amount": 7000.00}) + assert not wsc.trade_amount_ok({"price": 12.34, "qty": 600}) + + @case("终态判定") + def _(): + for s in ("FILLED", "CANCELLED", "EXPIRED", "REJECTED"): + assert wsc.is_final(s), s + for s in ("ACCEPTED", "SUBMITTED", "PARTIAL", ""): + assert not wsc.is_final(s), s + + @case("只有 trade 需要 worker 入账") + def _(): + eq(tuple(wsc.NEEDS_LEDGER), ("trade",)) + assert wsc.T_ORDER_UPDATE not in wsc.NEEDS_LEDGER, \ + "order_update 只作状态跟踪 —— 拿它入账会和逐笔 trade 重复计数" + + print("\n[G] 严格单表访问守卫 (upsert 不得被误判成多表)") + + @case("ON DUPLICATE KEY UPDATE 的四条现存 upsert 全部放行") + def _(): + from app.db.session import assert_single_table + for sql in ( + "INSERT INTO pms_runtime_param (param_key, param_value, updated_by, updated_at) " + "VALUES (:k, :v, :by, :ts) ON DUPLICATE KEY UPDATE param_value = :v, " + "updated_by = :by, updated_at = :ts", + "INSERT INTO pms_position (ts_code, status, updated_at) VALUES " + "(:code, 'PLANNED', :ts) ON DUPLICATE KEY UPDATE updated_at = :ts", + "INSERT INTO pms_daily_report (ymd, report_json, created_at) VALUES " + "(:y, :r, :ts) ON DUPLICATE KEY UPDATE report_json = :r, created_at = :ts", + "INSERT INTO pms_industry_map (ts_code, industry, updated_at) VALUES " + "(:code, :ind, :ts) ON DUPLICATE KEY UPDATE industry = :ind, updated_at = :ts", + "INSERT INTO pms_qmt_inbox (seq, msg_id) VALUES (:s, :m) " + "ON DUPLICATE KEY UPDATE seq = seq", + ): + assert_single_table(sql) + + @case("真的多表 / JOIN / 逗号连表 仍然拦得住") + def _(): + from app.db.session import MultiTableSQL, assert_single_table + for sql in ("SELECT a.* FROM pms_position a JOIN pms_lot b ON a.ts_code = b.ts_code", + "SELECT * FROM pms_position, pms_lot WHERE 1 = 1", + "INSERT INTO pms_lot (qty) SELECT qty FROM pms_position"): + try: + assert_single_table(sql) + raise AssertionError(f"应当拦截: {sql[:50]}") + except MultiTableSQL: + pass + + @case("通道三表的 SQL 全部单表合规") + def _(): + from app.db.session import assert_single_table + for sql in ( + "SELECT * FROM pms_qmt_order WHERE status = 'QUEUED' ORDER BY id ASC LIMIT :n", + "UPDATE pms_qmt_order SET status = 'SENDING', updated_at = :ts " + "WHERE instruction_id = :iid AND status = 'QUEUED'", + "SELECT seq FROM pms_qmt_inbox WHERE seq > :n ORDER BY seq ASC LIMIT :m", + "SELECT status, COUNT(*) AS n FROM pms_qmt_order GROUP BY status", + "UPDATE pms_ws_state SET conn_state = 'STOPPED', heartbeat_at = NULL, " + "last_error = :le, updated_at = :ts WHERE id = :i", + ): + assert_single_table(sql) + + print("\n[H] dispatcher 纯助手") + + @case("valid_until 归一成 epoch 毫秒") + def _(): + from datetime import datetime + from app.services.dispatcher import _to_epoch_ms + dt = datetime(2026, 7, 28, 14, 35, 0) + eq(_to_epoch_ms(dt), int(dt.timestamp() * 1000)) + eq(_to_epoch_ms(1769512500000), 1769512500000) + eq(_to_epoch_ms(1769512500), 1769512500000, "传秒的也要认") + assert _to_epoch_ms(None) > 0, "给不出有效期时要兜底, 不能是无限期挂单" + + @case("子单 id 反推父指令") + def _(): + from app.services.dispatcher import _parent_of + eq(_parent_of("INS_20260728_600000SH_EXIT_01_D03"), + "INS_20260728_600000SH_EXIT_01") + eq(_parent_of("INS_20260728_600000SH_EXIT_01"), "INS_20260728_600000SH_EXIT_01") + eq(_parent_of("whatever_D07", parent_id="显式优先"), "显式优先") + + @case("连接类异常判别 (决定要不要重连、算不算发送失败次数)") + def _(): + from app.ws.runner import _is_conn_error + + class ConnectionClosed(Exception): + pass + assert _is_conn_error(ConnectionError("boom")) + assert _is_conn_error(OSError("boom")) + assert _is_conn_error(ConnectionClosed("bye")) + assert not _is_conn_error(ValueError("参数不对")) + + @case("密钥不进 ParamStore (协议 §10.1.1)") + def _(): + from app.services import param_store + snap_keys = {p["key"] for p in [{"key": k} for k in param_store._editable_keys()]} + for k in param_store.SECRET_KEYS: + assert k not in snap_keys, f"{k} 不该出现在可调参数里" + eq(param_store.get(k), "", f"{k} 必须读不到") + r = param_store.set_param(k, "deadbeef") + eq(r["ok"], False, f"{k} 必须改不了") + + +def main(): + print("=" * 62) + print("批次6: ws 直连通道纯逻辑") + print("=" * 62) + run() + print("\n" + "-" * 62) + print(f"通过 {len(PASS)} 例, 失败 {len(FAIL)} 例") + if FAIL: + for name, msg, tb in FAIL: + print(f"\n--- {name}\n{tb}") + sys.exit(1) + print("BATCH6 PASS") + + +if __name__ == "__main__": + main() diff --git a/scripts/test_wiring.py b/scripts/test_wiring.py index 90e9e85..4bf05aa 100644 --- a/scripts/test_wiring.py +++ b/scripts/test_wiring.py @@ -849,19 +849,24 @@ def _(): assert fake.instructions["INS_A2"]["status"] == "EXPIRED" -@case("下发通道·三模式描述与影子回执; 撤销走本地置状态") +@case("下发通道·两模式描述与影子回执; ws 未实现时明确拒发; 撤销走本地置状态") def _(): from app.services import dispatcher, executor, param_store fake = install_fakes() assert dispatcher.mode() == "shadow" + assert set(dispatcher.describe()["modes"]) == {"shadow", "ws"}, dispatcher.describe() d = dispatcher.dispatch(instruction_id="INS_1", ts_code="600000.SH", side="sell", qty=1000, limit_price=9.98) assert d["ok"] and d["ref"] == "manual:INS_1" and "人工" in d["note"], d - assert param_store.set_param("PMS_DISPATCH_MODE", "bad_mode")["ok"] is False - assert param_store.set_param("PMS_DISPATCH_MODE", "plan_x")["ok"] is True + # 已作废的通道名不能再被设进来 + for dead in ("bad_mode", "plan_x", "channel_y"): + assert param_store.set_param("PMS_DISPATCH_MODE", dead)["ok"] is False, dead + # ws 通道未实现: 必须 ok=False 而不是静默成功 —— executor 据此不改指令状态 + assert param_store.set_param("PMS_DISPATCH_MODE", "ws")["ok"] is True d2 = dispatcher.dispatch(instruction_id="INS_2", ts_code="600000.SH", side="sell", qty=1000, limit_price=9.98) - assert d2["ok"] and d2["mode"] == "shadow" and "无卖出通道" in d2["note"], d2 + assert d2["ok"] is False and d2["mode"] == "ws" and "尚未实现" in d2["error"], d2 + assert dispatcher.cancel(instruction_id="INS_2")["ok"] is False param_store.set_param("PMS_DISPATCH_MODE", "shadow") fake.insert_instruction(instruction_id="INS_C", origin_type="plan", origin_id="P1",