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",