qmt系统对接提交

This commit is contained in:
zlt 2026-07-28 15:48:57 +08:00
parent 4f463f8afd
commit c4833ba257
23 changed files with 3050 additions and 106 deletions

View File

@ -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=

View File

@ -2,7 +2,9 @@
> 用途:本清单由用户持有,与 QMT 侧(下游交易系统)协商。请下游按编号逐项答复(能提供/字段差异/时延/替代方案),答复直接回填本文档「答复」列,作为对接定稿依据。
> 背景架构调整后对下游的指挥权由决策系统bionic_trader移交持仓系统tradingSystem/PMS。`trading_order` / `trading_position` 两表仍归下游维护PMS 只读PMS 新增一条带数量的统一指令通道(见 B 部分)。
> 版本V1.02026-07-27
> 版本:**V2.02026-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 同时下单时会误判 |<br><br>**QMT 侧答复2026-07-28**:①**状态枚举**已给出 —— `submitted` 已提交 / `filled` **部分成交** / `completed` 已完成 / `pending` 挂单成功但 QMT 无响应 / `failed` 委托失败;撤单另有可靠终态。②**成交均价**将新增列。③**策略标识**字段表中已有,改造后可写入。<br><br>**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 侧守成);下游恢复后不补执行已过期指令 | |

531
QMT_WS_PROTOCOL.md Normal file
View File

@ -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-<yyyymmdd>-<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 | **股数**,非金额。买入恒为整百;卖出通常整百,**清仓时可能带零股尾数**(如 2130QMT 需支持 |
| `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 |

105
README.md
View File

@ -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 定稿,下发通道的唯一依据)。三份读完即可开工,不需要额外的口头背景。

View File

@ -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 末句)。

391
app/core/ws_codec.py Normal file
View File

@ -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-<yyyymmdd>-<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 123, 水位就永远推不动:
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

View File

@ -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):

387
app/repo/qmt_repo.py Normal file
View File

@ -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=NULLprocessed=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}

View File

@ -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), 表未建时会明确报错而非静默吞掉
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}

View File

@ -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"))

View File

@ -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

View File

@ -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。"""

0
app/ws/__init__.py Normal file
View File

635
app/ws/runner.py Normal file
View File

@ -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)

View File

@ -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 # 单笔敞口告警线 (占规模)

View File

@ -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;

View File

@ -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

View File

@ -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

View File

@ -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)} 项告警")

View File

@ -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():

View File

@ -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

View File

@ -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()

View File

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