qmt系统对接提交
This commit is contained in:
parent
4f463f8afd
commit
c4833ba257
11
.env.example
11
.env.example
|
|
@ -23,3 +23,14 @@ PMS_REDIS_URL=redis://:密码@192.168.16.150:6379/8
|
|||
|
||||
# ---- 管理页面 ----
|
||||
PMS_WEB_PORT=38100
|
||||
|
||||
# ---- QMT WebSocket 直连通道的密钥 (协议 QMT_WS_PROTOCOL.md §10.1.1) ----
|
||||
# 这两项是**密钥**, 只在这里维护: 不入库、不进 ParamStore、不上管理页面、不进代码。
|
||||
# 生成一对新密钥并打印公钥 (公钥带外交给 QMT 侧, 私钥不出机器):
|
||||
# docker compose run --rm pms-web python -c "\
|
||||
# import secrets; from app.core import ws_codec as w; \
|
||||
# s=secrets.token_hex(32); print('seed(私钥, 填下面):', s); \
|
||||
# print('pubkey(公钥, 给对方):', w.public_key_b64(s))"
|
||||
PMS_QMT_SIGN_SEED_HEX=
|
||||
# QMT 侧的 Ed25519 公钥 (base64), 用来验上行签名。上下行是两对不同的密钥, 互不复用。
|
||||
PMS_QMT_PEER_PUBKEY_B64=
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
> 用途:本清单由用户持有,与 QMT 侧(下游交易系统)协商。请下游按编号逐项答复(能提供/字段差异/时延/替代方案),答复直接回填本文档「答复」列,作为对接定稿依据。
|
||||
> 背景:架构调整后,对下游的指挥权由决策系统(bionic_trader)移交持仓系统(tradingSystem/PMS)。`trading_order` / `trading_position` 两表仍归下游维护,PMS 只读;PMS 新增一条带数量的统一指令通道(见 B 部分)。
|
||||
> 版本:V1.0(2026-07-27)
|
||||
> 版本:**V2.0(2026-07-28)** —— QMT 侧已就六项问询答复,本版回填答复并调整通道方案。
|
||||
>
|
||||
> **本版最大变化:B 部分的表通道方案已废止**。双方商定指令下发与执行回报改走 **WebSocket 双向长连接**,协议细节见同目录 [`QMT_WS_PROTOCOL.md`](./QMT_WS_PROTOCOL.md)。B 部分保留仅供追溯,不再作为实现依据。A 部分(只读数据)与 C 部分(切换约定)继续有效。
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -11,14 +13,21 @@
|
|||
| # | 数据项 | 需要的字段 | 期望更新时延 | 用途 | 答复 |
|
||||
|---|---|---|---|---|---|
|
||||
| A1 | 持仓快照 `trading_position` | 股票代码(点式)、持仓数量、**可用数量(T+1 可卖)**、成本价(如有)、冻结数量(如有) | 盘中 ≤ 5 分钟 | 账本对账基准。当前 PMS 只确认过 `stock_code` 列可用,**请提供该表完整字段定义(DDL)**,尤其确认是否已有"可用数量"列——若无,PMS 自行按 T+1 规则推算 | **PMS 侧实机探明(2026-07-27,待下游确认语义)**:该表 14 列,含 `stock_code / stock_name / total_quantity / available_quantity / frozen_quantity / cost_price / market_price / market_value / profit_loss`。**可用数量列已存在**(`available_quantity`),本项数据需求实质已满足,PMS 已按此列对接。仍请确认:①`available_quantity` 是否即 T+1 可卖口径 ②更新时延 ③`stock_code` 格式(点式/前缀式) |
|
||||
| A2 | 委托与成交 `trading_order` | 委托号、股票代码、方向、委托价、委托量、状态(**完整状态枚举文档**)、成交量、成交均价、委托/成交时间、来源标识 | 状态变更后 ≤ 1 分钟 | 成交回放入账(批次/成本)、指令执行确认。**请提供完整 DDL 与状态流转说明**(现掌握的 submitted/filled/completed/pending/failed 为推断口径,需正式确认) | **PMS 侧实机探明**:该表 20 列,已见 `order_id / strategy_id / stock_code / stock_name / order_side / order_quantity / order_price / target_price / order_status` 等。**仍缺两项,是本清单里最卡 PMS 的**:①状态枚举的正式定义 ②**成交来源标识**(哪条委托对应 PMS 的哪条指令)。在来源标识到位前,PMS 只能按「同股同向 + 下发早于成交 + FIFO」贪心认领,认领不上即判外部成交并告警——人工与 PMS 同时下单时会误判 |
|
||||
| A3 | 账户资金快照(**新增需求**) | 总资产、可用资金、冻结资金、当日卖出可用资金(T+0 回笼) | 盘中 ≤ 5 分钟;日终必须 | 总规模校准与买入前资金校验。形式不限:新表 / 现有表 / HTTP 接口均可,请给出可行方案 | |
|
||||
| A4 | 成交回报明细(可选) | 若 A2 已含逐笔或聚合成交(成交量/均价),本项可免;否则请提供逐笔成交表 | 同 A2 | 部分成交场景的精确入账 | |
|
||||
| A5 | 上游买入计划 `trading_buy_plan` | PMS 将作为该表的承接方(替代原决策系统 ENTRY_GATE 的角色)。请确认:①下游当前是否仍轮询 `is_active=6` 自动挂单?②切换后是否可以**停止**该轮询(统一走 B1 通道),或保留作为过渡(方案 X) | — | 旧通道处置(见 C2) | |
|
||||
| A2 | 委托与成交 `trading_order` | 委托号、股票代码、方向、委托价、委托量、状态(**完整状态枚举文档**)、成交量、成交均价、委托/成交时间、来源标识 | 状态变更后 ≤ 1 分钟 | 成交回放入账(批次/成本)、指令执行确认。**请提供完整 DDL 与状态流转说明**(现掌握的 submitted/filled/completed/pending/failed 为推断口径,需正式确认) | **PMS 侧实机探明**:该表 20 列,已见 `order_id / strategy_id / stock_code / stock_name / order_side / order_quantity / order_price / target_price / order_status` 等。**仍缺两项,是本清单里最卡 PMS 的**:①状态枚举的正式定义 ②**成交来源标识**(哪条委托对应 PMS 的哪条指令)。在来源标识到位前,PMS 只能按「同股同向 + 下发早于成交 + FIFO」贪心认领,认领不上即判外部成交并告警——人工与 PMS 同时下单时会误判 |<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 侧守成);下游恢复后不补执行已过期指令 | |
|
||||
|
|
|
|||
|
|
@ -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 | **股数**,非金额。买入恒为整百;卖出通常整百,**清仓时可能带零股尾数**(如 2130),QMT 需支持 |
|
||||
| `limit_price` | number | **必填**,2 位小数。PMS 的择时模块负责定价,QMT 不做价格决策。不接受 null |
|
||||
| `valid_until` | int | 有效期截止,epoch 毫秒。到期仍未全成,QMT **自动撤单**并回 `EXPIRED` 终态 |
|
||||
| `intent` | string | `OPEN`/`FILL`/`ADD`/`DCA`/`TRIM`/`EXIT`/`T0`,仅归类 |
|
||||
| `note` | string | ≤ 200 字,人工看盘用,QMT 原样落库即可 |
|
||||
|
||||
`valid_until` 是这套设计里替代「10 秒轮询撤单」的关键:PMS 不必盯着撤,到点 QMT 自己收。
|
||||
|
||||
### 4.3 `cancel_order` —— 撤单
|
||||
|
||||
```json
|
||||
{ "cancel_id": "CXL-20260728-71b2ef39", "instruction_id": "INS-20260728-a3f19c04" }
|
||||
```
|
||||
|
||||
QMT 撤在途部分,已成交部分保留。撤单完成后该指令进入 `CANCELLED` 终态(若撤单前已全成,则回 `reject{code:"ALREADY_FINAL"}` 并附当前状态)。
|
||||
|
||||
### 4.4 查询类
|
||||
|
||||
| type | payload | 回应 |
|
||||
|---|---|---|
|
||||
| `query_positions` | `{}` | `snapshot`(`kind: "positions"`) |
|
||||
| `query_funds` | `{}` | `snapshot`(`kind: "funds"`) |
|
||||
| `query_orders` | `{"date":"2026-07-28"}` 或 `{"instruction_id":"..."}` | `snapshot`(`kind: "orders"`) |
|
||||
|
||||
查询消息同样需要签名,但**不需要** `instruction_id`(无副作用,天然幂等)。
|
||||
|
||||
### 4.5 `ack_seq` —— 上行消息的累积确认
|
||||
|
||||
```json
|
||||
{ "seq": 10450 }
|
||||
```
|
||||
|
||||
**累积确认**:表示 `seq ≤ 10450` 的上行消息 PMS 已全部落库,QMT 可安全清理 Redis 中这部分消息。语义同 TCP 的累积 ACK——确认 10450 即隐含确认了之前所有消息,中间漏发一条不会被误确认。
|
||||
|
||||
发送时机:PMS **每落库 20 条或每 2 秒**(取先到)发一次,只发当前连续水位。**确认前必须已持久化**,不能收到就 ack——否则 QMT 清理了消息、PMS 又崩在落库前,那段数据就永久丢了。
|
||||
|
||||
断线重连时以 `hello.last_seq` 为准,`ack_seq` 只用于让 QMT 及时释放存储,两者不冲突。
|
||||
|
||||
### 4.6 `ping`
|
||||
|
||||
`payload: {}`。QMT 立即回 `pong`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 上行:QMT → PMS
|
||||
|
||||
所有上行消息带 `seq`,PMS 按 `seq` 顺序消费并持久化水位。
|
||||
|
||||
### 5.1 `hello_ack`
|
||||
|
||||
```json
|
||||
{ "server_version": "...", "server_seq": 10450, "resume_from": 10231, "resync_required": false }
|
||||
```
|
||||
|
||||
`resync_required: true` 表示 QMT 无法补齐 PMS 请求的区间(日志已滚动),PMS 收到后必须走全量快照对账(见 6.2)。
|
||||
|
||||
### 5.2 `ack` —— 指令已受理
|
||||
|
||||
```json
|
||||
{ "instruction_id": "INS-...", "accepted": true, "duplicate": false,
|
||||
"broker_order_id": "QMT-88123", "status": "ACCEPTED" }
|
||||
```
|
||||
|
||||
`duplicate: true` 时 `status` 反映**首次执行**该指令的当前状态,`broker_order_id` 也是首次那笔的。
|
||||
|
||||
### 5.3 `reject` —— 指令被拒
|
||||
|
||||
```json
|
||||
{ "instruction_id": "INS-...", "code": "LIMIT_UP", "reason": "涨停价无法买入", "retryable": false }
|
||||
```
|
||||
|
||||
### 5.4 `order_update` —— 委托状态变更
|
||||
|
||||
每次状态发生变化推一条,**终态必须推且只推一次**。
|
||||
|
||||
```json
|
||||
{
|
||||
"instruction_id": "INS-...", "broker_order_id": "QMT-88123",
|
||||
"status": "PARTIAL", "final": false,
|
||||
"cum_qty": 600, "cum_avg_price": 12.34, "leaves_qty": 1400,
|
||||
"update_time": 1769500123456
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `cum_qty` | **本委托**累计成交股数。口径明确为本委托,不是当日、不是该股票持仓 |
|
||||
| `cum_avg_price` | **本委托**累计成交均价,2 位小数 |
|
||||
| `leaves_qty` | 未成交剩余 = `qty - cum_qty`。撤单/过期后应为 0 |
|
||||
| `final` | 是否终态。终态后该 `instruction_id` 不再有任何 `order_update` |
|
||||
|
||||
> 这三个字段是为了消灭原 `trading_log` 示例里 `total_filled=5000` 而 `order_quantity=600` 那种口径歧义。凡是累计量,一律以「本委托」为准。
|
||||
|
||||
### 5.5 `trade` —— 逐笔成交
|
||||
|
||||
**每一笔成交推一条**,只描述这一笔,不含累计:
|
||||
|
||||
```json
|
||||
{
|
||||
"instruction_id": "INS-...", "broker_order_id": "QMT-88123",
|
||||
"trade_no": "QMT-88123#1",
|
||||
"ts_code": "600000.SH", "side": "sell",
|
||||
"qty": 600, "price": 12.34, "amount": 7404.00,
|
||||
"fee": 3.21, "fee_estimated": true, "trade_time": 1769500123456
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `trade_no` | 该笔成交的唯一编号。**格式固定为 `{broker_order_id}#{n}`**,`n` 为该委托内成交序号,从 1 起递增。券商原生只提供委托号(`order_id`),一次委托多次成交时委托号相同、无法区分,故由 QMT 按此规则拼出笔号。跨重启唯一性由「委托号唯一 + n 从该委托已回报笔数续接」保证 |
|
||||
| `qty` / `price` | **本笔**成交股数与价格 |
|
||||
| `amount` | `price × qty`,QMT 给出,PMS 会校验一致性(差异 > 0.01 元告警) |
|
||||
| `fee` | 本笔手续费(佣金 + 印花税 + 过户费合计)。QMT 侧按费率**估算**,与实际扣费可能有误差 |
|
||||
| `fee_estimated` | `true` 表示 `fee` 为估算值。PMS 据此决定是否把它计入成本 |
|
||||
|
||||
**关于手续费的处理口径**(QMT 侧已说明逐笔费用未必精确):PMS **不把 `fee` 摊进持仓成本**,成本价只由 `price × qty` 决定;费用单独记为一笔现金流出。这样估算误差不会污染摊薄成本,进而不会污染安全垫(cushion)与保垫减仓的判断——这是 PMS 里最不能被噪声干扰的一条计算链。真实费用在日终用资金快照反推校准(总资产变动 − 成交净额 = 当日实际费用),只入现金账,不回溯改成本。
|
||||
|
||||
**PMS 的账本以 `trade` 为唯一入账依据**,`order_update` 只用于状态跟踪与最终核对(用终态的 `cum_qty` 校验逐笔加总是否相等,不等则告警)。这样即使某条 `order_update` 丢失也不会记错账。
|
||||
|
||||
去重是双层的:**第一层按 `seq`**(补发时同一条消息 `seq` 不变,见 §6.1),**第二层按 `trade_no`**。任一层命中即丢弃。第二层的意义是防住 `seq` 实现出 bug 的场景,两层都命中才入账。
|
||||
|
||||
### 5.6 `persist_result` —— 落库结果
|
||||
|
||||
QMT 把成交写入 `trading_order` / `trading_position` / `trading_log` 之后回一条,供 PMS 确认下游表已同步(trading_service 的可视化统计依赖这些表)。
|
||||
|
||||
```json
|
||||
{
|
||||
"instruction_id": "INS-...", "scope": "trade",
|
||||
"trade_no": "T-20260728-000991",
|
||||
"ok": true,
|
||||
"tables": { "trading_order": "QMT-88123", "trading_log": 88231, "trading_position": "600000.SH" },
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
- `scope`: `trade`(逐笔落库)/ `final`(该指令终态落库完成)
|
||||
- `ok: false` 时 `error` 给出原因。**落库失败不影响成交事实**,PMS 照常入账,但会记一条下游不一致告警。
|
||||
|
||||
### 5.7 `snapshot` —— 查询回应
|
||||
|
||||
```json
|
||||
{ "kind": "positions", "as_of": 1769500000000, "items": [
|
||||
{ "ts_code": "600000.SH", "total_qty": 6000, "avail_qty": 4000, "frozen_qty": 0,
|
||||
"cost_price": 11.80, "market_price": 12.34 }
|
||||
]}
|
||||
```
|
||||
|
||||
```json
|
||||
{ "kind": "funds", "as_of": 1769500000000, "data": {
|
||||
"total_asset": 2013456.78, "available_cash": 312450.10,
|
||||
"frozen_cash": 12000.00, "sell_return_today": 48900.00, "market_value": 1701006.68
|
||||
}}
|
||||
```
|
||||
|
||||
`sell_return_today`(当日卖出回笼,T+0 可用)是 PMS 买入前资金校验必需的,请务必提供。
|
||||
|
||||
### 5.8 `position_update` / `funds_update` —— 主动推送
|
||||
|
||||
QMT 侧已确认支持。有变化即推,字段同 `snapshot` 的单项。PMS 的 5 分钟 `query_*` 轮询退为兜底手段,不再是主要来源。
|
||||
|
||||
### 5.9 `pong`
|
||||
|
||||
`payload: {}`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 断线、补发与对账
|
||||
|
||||
WebSocket 相比数据库轮询,最大的代价是**断线期间的消息没地方补读**。因此以下三条是本协议的安全底座,缺一不可。
|
||||
|
||||
### 6.1 序号补发
|
||||
|
||||
QMT 侧为每条上行消息分配单调递增 `seq` 并**持久化到 Redis**(跨重启、跨交易日不回退)。PMS 重连时在 `hello` 里带 `last_seq`,QMT 从 `last_seq + 1` 起按序补发,补完再推实时消息。
|
||||
|
||||
补发的消息与首次推送**完全一致**(同 `seq`、同 `msg_id`、同内容、**同签名**),PMS 侧按 `seq` 与 `trade_no` 双重去重。
|
||||
|
||||
**消息保留策略**:QMT 收到 `ack_seq{seq: N}`(见 §4.5)后可清理 `seq ≤ N` 的消息。除此之外还需一条时间下限兜底——**已确认的消息也至少保留 7 天**,防止 PMS 侧数据库回滚或误删后无从追溯。
|
||||
|
||||
> 联调/测试期 QMT 侧 Redis 不设过期时间(双方已确认),上线前需按上述策略配置,并确认 Redis 本身开启持久化(AOF 或 RDB)——`seq` 与消息体都在 Redis 里,Redis 一旦丢数据,补发能力就没了,只能退回全量对账。
|
||||
|
||||
### 6.2 快照对账(兜底)
|
||||
|
||||
即使有补发,PMS 仍会:盘中每 **5 分钟**发一次 `query_positions` + `query_funds`,与本地账本比对;日终收盘后做一次全量对账。任何一次比对不一致,PMS 记录差异并按严重度决定是否冻结自主动作(这是 PMS 侧既有的对账引擎,QMT 只需保证快照准确)。
|
||||
|
||||
`resync_required: true` 或发现 `seq` 缺口不可补时,PMS 强制走全量对账,对不齐则停止一切自主动作、告警等人工裁决。
|
||||
|
||||
### 6.3 故障时双方的行为(「故障即守成」)
|
||||
|
||||
| 情形 | PMS 行为 | QMT 行为 |
|
||||
|---|---|---|
|
||||
| PMS 断连 | 重连并补发 | **不做任何自主决策**。在途委托按各自 `valid_until` 到期自动撤单,不新下单、不替 PMS 判断 |
|
||||
| QMT 断连/不可用 | **停发一切增持指令**,仅保留减持路径;告警 | — |
|
||||
| 对账不一致 | 冻结自主动作,等人工 | 配合提供快照 |
|
||||
|
||||
减持在故障期仍放行,是 PMS 既有口径(冻结与刹车只挡增持不挡减持),这里保持一致。
|
||||
|
||||
---
|
||||
|
||||
## 7. 状态机与枚举
|
||||
|
||||
### 7.1 协议状态(本协议使用的正式枚举)
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
place_order → │ ACCEPTED │ QMT 已受理,未报盘
|
||||
└──────┬───────┘
|
||||
↓
|
||||
┌──────────────┐
|
||||
│ SUBMITTED │ 已报盘,交易所已接受
|
||||
└──────┬───────┘
|
||||
┌─────────┼─────────┐
|
||||
↓ ↓ ↓
|
||||
┌─────────┐ ┌────────┐ ┌───────────┐
|
||||
│ PARTIAL │→│ FILLED │ │ CANCELLED │ ← cancel_order 或 valid_until
|
||||
└────┬────┘ └────────┘ └───────────┘
|
||||
└──────────→ ┌──────────┐
|
||||
│ EXPIRED │ valid_until 到期,QMT 自动撤
|
||||
└──────────┘
|
||||
任意阶段失败 → ┌──────────┐
|
||||
│ REJECTED │
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
**终态**:`FILLED` / `CANCELLED` / `EXPIRED` / `REJECTED`。终态唯一、不可再变、必须推送一次 `order_update{final:true}`。
|
||||
|
||||
`CANCELLED` 与 `EXPIRED` 均可能带部分成交(`cum_qty > 0`),这是正常情况,PMS 会如实入账。
|
||||
|
||||
### 7.2 与现有 `trading_order.order_status` 的映射
|
||||
|
||||
QMT 侧落库时沿用现有表字段,映射关系固定如下:
|
||||
|
||||
| 协议状态 | `trading_order.order_status` | 备注 |
|
||||
|---|---|---|
|
||||
| `ACCEPTED` | `pending` | 挂单成功、QMT 尚无响应 |
|
||||
| `SUBMITTED` | `submitted` | 已提交 |
|
||||
| `PARTIAL` | `filled` | ⚠️ 现有表里 `filled` 表示**部分成交**,命名易误解,协议层不复用该词 |
|
||||
| `FILLED` | `completed` | 已完成 |
|
||||
| `CANCELLED` | `cancelled` | 表已有 `cancel_time` 列,请同时写入 |
|
||||
| `EXPIRED` | `cancelled` | 落库层**不区分**主动撤与到期撤,见下方说明 |
|
||||
| `REJECTED` | `failed` | 委托失败 |
|
||||
|
||||
> 提醒:现有表里 `filled` = 部分成交这个命名,未来接手的人几乎必然会读成「已全部成交」。协议层用 `PARTIAL` / `FILLED` 把它区分开,落库时再映射回去。**PMS 不再直接读 `order_status` 判断成交**,一律以 `trade` 消息为准。
|
||||
|
||||
**关于 `CANCELLED` 与 `EXPIRED` 的区分**:落库层两者都写 `cancelled`,靠「成交数量 + 订单状态」无法区分——两种情况都可能带部分成交,组合完全一样。但这不需要 QMT 额外配合,因为**PMS 自己知道**:主动撤是 PMS 发过 `cancel_order` 的,到期撤是 PMS 没发过撤单却收到了终态。PMS 侧按本地是否发过撤单来标注,不依赖下游表。
|
||||
|
||||
因此**协议层 `order_update.status` 仍必须区分 `CANCELLED` 与 `EXPIRED`**(QMT 知道自己是被要求撤的还是到点自动撤的,如实填写即可,成本为零),只有落库时才合并。事后统计以 PMS 账本为准。
|
||||
|
||||
### 7.3 拒绝码 `code`
|
||||
|
||||
| code | 含义 | `retryable` |
|
||||
|---|---|---|
|
||||
| `SIG_INVALID` | 签名验证失败 | 否 |
|
||||
| `TS_SKEW` | 时间戳超出容忍窗口 | 是(对时后重发) |
|
||||
| `VERSION` | 协议版本不支持 | 否 |
|
||||
| `DUP_INSTRUCTION` | 幂等键重复(正常经 `ack{duplicate}` 表达,此码仅用于异常场景) | 否 |
|
||||
| `BAD_PARAM` | 字段缺失/类型错/数量非整百/价格精度超限 | 否 |
|
||||
| `UNKNOWN_CODE` | 股票代码不存在 | 否 |
|
||||
| `SUSPENDED` | 停牌 | 否 |
|
||||
| `LIMIT_UP` / `LIMIT_DOWN` | 涨/跌停无法成交该方向 | 是(换价重发) |
|
||||
| `INSUFFICIENT_CASH` | 可用资金不足 | 是 |
|
||||
| `INSUFFICIENT_POSITION` | 可用持仓不足(T+1 未解冻/已冻结) | 是 |
|
||||
| `NOT_TRADING_TIME` | 非交易时段 | 是 |
|
||||
| `ALREADY_FINAL` | 指令已终态,无法撤单 | 否 |
|
||||
| `BROKER_ERROR` | 券商柜台返回错误,`reason` 带原文 | 视情况 |
|
||||
| `INTERNAL` | QMT 内部错误 | 是 |
|
||||
|
||||
QMT 侧如有本表未覆盖的拒绝场景,请在定稿时补充,**不要归入 `INTERNAL`**——PMS 会对 `INTERNAL` 触发降级告警。
|
||||
|
||||
---
|
||||
|
||||
## 8. 数量、价格与代码口径(易错点集中说明)
|
||||
|
||||
| 项 | 约定 |
|
||||
|---|---|
|
||||
| 数量单位 | **股**,不是手、不是金额。`trading_buy_plan.buy_amount` 那套金额口径在本通道不再使用 |
|
||||
| 整百规则 | 买入必整百;卖出整百,**清仓允许零股尾数** |
|
||||
| 价格精度 | 2 位小数。超出精度直接 `BAD_PARAM`,不做四舍五入(避免双方舍入方向不一致) |
|
||||
| 累计口径 | 凡 `cum_*` 一律「本委托」口径 |
|
||||
| 金额 | `amount = price × qty`,不含费用;`fee` 单列 |
|
||||
| 股票代码 | 点式 `600000.SH`。PMS 内部与本协议统一使用点式 |
|
||||
| 时间 | epoch 毫秒整数;文字展示为 Asia/Shanghai |
|
||||
| 一条指令 = 一张委托 | QMT **不得**自行把一条指令拆成多张委托。确需拆单请在定稿前提出,协议要相应扩展 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 联调与灰度
|
||||
|
||||
| 阶段 | 内容 | 通过标准 |
|
||||
|---|---|---|
|
||||
| S1 握手与心跳 | 连接、签名、`ping`/`pong`、断线重连 | 断网 60 秒后自动恢复,`seq` 无缺口 |
|
||||
| S2 影子模式 | PMS 正常发指令,QMT **只回报不下单**(`ack` + 模拟 `order_update`) | 全链路消息格式互通,PMS 账本与模拟回报一致 |
|
||||
| S3 小额实盘 | 单笔 ≤ 100 股,覆盖:全成、部分成交、主动撤单、到期过期、各类拒绝 | 五类场景各至少 1 次,账本与快照对账零差异 |
|
||||
| S4 灰度并行 | 旧通道只读不执行,新通道实际执行,观察 N 个交易日 | 日终对账连续 N 日一致 |
|
||||
| S5 切换 | 旧通道**已于 2026-07-28 停用**,本阶段即新通道全量承接 | — |
|
||||
|
||||
> 旧通道已先行停掉,因此 S4 的「灰度并行」不再有旧通道可比对——过渡期实际形态是:PMS 影子运行 + 人工在 QMT 侧执行。S2/S3 通过后即可切 `PMS_DISPATCH_MODE=ws`,S4 改为「小仓位实跑数日、日终对账连续一致」再放开全量。
|
||||
|
||||
---
|
||||
|
||||
## 10. Q1~Q11 答复与处置(QMT 侧已答复,2026-07-28)
|
||||
|
||||
| # | 事项 | QMT 答复 | 处置 |
|
||||
|---|---|---|---|
|
||||
| Q1 | 旧通道停用时点 | **旧通道停止**,时点为**立即** | ✅ 已闭环,见 §10.1 R1。停用期间 PMS 影子运行,需人工执行 |
|
||||
| Q2 | `filled_quantity` 是否可靠 | 可靠,但**只作故障校验**,主依据仍是 `order_update` | ✅ 与本协议设计一致。PMS 账本以 `trade` 为准,`order_update` 作状态跟踪,该列仅在对账出现分歧时作第三方参照 |
|
||||
| Q3 | `trading_log` 的 JSON 落在哪列、`total_filled` 口径 | 落在 **`extra_data`** 列;`total_filled` 是**单笔订单的下单数量** | ⚠️ **答复与示例数据矛盾**:示例第二条 `total_filled=5000` 而 `order_quantity=600`,若它是「单笔下单数量」两者应相等。处置见 §10.2 |
|
||||
| Q4 | `EXPIRED` 如何落库 | 用 order 表的已成交数量 + 订单状态**联合判断** | ⚠️ 该组合区分不了主动撤与到期撤(两者都是 `cancelled` 且都可能带部分成交)。**已自行化解**:PMS 按本地是否发过 `cancel_order` 自记,不依赖下游表;协议层 `status` 仍区分。见 §7.2 |
|
||||
| Q5 | 签名方案 | **Ed25519** | ✅ 已锁定。公钥带外交换列入部署清单 §10.1.1 |
|
||||
| Q6 | 逐笔手续费能否提供 | 逐笔手续费与印花税**不一定准确**,与实际费率可能有误差 | ✅ 已按此调整口径:**费用不摊进持仓成本**,只记现金流出,日终用资金快照反推校准。避免估算误差污染安全垫。见 §5.5。新增 `fee_estimated` 字段 |
|
||||
| Q7 | `trade_no` 券商是否原生提供 | 原生有 **`order_id`** | ⚠️ `order_id` 是**委托号**不是成交笔号——Q11 已确认一次委托多次回报,同一委托各笔的 `order_id` 相同,无法逐笔去重。**已定规则**:`trade_no = {broker_order_id}#{n}`,QMT 拼接,成本极低。见 §5.5 |
|
||||
| Q8 | 上行消息保留与 `seq` 持久化 | 消息序列存 **Redis**;ws 中断后下次上线**按最后未 ack 的消息**续发;测试期 Redis 不设过期 | ⚠️ 「按未 ack 续发」意味着需要 PMS 逐条确认,而**原协议没有定义 ack 机制**——已补:新增下行消息 **`ack_seq`(累积确认)**,见 §4.5;保留策略与 Redis 持久化要求见 §6.1 |
|
||||
| Q9 | 服务地址、端口、白名单 | **`ws://192.168.16.98:8080`** | ✅ 已闭环,见 §10.1 R2 与 §10.1.1 部署清单 |
|
||||
| Q10 | 是否支持主动推送 | **支持** | ✅ `position_update` / `funds_update` 按 §5.8 实现,PMS 的 5 分钟轮询退为兜底 |
|
||||
| Q11 | 一条指令是否会被拆成多张委托 | **一次委托,多次返回**(成交信息 + 最终状态) | ✅ 确认「一指令一委托」成立,多次返回正是 §5.5 逐笔成交 + §5.4 状态更新的模型。`broker_order_id` 保持单值 |
|
||||
|
||||
### 10.1 R1 / R2 答复(2026-07-28,已闭环)
|
||||
|
||||
| # | 事项 | 答复 | 处置 |
|
||||
|---|---|---|---|
|
||||
| R1 | 旧通道停用时点 | **现在直接停掉** | ✅ 即刻生效。下游停止:①直接执行决策系统卖出信号 ②轮询 `trading_buy_plan` 自动挂单。**注意由此产生的空窗**:新通道实现前 PMS 处于影子运行(`PMS_DISPATCH_MODE=shadow`),指令照常生成、过闸、记账,但需**人工在 QMT 侧执行**,成交由回放认领。这段时间没有任何系统会自动下单——这是安全的,但要知道它是这个状态 |
|
||||
| R2 | 服务端点与白名单 | **`ws://192.168.16.98:8080`**,白名单即该地址 | ✅ 已写入 §1 与 `config/settings.py` 的 `PMS_QMT_WS_URL`。**部署期还需两步**:①把 PMS 宿主机的内网 IP 报给 QMT 侧加入其入站白名单(PMS 跑在容器里,须用 host 网络或固定出口 IP,否则重启换 IP 会被挡)②双方交换 Ed25519 公钥(各自 `ssh`/U 盘等带外方式送达,私钥不出机器) |
|
||||
|
||||
### 10.1.1 部署前的检查清单
|
||||
|
||||
- [ ] PMS 宿主机内网 IP 已确定并报给 QMT 侧加白
|
||||
- [ ] 双方 Ed25519 公钥已交换,用 §2.1.1 的测试向量互验通过
|
||||
- [ ] QMT 侧 `192.168.16.98:8080` 已绑定内网网卡、未暴露公网
|
||||
- [ ] QMT 侧 Redis 已开启持久化(AOF/RDB),过期策略按 §6.1 配置
|
||||
- [ ] PMS 侧 `PMS_QMT_SIGN_SEED_HEX` / `PMS_QMT_PEER_PUBKEY_B64` 已从 `.env` 注入(**不入库、不进 ParamStore、不写进代码**)
|
||||
|
||||
### 10.2 `total_filled` 的处置(PMS 侧自行规避,不再追问)
|
||||
|
||||
对方答「`total_filled` 是单笔订单的下单数量」,但示例数据里 `total_filled=5000` 而 `order_quantity=600`、`traded_volume=600`,三者对不上。同时可以验证:`47.02 × 600 = 28212 = traded_amount`,说明 `traded_price` / `traded_volume` / `traded_amount` 三个字段**自洽且可信**。
|
||||
|
||||
由于主链路已走 WebSocket、账本以 `trade` 消息为准,`trading_log` 仅用于事后人工核对,因此不再为此多耗一轮:**PMS 事后核对只读 `traded_volume` / `traded_price` / `traded_amount`,不读 `total_filled`**。该列口径存疑,已在此记录,避免后来者踩坑。
|
||||
|
||||
---
|
||||
|
||||
## 11. 变更记录
|
||||
|
||||
| 版本 | 日期 | 变更 |
|
||||
|---|---|---|
|
||||
| V0.9 | 2026-07-28 | 初稿。依据 QMT 侧对《QMT_INTERFACE_REQUIREMENTS.md》六项问询的答复,将指令通道由表轮询改为 WebSocket 双向长连接;补齐签名、幂等、序号补发与对账兜底 |
|
||||
| V0.9.1 | 2026-07-28 | 按双方最终意见调整:①**不启用 TLS**,改为明文 `ws://` + 内网 IP 白名单,并补充取舍说明与相应的安全补偿(签名升格为必需、承担身份认证职责)②`hello` 去掉 `client_version` ③签名方案锁定 Ed25519(原 Q5 二选一取消)④补齐 §2.1.1 测试向量与参考实现,消除规范化串的实现歧义 |
|
||||
| **V1.0** | 2026-07-28 | **定稿**。回填 R1/R2:旧通道**即刻停用**、端点定为 `ws://192.168.16.98:8080`;补 §10.1.1 部署前检查清单(白名单、公钥交换、Redis 持久化、密钥只走 `.env`);调整 §9 灰度策略(旧通道已停,无从并行,改为小仓位实跑) |
|
||||
| V0.9.2 | 2026-07-28 | 回填 QMT 侧 Q1~Q11 答复(§10),并据此补齐四处:①新增下行消息 **`ack_seq` 累积确认**(§4.5)——对方的「按最后未 ack 的消息续发」实现依赖它,原协议缺失 ②`trade_no` 定为 `{broker_order_id}#{n}`(§5.5)——券商原生只有委托号,一委托多成交时无法逐笔去重 ③手续费**不摊入持仓成本**、新增 `fee_estimated` 标志(§5.5)——对方明确逐笔费用可能有误差,避免污染安全垫 ④`CANCELLED`/`EXPIRED` 改由 PMS 侧自记(§7.2),不再要求下游区分。另记录 `total_filled` 口径存疑及规避方式(§10.2)。剩余阻塞项收敛为 R1/R2 两条(§10.1) |
|
||||
105
README.md
105
README.md
|
|
@ -11,8 +11,9 @@
|
|||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| `POSITION_MGMT_DESIGN.md` | 总体设计 **V0.4(定稿,开发启动)**:命令系统与管理页面/账本/仓位框架/动作引擎/两道关口/择时执行/下游通道。功能一次性开发,上线按依赖分三步切换 |
|
||||
| `QMT_INTERFACE_REQUIREMENTS.md` | 与 QMT 侧下游系统协商用的数据与接口需求清单(含资金快照、统一指令通道建议 DDL),按编号答复回填 |
|
||||
| `ddl_pms_v1.sql` | PMS 全部自有表建表语句(153 代理侧,10 张) |
|
||||
| `QMT_WS_PROTOCOL.md` | **PMS ↔ QMT WebSocket 指令与回报协议 V1.0(定稿)**:传输与重连、Ed25519 签名与幂等、消息集、状态机、断线补发与对账兜底、部署前检查清单。**这是下发通道的唯一实现依据** |
|
||||
| `QMT_INTERFACE_REQUIREMENTS.md` | 与 QMT 侧的数据与接口需求清单 **V2.0**:A 部分(只读数据)与 C 部分(切换约定)有效;**B 部分的表通道已废止**,改由上面的 ws 协议承担 |
|
||||
| `ddl_pms_v1.sql` | PMS 全部自有表建表语句(153 代理侧,**13 张**:设计 §11 的 10 张 + ws 通道 3 张) |
|
||||
| `config/settings.py` | 配置(基础设施键名对齐 bionic;业务参数为初值,页面调参持久化到 `pms_runtime_param` 后优先) |
|
||||
|
||||
## 模块地图
|
||||
|
|
@ -30,29 +31,33 @@ app/
|
|||
action_engine.py 动作引擎: FILL 回踩补足 / ADD 盈利加仓 / DCA 补仓 / TRIM 保垫减仓
|
||||
signal_rules.py 决策系统两条信号流的解析与消化口径 (含置信度尺度归一)
|
||||
tradedays.py 交易日历: 调度守卫与执行窗口计算
|
||||
ws_codec.py QMT 协议编解码: 规范化串 / Ed25519 签名验签 / 信封 / seq 水位推进
|
||||
db/session.py 三库连接 + **严格单表访问守卫** (JOIN/逗号连表/跨表子查询一律拒绝)
|
||||
repo/ 单表数据访问: pms_repo (自有 10 表) / downstream_repo (下游只读三表)
|
||||
repo/ 单表数据访问: pms_repo (自有 10 表) / qmt_repo (ws 通道 3 表)
|
||||
/ downstream_repo (下游只读三表)
|
||||
services/ 编排层
|
||||
param_store.py 运行参数中心 (表值优先于 settings 初值, 页面调参即时生效)
|
||||
portfolio.py 组合快照 (账本+行情+行业 → 方案/规则闸/页面的统一输入)
|
||||
command_service.py 命令下达→校验→冲突→生效/规划→进度推进→撤销
|
||||
executor.py 方案→指令→分日出手→窗口收口 (规则闸与择时的编排落点)
|
||||
dispatcher.py 下发通道三适配器: shadow(默认) / plan_x / channel_y
|
||||
dispatcher.py 下发通道两适配器: shadow(默认) / ws(落 pms_qmt_order 出口表)
|
||||
proposal_service.py 自主提议: 扫描→规则闸→研判闸→按自主档位分流 (执行/入队)
|
||||
judge.py 研判闸客户端 (决策系统未接通时自动降级为人工确认)
|
||||
signal_service.py 盘中信号订阅 (db2 广播 + db3 风控卖出) → 卖出指令或提议
|
||||
ledger_service.py 成交回放 / 对账 / 除权 / 盘前 / 日终结算 / 运营日报
|
||||
market.py 行情 (Redis db13) 与参考位 (决策系统主口径 + 兜底自算)
|
||||
industry.py 行业划分可插拔适配器 (custom_table / gp_stock_category / 停用)
|
||||
ws/runner.py **常驻连接进程 (pms-ws)**: 握手/心跳/重连/补发 + 出口出栈 + 上行落库确认
|
||||
web/ FastAPI + 单页 (Vue3 + ElementPlus),页面四块 + 运维/日报抽屉
|
||||
scheduler.py Celery beat 调度总表 (设计 §10 八个调度位 + 三条守卫)
|
||||
scripts/
|
||||
run_tests.py 一次跑完全部单测 (见下方「Docker 部署」)
|
||||
test_core_units.py 仓位与安全垫核心逻辑 14 例
|
||||
test_batch2_units.py 命令 / 方案 / 回放对账 纯逻辑 35 例
|
||||
test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 18 例
|
||||
test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 19 例
|
||||
test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 11 例
|
||||
test_batch5_units.py 决策系统信号流解析与消化口径 8 例
|
||||
test_batch6_units.py ws 通道: 协议测试向量/签名/水位/单表守卫 44 例
|
||||
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 32 例
|
||||
init_db.py 建表 (应用 ddl_pms_v1.sql, 幂等, 默认演练)
|
||||
check_db.py 实机连通性与表结构自检 (需真实 .env)
|
||||
|
|
@ -88,6 +93,9 @@ curl http://127.0.0.1:38100/health # 健康检查 + 配置装载自证 +
|
|||
docker compose --profile sched up -d # 启用调度器 (beat + worker)
|
||||
docker compose logs -f pms-beat pms-worker
|
||||
|
||||
docker compose --profile ws up -d pms-ws # 启用 QMT 直连 (先配好 .env 里的两把密钥)
|
||||
docker compose logs -f pms-ws
|
||||
|
||||
# 日常更新
|
||||
git pull && docker compose build && docker compose up -d
|
||||
```
|
||||
|
|
@ -115,13 +123,36 @@ git pull && docker compose build && docker compose up -d
|
|||
|
||||
| 模式 | 行为 | 什么时候用 |
|
||||
|---|---|---|
|
||||
| `shadow`(默认) | 指令照常过规则闸、照常置 DISPATCHED,但**不写下游**。你在 QMT 侧人工执行,成交由回放按 FIFO 认领回账本 | 直连服务就绪前的一期口径(设计 §9:命令类降仓/清仓由用户人工执行、PMS 记账跟踪) |
|
||||
| `plan_x` | 买入写 `trading_buy_plan` | ⚠️ **已作废**:架构已定 trading_service 全量退出业务,此模式不再使用(代码暂留,勿在实盘开启) |
|
||||
| `channel_y` | 写统一指令表 `pms_order_request` | 新 QMT 直连服务就绪后启用,由它消费本表 |
|
||||
| `shadow`(默认) | 指令照常过规则闸、照常置 DISPATCHED,但**不写下游**。你在 QMT 侧人工执行,成交由回放按 FIFO 认领回账本 | **当前仍是这个状态**(切 ws 需先配密钥并跑完 S1/S2 联调) |
|
||||
| `ws` | WebSocket 直连 QMT 执行服务,协议见 `QMT_WS_PROTOCOL.md` V1.0 | 目标形态。**通道已实现**(2026-07-28),联调通过即可切 |
|
||||
|
||||
> **目标架构(2026-07-28 已定)**:`trading_service` 全量退出业务,只保留看板与统计展示;新写一个 QMT 直连服务承担挂单与订单/持仓/资金回写;PMS 只管决策与账本。三者之间的数据接口待协定后另行成文。
|
||||
**ws 模式的进程边界**(理解这条通道的关键):连接是一条常驻长连接,且协议 §1 规定 PMS 只准开一条(多开会让指令乱序),所以它由独立的 `pms-ws` 进程独占;而 `executor` 跑在 celery worker 这种短命任务进程里。两者**只经数据库耦合**:
|
||||
|
||||
影子模式下的完整闭环:页面下命令 → 方案落表 → 方案转指令 → 择时按日配额给出「今天该出多少、什么价」→ 你照着在 QMT 下单 → 5 分钟一次的回放把成交认领回批次账本 → 命令进度自动推进。整条链路除了「人手下单」这一步,其余与实盘接管后完全一致。
|
||||
```
|
||||
executor (worker) ──写 pms_qmt_order(QUEUED)──▶ pms-ws ──签名──▶ QMT
|
||||
│
|
||||
QMT ──trade/order_update──▶ pms-ws ──落 pms_qmt_inbox──▶ 回放任务(worker) ──▶ 账本
|
||||
```
|
||||
|
||||
出口队列落在业务表而不是内存或 Redis,是因为「先记账后动作」这条铁律在这里可以字面成立——**落表就是记账**;ws 进程崩了重启队列还在,页面查 `pms_qmt_order` 就能看到在途委托,也不引入任何新中间件。代价是亚秒级轮询延迟,而择时本就是分钟级节奏。
|
||||
|
||||
`pms-ws` **只做通道**,不碰账本:成交入账仍然发生在 worker 里,账本变更保持单一来源,也不至于让一个慢查询把心跳拖到 15 秒超时断连。
|
||||
|
||||
**放不放行**(协议 §6.3「故障即守成」的落点):
|
||||
|
||||
| ws 进程 | 连接 | 买入 | 卖出 |
|
||||
|---|---|---|---|
|
||||
| 心跳陈旧(进程没了) | — | 拒发 | 拒发(排进队列也没人发) |
|
||||
| 在线 | 断开/重连中 | 拒发 | **照常入队**,重连后立即发出 |
|
||||
| 在线 | 已连接 | 放行 | 放行 |
|
||||
|
||||
> 原 `plan_x` / `channel_y` 两个适配器已于 2026-07-28 删除。前者写 `trading_buy_plan(is_active=6)`,那个 6 本身就是错的;后者写 `pms_order_request` 表由下游轮询,在通道改定 WebSocket 后作废。`pms_order_request` 表保留未用,不必删表。留着作废路径比删掉更危险——后来者会以为它可用。
|
||||
|
||||
**目标架构(2026-07-28 已定)**:`trading_service` 全量退出业务,只保留看板与统计展示;新写一个 QMT 直连服务承担挂单与订单/持仓/资金回写,PMS 经 WebSocket 与它直连;PMS 只管决策与账本。
|
||||
|
||||
**当前处境(重要)**:旧通道(下游直接执行决策系统卖出信号、轮询 `trading_buy_plan` 自动挂单)已按双方约定**即刻停用**。新的 ws 通道代码已就绪,但 `PMS_DISPATCH_MODE` 仍是 `shadow` 且 `PMS_QMT_WS_ENABLED=False`,所以现在**依然没有任何系统会自动下单**——PMS 影子运行,指令照常生成、过闸、记账,但需人工在 QMT 侧执行。这是安全的状态,但要知道它是这个状态。切 ws 是**两个开关加一次联调**,不是再写代码。
|
||||
|
||||
影子模式下的完整闭环:页面下命令 → 方案落表 → 方案转指令 → 择时按日配额给出「今天该出多少、什么价、挂到几点」→ 你照着在 QMT 下单 → 5 分钟一次的回放把成交认领回批次账本 → 命令进度自动推进。整条链路除了「人手下单」这一步,其余与实盘接管后完全一致。
|
||||
|
||||
## 自主提议的分流(设计 §6 / §7)
|
||||
|
||||
|
|
@ -135,16 +166,62 @@ git pull && docker compose build && docker compose up -d
|
|||
|
||||
## 已实现 / 待开发
|
||||
|
||||
**已实现**:建表 DDL 与建表脚本;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口);三模式下发通道;**动作引擎四类自主动作 + 研判闸客户端 + 提议分流**;**决策系统信号消化**(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器八个调度位;单测 118 例。
|
||||
**已实现**:建表 DDL 与建表脚本;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器八个调度位;**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 163 例**。
|
||||
|
||||
**待开发**:T0 做T(二期)、择时实现 A(委托决策系统盘中择时,等 bionic 侧接口)、新 QMT 直连服务的对接(等接口协定)。研判闸客户端已就位,等 bionic 侧 `process_intraday_audit` 新增 PMS 请求 direction 后,在页面填 `PMS_JUDGE_API_BASE` 即接通。
|
||||
### 下一步(按可动工顺序)
|
||||
|
||||
**待外部协商**:`QMT_INTERFACE_REQUIREMENTS.md` 的 A/B/C/D 各项——尤其 A1(`trading_position` 完整 DDL 与可用数量列)、A2(`trading_order` 状态枚举与**来源标识**)、B1(统一指令通道)。在来源标识到位前,回放按「同股同向 + 下发早于成交 + FIFO」贪心认领,认领不上即判外部成交并告警;持仓数量列用候选名探测,探测结果可经页面「运维 → 导出下游表结构」查看,也是回填 D1 的现成材料。
|
||||
| # | 事项 | 状态 |
|
||||
|---|---|---|
|
||||
| 1 | **ws 通道的账本侧改造**(清单 4~6) | 可立即开工,见下方「ws 通道实现清单」 |
|
||||
| 2 | ws 通道联调(协议 §9 的 S1/S2/S3) | 需 QMT 侧配合:公钥交换 + IP 加白 |
|
||||
| 3 | T0 做T(二期) | 可做,设计已有,无外部依赖 |
|
||||
| 4 | 择时实现 A(委托决策系统盘中择时) | 阻塞:等 bionic 侧接口 |
|
||||
| 5 | 研判闸接通 | 阻塞:等 bionic 侧 `process_intraday_audit` 新增 PMS 请求 direction。客户端已就位,接口好了在页面填 `PMS_JUDGE_API_BASE` 即通 |
|
||||
| 6 | bionic 侧配套改造(出口改道 + PMS direction) | 另一仓库 |
|
||||
|
||||
### ws 通道实现清单
|
||||
|
||||
协议 `QMT_WS_PROTOCOL.md` V1.0 已定稿,端点 `ws://192.168.16.98:8080`,明文 ws + Ed25519 双向签名。
|
||||
|
||||
1. ✅ **常驻连接进程** —— `app/ws/runner.py` + compose 的 `pms-ws`(`--profile ws`,**绝不可扩副本**,协议 §1 只准一条连接)。四个协程:存活心跳 / 收帧落库 / 5 秒 ping / 0.5 秒出口轮询。SIGTERM 优雅退出(停取新单 → 刷水位 → 最后一次 ack → 关连接 → 置 STOPPED 并清心跳),`stop_grace_period: 25s`。所有 DB 调用走 `to_thread`,不阻塞事件循环。
|
||||
2. ✅ **`dispatcher._ws()`** —— 落 `pms_qmt_order` 出口表置 QUEUED 即返回,签名与发送由 ws 进程做。参数不合协议(非整百、限价缺失、代码非点式)在本地就拦下。撤单把父指令名下所有在途子单标 `cancel_state=REQUESTED`。
|
||||
3. ✅ **上行消费与 seq 水位** —— `pms_qmt_inbox` 落库(seq 主键 + `trade_no` 唯一索引 = 协议 §6.1/§5.5 的双层去重),`pms_ws_state` 存连续水位,按 20 条 / 2 秒发 `ack_seq`。**落库失败绝不 ack**——落不了库就主动断线,让对端从 `last_seq+1` 重发,用协议自带的补发机制而不是自攒重试队列。
|
||||
4. 🔜 **`recon` 改为吃 `trade` 消息入账** —— 从 `pms_qmt_inbox` 里 `processed=0` 的 trade 行消费;FIFO 贪心认领退化为只处理外部/人工成交;`downstream_repo.FILLED_STATUSES` 降为旁路校验,不再作为成交判据。
|
||||
5. 🔜 **手续费口径** —— `fee` 不摊进持仓成本(对方明确逐笔费用可能有误差),只记现金流出,日终用资金快照反推校准。避免污染安全垫。
|
||||
6. 🔶 **`CANCELLED` / `EXPIRED` 自记** —— 通道层已做:`pms_qmt_order.cancel_state` 记本地是否发过撤单,与对端回的 `status` 不一致时告警。账本侧的口径随第 4 条一起落。
|
||||
|
||||
部署前另有一份检查清单在协议 §10.1.1(白名单、公钥交换、Redis 持久化、密钥只走 `.env`)。
|
||||
|
||||
### 切 ws 通道的操作顺序
|
||||
|
||||
```bash
|
||||
# 1. 生成本端密钥对, seed 填进 .env, 公钥带外交给 QMT 侧
|
||||
docker compose run --rm pms-web python -c "\
|
||||
import secrets; from app.core import ws_codec as w; \
|
||||
s=secrets.token_hex(32); print('seed:', s); print('pubkey:', w.public_key_b64(s))"
|
||||
# 2. 把 QMT 侧公钥填进 .env 的 PMS_QMT_PEER_PUBKEY_B64; 把本机内网 IP 报给对方加白
|
||||
# 3. 双方各自用协议 §2.1.1 的测试向量互验 —— 单测已覆盖本端:
|
||||
docker compose run --rm pms-web python scripts/test_batch6_units.py
|
||||
# 4. 建新表 (幂等) 并自检
|
||||
docker compose run --rm pms-web python scripts/init_db.py --yes
|
||||
docker compose run --rm pms-web python scripts/check_db.py # [6] 段看通道状态
|
||||
# 5. 起进程 (此时 PMS_DISPATCH_MODE 仍是 shadow, 只连不发)
|
||||
docker compose --profile ws up -d pms-ws && docker compose logs -f pms-ws
|
||||
# 6. 页面把 PMS_QMT_WS_ENABLED 打开, 观察 /api/ws-channel 的 conn_state 与 seq 水位
|
||||
# 7. S2/S3 联调通过后, 页面把 PMS_DISPATCH_MODE 改成 ws
|
||||
```
|
||||
|
||||
任一步不放心都可以退回去:把 `PMS_DISPATCH_MODE` 改回 `shadow` 即恢复人工执行,`pms-ws` 停掉也只是让指令拒发(保持原状),不会产生半截状态。
|
||||
|
||||
**待外部协商(剩余)**:`QMT_INTERFACE_REQUIREMENTS.md` 的 A/C/D 各项。已闭环的有:A2 状态枚举与成交均价、A3 资金快照、A4 逐笔成交、Q1~Q11 与 R1/R2(详见协议 §10)。已知存疑项:`trading_log.extra_data` 里的 `total_filled` 口径与示例数据矛盾,PMS 事后核对绕开该列,只读 `traded_volume / traded_price / traded_amount`(协议 §10.2)。
|
||||
|
||||
## 开发约定
|
||||
|
||||
- 开发机与服务器经 git 同步代码;**构建与运行统一走 Docker**(`docker compose build` → 容器内 `python scripts/run_tests.py` → `up -d`),测试结果回传后迭代。
|
||||
- 153 代理侧数据库严格单表访问;该纪律已落到 `app/db/session.py` 的静态守卫,违规 SQL 在执行前抛 `MultiTableSQL`。持仓系统内部代码统一 Tushare 点式(`600000.SH`),读决策系统结论表时转前缀式。
|
||||
> 2026-07-28 修了守卫本身的一个误判:`ON DUPLICATE KEY UPDATE` 后面跟的是列名不是表名,原来会被当成第二张表,于是**所有 upsert 一执行就抛 `MultiTableSQL`**——页面改参数、`ensure_position`、日报落库、行业映射导入四条路径全中。单测走内存桩不经过该函数,所以一直没暴露。回归用例在 `test_batch6_units.py` 的 [G] 组。
|
||||
- 配置分两层:基础设施连接串在 `.env`(服务器手工维护,不入库,模板见 `.env.example`);业务参数在 `config/settings.py` 只是初值,上线后经管理页面修改并持久化到 `pms_runtime_param` 表。**业务代码禁止直接读 settings 取业务参数**,一律走 `services/param_store.py`。
|
||||
- 新增纯逻辑一律进 `app/core/`(零外部依赖 + 配套单测);需要连库的编排进 `app/services/`,并保证连库失败时降级而非崩页。
|
||||
> 例外是**密钥**:`PMS_QMT_SIGN_SEED_HEX` / `PMS_QMT_PEER_PUBKEY_B64` 只走 `.env`(协议 §10.1.1),已列入 `param_store.SECRET_KEYS`——页面读不到、改不了、快照里也不出现。新增密钥类配置记得同步加进那个元组,否则它会被当成普通业务参数显示在参数设置页上。
|
||||
- 新增纯逻辑一律进 `app/core/`(零外部依赖 + 配套单测);需要连库的编排进 `app/services/`,并保证连库失败时降级而非崩页。ws 通道的协议编解码在 `app/core/ws_codec.py`,改动后**必须先跑通协议 §2.1.1 的测试向量**(`test_batch6_units.py` 的 [A] 组)——规范化串两边写不一致的话,联调只会告诉你"签名验不过",看不出差在哪一段。
|
||||
- 里程碑(设计定稿、建表、各期上线)及时 git 提交。
|
||||
- **接手前先读三份**:本文件 → `POSITION_MGMT_DESIGN.md`(V0.4 定稿,**勿改设计**)→ `QMT_WS_PROTOCOL.md`(V1.0 定稿,下发通道的唯一依据)。三份读完即可开工,不需要额外的口头背景。
|
||||
|
|
|
|||
|
|
@ -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 末句)。
|
||||
|
||||
|
|
|
|||
|
|
@ -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 1、2、3…, 水位就永远推不动:
|
||||
ack_seq 发不出去 → 对端的消息永远清理不掉 → 重连时又从头补发一遍。整个过程**不抛任何
|
||||
异常**, 日志上只有一行「上行乱序」在刷屏, 而成交其实一条都没确认。
|
||||
|
||||
分两种情形:
|
||||
last_seq == 0 冷启动。本端从没收过任何消息, 谈不上"丢" —— 以 first_seq-1 为起点。
|
||||
这不是缺口, 这是起点。
|
||||
last_seq > 0 真缺口, 中间那段再也拿不到了。基线同样要推上去 (卡死只会更糟),
|
||||
但必须置 resync 标记走全量对账 —— §6.2。
|
||||
"""
|
||||
if int(first_seq) <= int(last_seq) + 1:
|
||||
return int(last_seq), False
|
||||
return int(first_seq) - 1, int(last_seq) > 0
|
||||
|
||||
|
||||
def next_watermark(last_seq: int, arrived: set, seq: int) -> tuple:
|
||||
"""收到 seq 后推进连续水位。返回 (新水位, 新的乱序暂存集合)。
|
||||
|
||||
水位必须是**连续前缀**的末位: 确认 10450 隐含确认之前所有 (§4.5 累积确认语义),
|
||||
中间缺一条就不能往前跨 —— 跨过去那条就永久丢了, 而且丢得毫无痕迹。
|
||||
正常情况下 QMT 按序补发, 这个集合始终是空的; 它存在是为了「万一乱序」不出错。
|
||||
"""
|
||||
arrived = set(arrived or ())
|
||||
seq = int(seq)
|
||||
if seq <= last_seq:
|
||||
return last_seq, arrived # 已确认过, 重复帧
|
||||
arrived.add(seq)
|
||||
while (last_seq + 1) in arrived:
|
||||
last_seq += 1
|
||||
arrived.discard(last_seq)
|
||||
return last_seq, arrived
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,387 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
ws 直连通道三表的数据访问 (pms_qmt_order / pms_qmt_inbox / pms_ws_state)
|
||||
=======================================================================
|
||||
与 pms_repo 同纪律: 每个函数只碰一张表, SQL 全部经 db.session 的单表守卫,
|
||||
可更新列走白名单。表结构与"为什么是三张表"见 ddl_pms_v1.sql 尾部。
|
||||
|
||||
三张表分别对应协议里的三件事:
|
||||
pms_qmt_order §4.2 place_order / §4.3 cancel_order 的出口队列 + 委托状态跟踪
|
||||
pms_qmt_inbox §4.5「确认前必须已持久化」的那个"库" + §6.1 双层去重
|
||||
pms_ws_state §6.1 seq 水位 (跨重启不回退) + 常驻进程存活心跳
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from app.db.session import execute, fetch_all, fetch_one
|
||||
|
||||
_NOW = lambda: datetime.now() # noqa: E731 (容器时区 Asia/Shanghai)
|
||||
|
||||
STATE_ID = 1 # pms_ws_state 恒一行
|
||||
|
||||
# 本地出口状态 (还没进协议状态机)
|
||||
OS_QUEUED, OS_SENDING, OS_SENT = "QUEUED", "SENDING", "SENT"
|
||||
OS_SEND_FAILED, OS_ABORTED = "SEND_FAILED", "ABORTED"
|
||||
# 协议状态 (§7.1)
|
||||
OS_ACCEPTED, OS_SUBMITTED, OS_PARTIAL = "ACCEPTED", "SUBMITTED", "PARTIAL"
|
||||
OS_FILLED, OS_CANCELLED, OS_EXPIRED, OS_REJECTED = ("FILLED", "CANCELLED", "EXPIRED",
|
||||
"REJECTED")
|
||||
FINAL = (OS_FILLED, OS_CANCELLED, OS_EXPIRED, OS_REJECTED, OS_SEND_FAILED, OS_ABORTED)
|
||||
# 「在途」= 还可能成交或还能撤的。撤单只对这些有意义。
|
||||
LIVE = (OS_QUEUED, OS_SENDING, OS_SENT, OS_ACCEPTED, OS_SUBMITTED, OS_PARTIAL)
|
||||
|
||||
CANCEL_NONE, CANCEL_REQUESTED, CANCEL_SENT = "NONE", "REQUESTED", "SENT"
|
||||
|
||||
ORDER_COLS = {
|
||||
"status", "broker_order_id", "cum_qty", "cum_avg_price", "leaves_qty", "cancel_state",
|
||||
"cancel_id", "cancel_req_at", "reject_code", "reject_reason", "send_attempts",
|
||||
"sent_at", "final_at", "note",
|
||||
}
|
||||
|
||||
|
||||
def _dumps(v):
|
||||
return json.dumps(v, ensure_ascii=False) if not isinstance(v, (str, type(None))) else v
|
||||
|
||||
|
||||
def _loads(v, default=None):
|
||||
if v in (None, ""):
|
||||
return default
|
||||
if isinstance(v, (dict, list)):
|
||||
return v
|
||||
try:
|
||||
return json.loads(v)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def _in_clause(values, prefix: str, params: dict) -> str:
|
||||
keys = []
|
||||
for i, v in enumerate(values):
|
||||
keys.append(f":{prefix}{i}")
|
||||
params[f"{prefix}{i}"] = v
|
||||
return ", ".join(keys)
|
||||
|
||||
|
||||
# ================================================================ pms_ws_state
|
||||
def get_state() -> dict:
|
||||
r = fetch_one("SELECT * FROM pms_ws_state WHERE id = :i", {"i": STATE_ID})
|
||||
if not r:
|
||||
return {"id": STATE_ID, "last_seq": 0, "acked_seq": 0, "server_seq": 0,
|
||||
"conn_state": "INIT", "heartbeat_at": None, "resync_flag": 0,
|
||||
"connected_at": None, "last_error": None, "stat": {}}
|
||||
d = dict(r)
|
||||
d["stat"] = _loads(d.pop("stat_json", None), {})
|
||||
return d
|
||||
|
||||
|
||||
def ensure_state() -> int:
|
||||
"""建表脚本已插过一行; 这里兜底 (库是别人手工建的/被清过 也不至于全线报错)。"""
|
||||
return execute(
|
||||
"INSERT INTO pms_ws_state (id, last_seq, acked_seq, server_seq, conn_state, "
|
||||
"updated_at) VALUES (:i, 0, 0, 0, 'INIT', :ts) "
|
||||
"ON DUPLICATE KEY UPDATE updated_at = :ts", {"i": STATE_ID, "ts": _NOW()})
|
||||
|
||||
|
||||
def save_watermark(last_seq: int, acked_seq=None) -> int:
|
||||
"""水位只进不退 —— GREATEST 兜住并发/乱序写回, 回退一格就意味着重复入账。"""
|
||||
sets = ["last_seq = GREATEST(last_seq, :ls)"]
|
||||
p = {"ls": int(last_seq), "i": STATE_ID, "ts": _NOW()}
|
||||
if acked_seq is not None:
|
||||
sets.append("acked_seq = GREATEST(acked_seq, :as_)")
|
||||
p["as_"] = int(acked_seq)
|
||||
return execute(f"UPDATE pms_ws_state SET {', '.join(sets)}, updated_at = :ts "
|
||||
f"WHERE id = :i", p)
|
||||
|
||||
|
||||
def set_conn(conn_state: str, *, connected_at=None, last_error=None, server_seq=None,
|
||||
resync=None, stat=None, beat: bool = True) -> int:
|
||||
sets, p = ["conn_state = :cs"], {"cs": conn_state, "i": STATE_ID, "ts": _NOW()}
|
||||
if beat:
|
||||
sets.append("heartbeat_at = :ts")
|
||||
if connected_at is not None:
|
||||
sets.append("connected_at = :ca")
|
||||
p["ca"] = connected_at
|
||||
if last_error is not None:
|
||||
sets.append("last_error = :le")
|
||||
p["le"] = str(last_error)[:300]
|
||||
if server_seq is not None:
|
||||
sets.append("server_seq = :ss")
|
||||
p["ss"] = int(server_seq)
|
||||
if resync is not None:
|
||||
sets.append("resync_flag = :rf")
|
||||
p["rf"] = 1 if resync else 0
|
||||
if stat is not None:
|
||||
sets.append("stat_json = :sj")
|
||||
p["sj"] = _dumps(stat)
|
||||
return execute(f"UPDATE pms_ws_state SET {', '.join(sets)}, updated_at = :ts "
|
||||
f"WHERE id = :i", p)
|
||||
|
||||
|
||||
def mark_stopped(note: str = "") -> int:
|
||||
"""优雅退出: 置 STOPPED 并**清空心跳**。
|
||||
|
||||
清心跳是关键一步 —— 不清的话, 停机后的 stale 窗口 (默认 15 秒) 里 dispatcher 仍
|
||||
认为进程活着, 卖出指令还会继续往队列里排, 而已经没人会发它们了。
|
||||
"""
|
||||
return execute("UPDATE pms_ws_state SET conn_state = 'STOPPED', heartbeat_at = NULL, "
|
||||
"last_error = :le, updated_at = :ts WHERE id = :i",
|
||||
{"le": str(note)[:300], "i": STATE_ID, "ts": _NOW()})
|
||||
|
||||
|
||||
def beat(stat=None) -> int:
|
||||
"""常驻进程存活心跳。dispatcher 看这个时间戳判断「ws 进程还在不在」——
|
||||
连接断了是一回事 (还能重连), 进程没了是另一回事 (队列永远发不出去)。"""
|
||||
sets, p = ["heartbeat_at = :ts"], {"i": STATE_ID, "ts": _NOW()}
|
||||
if stat is not None:
|
||||
sets.append("stat_json = :sj")
|
||||
p["sj"] = _dumps(stat)
|
||||
return execute(f"UPDATE pms_ws_state SET {', '.join(sets)}, updated_at = :ts "
|
||||
f"WHERE id = :i", p)
|
||||
|
||||
|
||||
# ================================================================ pms_qmt_inbox
|
||||
PUT_NEW, PUT_DUP_SEQ, PUT_DUP_KEY = "NEW", "DUP_SEQ", "DUP_KEY"
|
||||
|
||||
|
||||
def inbox_put(*, seq: int, msg_id: str, msg_type: str, payload: dict, msg_ts: int,
|
||||
corr_id=None, dedup_key=None, processed: int = 2, note=None) -> str:
|
||||
"""落一条上行消息。返回 NEW / DUP_SEQ / DUP_KEY。
|
||||
|
||||
双层去重 (§6.1 + §5.5) 在这里靠两个唯一键实现: 主键 seq 是第一层, 唯一索引
|
||||
dedup_key (trade_no) 是第二层。
|
||||
|
||||
**DUP_KEY 这条分支容易漏, 单独说明**: trade_no 撞了但 seq 是新的 —— 说明 QMT 用
|
||||
新序号重推了一条我们已入过账的成交。这一笔不能再入账, 但**这个 seq 仍必须占住一行**,
|
||||
否则连续水位永远卡在它前面, ack_seq 再也推不动, QMT 那边的消息也就永远清理不掉。
|
||||
所以这里补插一行 dedup_key=NULL、processed=2 的存档行。
|
||||
"""
|
||||
now = _NOW()
|
||||
p = {"s": int(seq), "mi": str(msg_id)[:64], "mt": str(msg_type)[:24],
|
||||
"ci": (str(corr_id)[:64] if corr_id else None),
|
||||
"dk": (str(dedup_key)[:80] if dedup_key else None),
|
||||
"pj": _dumps(payload or {}), "mts": int(msg_ts or 0), "ts": now,
|
||||
"pc": int(processed), "nt": (str(note)[:300] if note else None),
|
||||
"pa": now if int(processed) != 0 else None}
|
||||
sql = ("INSERT INTO pms_qmt_inbox (seq, msg_id, msg_type, corr_id, dedup_key, "
|
||||
"payload_json, msg_ts, received_at, processed, processed_at, process_note) "
|
||||
"VALUES (:s, :mi, :mt, :ci, :dk, :pj, :mts, :ts, :pc, :pa, :nt) "
|
||||
"ON DUPLICATE KEY UPDATE seq = seq")
|
||||
if execute(sql, p):
|
||||
return PUT_NEW
|
||||
if fetch_one("SELECT seq FROM pms_qmt_inbox WHERE seq = :s", {"s": int(seq)}):
|
||||
return PUT_DUP_SEQ
|
||||
# 见 docstring: trade_no 重复但 seq 是新的 —— 占位存档, 让水位能继续往前推
|
||||
p["dk"] = None
|
||||
p["pc"] = 2
|
||||
p["pa"] = now
|
||||
p["nt"] = f"重复成交 (dedup_key={dedup_key}), 不入账, 仅占位以推进 seq 水位"
|
||||
execute(sql, p)
|
||||
return PUT_DUP_KEY
|
||||
|
||||
|
||||
def inbox_recover_watermark(stored_last_seq: int, limit: int = 20000) -> int:
|
||||
"""用 inbox 重算真正的连续水位。
|
||||
|
||||
last_seq 落库是按批次刷的 (每 20 条 / 2 秒), 崩溃时可能落后于实际已落库的消息。
|
||||
inbox 行才是事实, 这里从 stored 往后走连续段 —— 少 ack 一点只是让 QMT 多留一会儿,
|
||||
多 ack 一点会让数据永久丢失, 所以宁可从保守值往前推。
|
||||
"""
|
||||
cur = int(stored_last_seq or 0)
|
||||
rows = fetch_all("SELECT seq FROM pms_qmt_inbox WHERE seq > :n ORDER BY seq ASC LIMIT :m",
|
||||
{"n": cur, "m": int(limit)})
|
||||
for r in rows:
|
||||
if int(r["seq"]) == cur + 1:
|
||||
cur += 1
|
||||
else:
|
||||
break
|
||||
return cur
|
||||
|
||||
|
||||
def inbox_pending(limit: int = 500) -> list:
|
||||
rows = fetch_all("SELECT * FROM pms_qmt_inbox WHERE processed = 0 ORDER BY seq ASC "
|
||||
"LIMIT :n", {"n": int(limit)})
|
||||
for r in rows:
|
||||
r["payload"] = _loads(r.get("payload_json"), {})
|
||||
return rows
|
||||
|
||||
|
||||
def inbox_mark(seqs: list, processed: int = 1, note=None) -> int:
|
||||
if not seqs:
|
||||
return 0
|
||||
p = {"pc": int(processed), "ts": _NOW(), "nt": (str(note)[:300] if note else None)}
|
||||
return execute(f"UPDATE pms_qmt_inbox SET processed = :pc, processed_at = :ts, "
|
||||
f"process_note = :nt WHERE seq IN ({_in_clause(seqs, 's', p)})", p)
|
||||
|
||||
|
||||
def inbox_list(*, msg_type=None, corr_id=None, limit: int = 200) -> list:
|
||||
where, p = [], {"n": int(limit)}
|
||||
if msg_type:
|
||||
where.append("msg_type = :mt")
|
||||
p["mt"] = msg_type
|
||||
if corr_id:
|
||||
where.append("corr_id = :ci")
|
||||
p["ci"] = corr_id
|
||||
sql = "SELECT * FROM pms_qmt_inbox"
|
||||
if where:
|
||||
sql += " WHERE " + " AND ".join(where)
|
||||
sql += " ORDER BY seq DESC LIMIT :n"
|
||||
rows = fetch_all(sql, p)
|
||||
for r in rows:
|
||||
r["payload"] = _loads(r.get("payload_json"), {})
|
||||
return rows
|
||||
|
||||
|
||||
def inbox_pending_count() -> int:
|
||||
r = fetch_one("SELECT COUNT(*) AS n FROM pms_qmt_inbox WHERE processed = 0")
|
||||
return int((r or {}).get("n") or 0)
|
||||
|
||||
|
||||
# ================================================================ pms_qmt_order
|
||||
def enqueue_order(*, instruction_id, parent_id, ts_code, side, qty, limit_price,
|
||||
valid_until, intent="OPEN", note=None) -> int:
|
||||
"""把一张待发委托落进出口队列 —— 这一步就是「先记账」, ws 进程随后才「后动作」。"""
|
||||
now = _NOW()
|
||||
return execute(
|
||||
"INSERT INTO pms_qmt_order (instruction_id, parent_id, ts_code, side, qty, "
|
||||
"limit_price, valid_until, intent, note, status, cancel_state, cum_qty, "
|
||||
"send_attempts, created_at, updated_at) VALUES (:iid, :pid, :code, :side, :qty, "
|
||||
":px, :vu, :it, :nt, 'QUEUED', 'NONE', 0, 0, :ts, :ts)",
|
||||
{"iid": instruction_id, "pid": parent_id, "code": ts_code, "side": side,
|
||||
"qty": int(qty), "px": float(limit_price), "vu": int(valid_until),
|
||||
"it": intent, "nt": (str(note)[:200] if note else None), "ts": now})
|
||||
|
||||
|
||||
def get_order(instruction_id: str):
|
||||
return fetch_one("SELECT * FROM pms_qmt_order WHERE instruction_id = :iid",
|
||||
{"iid": instruction_id})
|
||||
|
||||
|
||||
def list_orders(*, statuses=None, parent_id=None, ts_code=None, limit: int = 200) -> list:
|
||||
where, p = [], {"n": int(limit)}
|
||||
if statuses:
|
||||
where.append(f"status IN ({_in_clause(list(statuses), 'st', p)})")
|
||||
if parent_id:
|
||||
where.append("parent_id = :pid")
|
||||
p["pid"] = parent_id
|
||||
if ts_code:
|
||||
where.append("ts_code = :code")
|
||||
p["code"] = ts_code
|
||||
sql = "SELECT * FROM pms_qmt_order"
|
||||
if where:
|
||||
sql += " WHERE " + " AND ".join(where)
|
||||
sql += " ORDER BY id DESC LIMIT :n"
|
||||
return fetch_all(sql, p)
|
||||
|
||||
|
||||
def next_queued(limit: int = 20, side=None) -> list:
|
||||
"""取待发委托。按 id 升序 = 先进先发, 保证同一只票的分笔不乱序 (§1「不开第二条连接」
|
||||
是为了避免乱序, 出口这一端也得守住)。"""
|
||||
p = {"n": int(limit)}
|
||||
sql = "SELECT * FROM pms_qmt_order WHERE status = 'QUEUED'"
|
||||
if side:
|
||||
sql += " AND side = :side"
|
||||
p["side"] = side
|
||||
sql += " ORDER BY id ASC LIMIT :n"
|
||||
return fetch_all(sql, p)
|
||||
|
||||
|
||||
def claim_order(instruction_id: str) -> bool:
|
||||
"""QUEUED → SENDING 的原子认领。返回 False 说明被别人抢走了/状态已变, **不要再发**。
|
||||
|
||||
正常只有一个 ws 进程, 但滚动重启会有两个进程短暂并存 —— 那一瞬间靠这条 CAS 兜住,
|
||||
而不是靠"我们约定只起一个"。
|
||||
"""
|
||||
n = execute("UPDATE pms_qmt_order SET status = 'SENDING', updated_at = :ts "
|
||||
"WHERE instruction_id = :iid AND status = 'QUEUED'",
|
||||
{"iid": instruction_id, "ts": _NOW()})
|
||||
return bool(n)
|
||||
|
||||
|
||||
def mark_sent(instruction_id: str) -> int:
|
||||
return execute("UPDATE pms_qmt_order SET status = 'SENT', sent_at = :ts, "
|
||||
"send_attempts = send_attempts + 1, updated_at = :ts "
|
||||
"WHERE instruction_id = :iid", {"iid": instruction_id, "ts": _NOW()})
|
||||
|
||||
|
||||
def requeue_order(instruction_id: str, error: str = "", max_attempts: int = 3,
|
||||
count_attempt: bool = True) -> int:
|
||||
"""发送失败退回队列; 试满次数置 SEND_FAILED 等人工 —— 不无限重试 (设计 §13
|
||||
「指令下发失败/超时 → 不自动重发」的折中: 网络抖动允许有限重试, 但不能一直撞)。
|
||||
|
||||
count_attempt=False 用于**连接断了**的场景: 那是通道的问题不是这张单的问题, 不该
|
||||
记在它头上, 否则一次几秒的抖动就能把待发单全烧成 SEND_FAILED。
|
||||
|
||||
注: MySQL 的 UPDATE ... SET 按书写顺序求值且后项可见前项新值 —— 故 status 必须写在
|
||||
send_attempts **之前** (用旧值 + inc 判断), 顺序不可调换。同 pms_repo.close_lot_qty。
|
||||
"""
|
||||
return execute(
|
||||
"UPDATE pms_qmt_order SET "
|
||||
"status = CASE WHEN send_attempts + :inc >= :mx THEN 'SEND_FAILED' "
|
||||
" ELSE 'QUEUED' END, "
|
||||
"send_attempts = send_attempts + :inc, "
|
||||
"reject_reason = :err, updated_at = :ts WHERE instruction_id = :iid",
|
||||
{"iid": instruction_id, "mx": int(max_attempts), "err": str(error)[:300],
|
||||
"inc": 1 if count_attempt else 0, "ts": _NOW()})
|
||||
|
||||
|
||||
def reset_stuck_sending() -> int:
|
||||
"""进程启动时把 SENDING 退回 QUEUED。
|
||||
|
||||
SENDING 意味着"认领了但没记到 SENT" —— 可能已经发出去了, 也可能没有。重发是安全的:
|
||||
协议 §2.2 规定重复 instruction_id 不会二次下单, 只回 ack{duplicate:true} 带当前状态。
|
||||
这正是幂等键存在的意义, 该用就用, 别为了"怕重复"把单子丢在半路。
|
||||
"""
|
||||
return execute("UPDATE pms_qmt_order SET status = 'QUEUED', updated_at = :ts "
|
||||
"WHERE status = 'SENDING'", {"ts": _NOW()})
|
||||
|
||||
|
||||
def abort_order(instruction_id: str, note: str) -> int:
|
||||
"""未发出即本地作废 (典型: 排队期间 valid_until 已过, 发出去也只会立刻 EXPIRED)。"""
|
||||
now = _NOW()
|
||||
return execute("UPDATE pms_qmt_order SET status = 'ABORTED', reject_code = 'LOCAL_ABORT', "
|
||||
"reject_reason = :nt, final_at = :ts, updated_at = :ts "
|
||||
"WHERE instruction_id = :iid",
|
||||
{"iid": instruction_id, "nt": str(note)[:300], "ts": now})
|
||||
|
||||
|
||||
def update_order(instruction_id: str, **fields) -> int:
|
||||
cols = [c for c in fields if c in ORDER_COLS]
|
||||
if not cols:
|
||||
return 0
|
||||
p = {c: fields[c] for c in cols}
|
||||
p.update({"iid": instruction_id, "ts": _NOW()})
|
||||
clause = ", ".join(f"{c} = :{c}" for c in cols)
|
||||
return execute(f"UPDATE pms_qmt_order SET {clause}, updated_at = :ts "
|
||||
f"WHERE instruction_id = :iid", p)
|
||||
|
||||
|
||||
def request_cancel(*, parent_id: str, cancel_id: str) -> int:
|
||||
"""把某父指令名下所有在途子单标为待撤。ws 进程扫到后发 cancel_order。
|
||||
|
||||
cancel_state 同时是**区分 CANCELLED 与 EXPIRED 的本地依据** (协议 §7.2): 下游落库层
|
||||
两者都写 cancelled, 但 PMS 自己知道有没有发过撤单。
|
||||
"""
|
||||
p = {"pid": parent_id, "cid": cancel_id, "ts": _NOW()}
|
||||
return execute(
|
||||
f"UPDATE pms_qmt_order SET cancel_state = 'REQUESTED', cancel_id = :cid, "
|
||||
f"cancel_req_at = :ts, updated_at = :ts WHERE parent_id = :pid "
|
||||
f"AND cancel_state = 'NONE' AND status IN ({_in_clause(list(LIVE), 'lv', p)})", p)
|
||||
|
||||
|
||||
def next_cancel_requests(limit: int = 20) -> list:
|
||||
p = {"n": int(limit)}
|
||||
return fetch_all(
|
||||
f"SELECT * FROM pms_qmt_order WHERE cancel_state = 'REQUESTED' "
|
||||
f"AND status IN ({_in_clause(list(LIVE), 'lv', p)}) ORDER BY id ASC LIMIT :n", p)
|
||||
|
||||
|
||||
def mark_cancel_sent(instruction_id: str) -> int:
|
||||
return execute("UPDATE pms_qmt_order SET cancel_state = 'SENT', updated_at = :ts "
|
||||
"WHERE instruction_id = :iid", {"iid": instruction_id, "ts": _NOW()})
|
||||
|
||||
|
||||
def queue_depth() -> dict:
|
||||
rows = fetch_all("SELECT status, COUNT(*) AS n FROM pms_qmt_order GROUP BY status")
|
||||
return {r["status"]: int(r["n"]) for r in rows}
|
||||
|
|
@ -2,32 +2,64 @@
|
|||
"""
|
||||
指令下发通道 (设计 §9 权限移交的落点)
|
||||
======================================
|
||||
三个适配器, 由参数 `PMS_DISPATCH_MODE` 切换, **默认 shadow**:
|
||||
两个适配器, 由参数 `PMS_DISPATCH_MODE` 切换, **默认 shadow**:
|
||||
|
||||
shadow 影子运行 —— 只记账不下发。指令照常过规则闸、照常置 DISPATCHED,
|
||||
等用户人工在 QMT 侧执行, 成交由回放按 FIFO 认领回来。
|
||||
这是设计 §9 的一期口径:「通道未通前由用户人工执行、PMS 记账跟踪」。
|
||||
plan_x 过渡兼容 —— 买入沿用 `trading_buy_plan` (置 is_active=6 待挂单,
|
||||
署名 approved_by='pms')。**卖出无对应通道**, 自动退回 shadow。
|
||||
列清单按下游现表推断, QMT 侧确认前请勿在实盘开启。
|
||||
channel_y 推荐方案 —— 写统一指令表 `pms_order_request` (DDL 见需求清单 B1)。
|
||||
该表归属与形态仍在协商 (B1.1/B1.7), 表未建时会明确报错而非静默吞掉。
|
||||
shadow 影子运行 —— 只记账不下发。指令照常过规则闸、照常置 DISPATCHED,
|
||||
等用户人工在 QMT 侧执行, 成交由回放按 FIFO 认领回来。
|
||||
这是设计 §9 的一期口径:「通道未通前由用户人工执行、PMS 记账跟踪」。
|
||||
ws WebSocket 长连接直连 QMT 执行服务, 协议见 QMT_WS_PROTOCOL.md V1.0。
|
||||
|
||||
无论哪种模式, **指令先落 pms_instruction 再下发** (先记账后动作), 本模块只负责
|
||||
「往下游递一手」, 不改指令状态 —— 状态由 executor 统一推进。
|
||||
ws 模式的进程边界 (这一段是理解本模块的关键)
|
||||
--------------------------------------------
|
||||
ws 是**一条常驻长连接**, 而 executor 跑在 celery worker 这种短命任务进程里, 且协议 §1
|
||||
明确「PMS 不开第二条连接」(避免指令乱序)。所以连接由独立的 `pms-ws` 进程持有
|
||||
(app/ws/runner.py), 本模块**不碰 socket** —— `_ws()` 只做一件事:
|
||||
|
||||
把这张委托写进 pms_qmt_order 置 QUEUED, 然后就返回。
|
||||
|
||||
出口队列落在业务表上而不是内存或 Redis, 是因为「先记账后动作」这条铁律在这里可以字面
|
||||
成立: **落表就是记账**。ws 进程崩了重启队列还在; 页面查 pms_qmt_order 就能看到在途委托;
|
||||
不引入任何新中间件。代价是亚秒级的轮询延迟 —— 择时本来就是分钟级节奏, 无感。
|
||||
|
||||
放不放行的判断 (对应协议 §6.3「故障即守成」)
|
||||
-------------------------------------------
|
||||
ws 进程心跳陈旧 (进程没了) → 一律拒发。队列里的单子谁也发不出去, 排进去只是假装干活。
|
||||
进程在、连接断 (非 ONLINE) → **买入拒发, 卖出照常入队**, 重连后立即发出。
|
||||
这是既有口径「冻结与刹车只挡增持不挡减持」的延续。
|
||||
进程在、连接通 → 放行。
|
||||
|
||||
无论哪种模式, 本模块只负责「往下游递一手」, 不改父指令状态 —— 状态由 executor 统一推进。
|
||||
|
||||
历史包袱清理 (2026-07-28)
|
||||
-------------------------
|
||||
原有 plan_x / channel_y 两个适配器已删除, 原因:
|
||||
|
||||
plan_x 买入写 trading_buy_plan(is_active=6)。这个 6 本身就是错的
|
||||
(下游语义 0待审/1已激活/2择时监测/5盘中观察, 没有 6); 且该表唯一约束
|
||||
是 (stock_code, trading_time)、buy_amount 是金额不是股数, 单股分批
|
||||
必撞约束。架构定案后该路径整体作废。
|
||||
channel_y 写 pms_order_request 表由下游轮询。双方已改定 WebSocket 直连,
|
||||
表通道作废 (见 QMT_INTERFACE_REQUIREMENTS.md V2 的 B 部分)。
|
||||
`pms_order_request` 表保留未用, 不必删表。
|
||||
|
||||
留着作废路径比删掉更危险 —— 后来者会以为它可用。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.db.session import execute
|
||||
from app.core import ws_codec as wsc
|
||||
from app.repo import qmt_repo
|
||||
from app.services import param_store
|
||||
|
||||
logger = logging.getLogger("pms.dispatch")
|
||||
|
||||
MODE_SHADOW, MODE_PLAN_X, MODE_CHANNEL_Y = "shadow", "plan_x", "channel_y"
|
||||
MODES = (MODE_SHADOW, MODE_PLAN_X, MODE_CHANNEL_Y)
|
||||
MODE_SHADOW, MODE_WS = "shadow", "ws"
|
||||
MODES = (MODE_SHADOW, MODE_WS)
|
||||
|
||||
# ws 进程心跳超过这个时长没更新, 就认为进程已经没了 (它每 2 秒写一次)
|
||||
DEFAULT_STALE_SEC = 15
|
||||
|
||||
|
||||
def mode() -> str:
|
||||
|
|
@ -35,30 +67,73 @@ def mode() -> str:
|
|||
return m if m in MODES else MODE_SHADOW
|
||||
|
||||
|
||||
# ================================================================ 通道健康
|
||||
def channel_status() -> dict:
|
||||
"""ws 通道当前能不能用。页面运维抽屉与下发前置校验共用同一份判断。"""
|
||||
stale = param_store.get_int("PMS_QMT_HEARTBEAT_STALE_SEC", DEFAULT_STALE_SEC)
|
||||
out = {"process_alive": False, "conn_state": "UNKNOWN", "online": False,
|
||||
"heartbeat_age_sec": None, "last_seq": 0, "acked_seq": 0,
|
||||
"resync_required": False, "queue": {}, "inbox_pending": 0, "error": None}
|
||||
try:
|
||||
st = qmt_repo.get_state()
|
||||
except Exception as e:
|
||||
out["error"] = f"通道状态读取失败: {type(e).__name__}: {e}"
|
||||
return out
|
||||
hb = st.get("heartbeat_at")
|
||||
if hb:
|
||||
age = (datetime.now() - hb).total_seconds()
|
||||
out["heartbeat_age_sec"] = round(age, 1)
|
||||
out["process_alive"] = age <= stale
|
||||
out.update({"conn_state": st.get("conn_state") or "INIT",
|
||||
"last_seq": int(st.get("last_seq") or 0),
|
||||
"acked_seq": int(st.get("acked_seq") or 0),
|
||||
"resync_required": bool(st.get("resync_flag")),
|
||||
"connected_at": str(st.get("connected_at") or ""),
|
||||
"last_error": st.get("last_error"), "stat": st.get("stat") or {}})
|
||||
out["online"] = out["process_alive"] and out["conn_state"] == "ONLINE"
|
||||
try:
|
||||
out["queue"] = qmt_repo.queue_depth()
|
||||
out["inbox_pending"] = qmt_repo.inbox_pending_count()
|
||||
except Exception as e: # 统计失败不影响放行判断
|
||||
out["error"] = f"队列统计失败: {type(e).__name__}: {e}"
|
||||
return out
|
||||
|
||||
|
||||
def describe() -> dict:
|
||||
m = mode()
|
||||
return {"mode": m, "shadow": m == MODE_SHADOW, "modes": list(MODES),
|
||||
"hint": {
|
||||
MODE_SHADOW: "影子运行: 指令只记账不下发, 请在 QMT 侧人工执行, "
|
||||
"成交由回放自动认领回账本",
|
||||
MODE_PLAN_X: "过渡通道: 买入写 trading_buy_plan(is_active=6), 卖出退回影子",
|
||||
MODE_CHANNEL_Y: "统一通道: 写 pms_order_request, 由下游轮询执行",
|
||||
}[m]}
|
||||
out = {"mode": m, "shadow": m == MODE_SHADOW, "modes": list(MODES)}
|
||||
if m == MODE_SHADOW:
|
||||
out["hint"] = ("影子运行: 指令只记账不下发, 请在 QMT 侧人工执行, "
|
||||
"成交由回放自动认领回账本")
|
||||
return out
|
||||
ch = channel_status()
|
||||
out["channel"] = ch
|
||||
if not ch["process_alive"]:
|
||||
out["hint"] = (f"WebSocket 直连: pms-ws 常驻进程未在线 (心跳 "
|
||||
f"{ch['heartbeat_age_sec']}s 前), 指令一律拒发。"
|
||||
f"启动: docker compose --profile ws up -d pms-ws")
|
||||
elif not ch["online"]:
|
||||
out["hint"] = (f"WebSocket 直连: 进程在线但连接 {ch['conn_state']} —— "
|
||||
f"买入拒发, 卖出仍可入队待重连后发出 (协议 §6.3)")
|
||||
else:
|
||||
out["hint"] = (f"WebSocket 直连 QMT: 在线, 已确认水位 seq={ch['acked_seq']}, "
|
||||
f"出口队列 {ch['queue'].get('QUEUED', 0)} 张待发")
|
||||
if ch.get("resync_required"):
|
||||
out["hint"] += " · 注意: 对端补发不全, 需走全量对账 (协议 §6.2)"
|
||||
return out
|
||||
|
||||
|
||||
# ================================================================ 下发
|
||||
def dispatch(*, instruction_id: str, ts_code: str, side: str, qty: int, limit_price=None,
|
||||
valid_until=None, stock_name=None) -> dict:
|
||||
valid_until=None, stock_name=None, intent=None, parent_id=None,
|
||||
note=None) -> dict:
|
||||
"""递一手给下游。返回 {ok, ref, mode, note, error}; ok=False 时 executor 不改指令状态。"""
|
||||
m = mode()
|
||||
try:
|
||||
if m == MODE_CHANNEL_Y:
|
||||
return _channel_y(instruction_id, ts_code, side, qty, limit_price, valid_until)
|
||||
if m == MODE_PLAN_X and str(side).lower() == "buy":
|
||||
return _plan_x(instruction_id, ts_code, qty, limit_price, stock_name)
|
||||
if m == MODE_PLAN_X:
|
||||
r = _shadow(instruction_id, side)
|
||||
r["note"] = "plan_x 无卖出通道, 本单退回影子运行 (待 QMT B1 落地)"
|
||||
return r
|
||||
if m == MODE_WS:
|
||||
return _ws(instruction_id=instruction_id, ts_code=ts_code, side=side, qty=qty,
|
||||
limit_price=limit_price, valid_until=valid_until,
|
||||
intent=intent, parent_id=parent_id, note=note)
|
||||
return _shadow(instruction_id, side)
|
||||
except Exception as e:
|
||||
logger.exception("下发失败 %s", instruction_id)
|
||||
|
|
@ -72,45 +147,97 @@ def _shadow(instruction_id: str, side: str) -> dict:
|
|||
f"成交由回放认领", "error": None}
|
||||
|
||||
|
||||
def _channel_y(instruction_id, ts_code, side, qty, limit_price, valid_until) -> dict:
|
||||
now = datetime.now()
|
||||
execute(
|
||||
"INSERT INTO pms_order_request (instruction_id, ts_code, side, qty, limit_price, "
|
||||
"valid_until, source, status, cancel_flag, create_time, update_time) VALUES "
|
||||
"(:iid, :code, :side, :qty, :lp, :vu, 'pms', 'NEW', 0, :ts, :ts)",
|
||||
{"iid": instruction_id, "code": ts_code, "side": str(side).lower(), "qty": int(qty),
|
||||
"lp": limit_price, "vu": valid_until or now, "ts": now})
|
||||
return {"ok": True, "ref": instruction_id, "mode": MODE_CHANNEL_Y,
|
||||
"note": "已写入 pms_order_request, 等下游轮询执行", "error": None}
|
||||
def _to_epoch_ms(valid_until) -> int:
|
||||
"""valid_until 统一成 epoch 毫秒 (协议 §1: 时间字段一律整数毫秒)。
|
||||
|
||||
executor 传的是 datetime, 页面或脚本可能直接传毫秒 —— 两种都收。
|
||||
给不出有效期时兜底为「当日 14:57」而不是无限期: 挂单不设终点等于把撤单责任又留回
|
||||
自己身上, 而 valid_until 存在的全部意义就是把它交给 QMT (协议 §4.2)。
|
||||
"""
|
||||
if isinstance(valid_until, datetime):
|
||||
return int(valid_until.timestamp() * 1000)
|
||||
if isinstance(valid_until, (int, float)) and valid_until > 0:
|
||||
v = int(valid_until)
|
||||
return v if v > 10 ** 12 else v * 1000 # 传秒的也认
|
||||
fallback = datetime.now().replace(hour=14, minute=57, second=0, microsecond=0)
|
||||
return int(fallback.timestamp() * 1000)
|
||||
|
||||
|
||||
def _plan_x(instruction_id, ts_code, qty, limit_price, stock_name) -> dict:
|
||||
"""买入走上游既有计划表。amount 由 数量×限价 反算 (下游按 buy_amount 挂单)。"""
|
||||
px = float(limit_price or 0)
|
||||
if px <= 0:
|
||||
return {"ok": False, "ref": None, "mode": MODE_PLAN_X, "note": "",
|
||||
"error": "plan_x 通道要求限价 (下游按 target_price 挂单)"}
|
||||
now = datetime.now()
|
||||
execute(
|
||||
"INSERT INTO trading_buy_plan (stock_code, stock_name, target_price, buy_amount, "
|
||||
"is_active, trading_time, create_time, update_time, approved_by, change_reason) "
|
||||
"VALUES (:code, :name, :px, :amt, 6, :ts, :ts, :ts, 'pms', :rsn)",
|
||||
{"code": ts_code, "name": stock_name or ts_code, "px": px,
|
||||
"amt": round(qty * px, 2), "ts": now,
|
||||
"rsn": f"PMS 指令 {instruction_id}"})
|
||||
return {"ok": True, "ref": f"buy_plan:{instruction_id}", "mode": MODE_PLAN_X,
|
||||
"note": "已写 trading_buy_plan(is_active=6) 待下游挂单", "error": None}
|
||||
def _parent_of(instruction_id: str, parent_id=None) -> str:
|
||||
"""子单 id 形如 {父指令}_D01 (见 executor._child_id)。显式传入的父 id 优先。"""
|
||||
if parent_id:
|
||||
return parent_id
|
||||
iid = str(instruction_id or "")
|
||||
tail = iid.rsplit("_D", 1)
|
||||
return tail[0] if len(tail) == 2 and tail[1].isdigit() else iid
|
||||
|
||||
|
||||
def _ws(*, instruction_id, ts_code, side, qty, limit_price, valid_until, intent=None,
|
||||
parent_id=None, note=None) -> dict:
|
||||
"""ws 直连: 落出口队列即返回, 实际发送由 pms-ws 常驻进程完成。
|
||||
|
||||
这里**不等** QMT 的 ack —— executor 一跳是分钟级节奏, 不该被一次网络往返卡住;
|
||||
受理结果由 ws 进程回写 pms_qmt_order.status, 页面与对账都看那里。
|
||||
"""
|
||||
side = str(side or "").lower()
|
||||
ch = channel_status()
|
||||
if not ch["process_alive"]:
|
||||
return {"ok": False, "ref": None, "mode": MODE_WS, "note": "",
|
||||
"error": f"pms-ws 常驻进程未在线 (心跳 {ch['heartbeat_age_sec']}s 前), "
|
||||
f"拒绝下发 —— 排进队列也发不出去"}
|
||||
if not ch["online"] and side != "sell":
|
||||
# 协议 §6.3: QMT 断连 → 停发一切增持指令, 仅保留减持路径
|
||||
return {"ok": False, "ref": None, "mode": MODE_WS, "note": "",
|
||||
"error": f"QMT 连接 {ch['conn_state']}, 按协议 §6.3 暂停买入下发 "
|
||||
f"(卖出不受此限)"}
|
||||
|
||||
try:
|
||||
payload = wsc.place_order_payload(
|
||||
instruction_id=instruction_id, ts_code=ts_code, side=side, qty=qty,
|
||||
limit_price=limit_price, valid_until=_to_epoch_ms(valid_until),
|
||||
intent=(intent or "OPEN"), note=(note or ""))
|
||||
except wsc.CodecError as e:
|
||||
# 参数不合规就在本地拦下 —— 换 QMT 一个 BAD_PARAM 是白跑一趟, 还污染对方日志
|
||||
return {"ok": False, "ref": None, "mode": MODE_WS, "note": "",
|
||||
"error": f"指令不合协议要求 ({e.code}): {e.message}"}
|
||||
|
||||
qmt_repo.enqueue_order(
|
||||
instruction_id=payload["instruction_id"],
|
||||
parent_id=_parent_of(instruction_id, parent_id),
|
||||
ts_code=payload["ts_code"], side=payload["side"], qty=payload["qty"],
|
||||
limit_price=payload["limit_price"], valid_until=payload["valid_until"],
|
||||
intent=payload["intent"], note=payload["note"])
|
||||
logger.info("[ws] 入队 %s %s %s %s股 @%.2f 有效至 %s", payload["instruction_id"],
|
||||
payload["ts_code"], payload["side"], payload["qty"],
|
||||
payload["limit_price"],
|
||||
datetime.fromtimestamp(payload["valid_until"] / 1000).strftime("%H:%M:%S"))
|
||||
hint = "" if ch["online"] else " (连接未就绪, 重连后立即发出)"
|
||||
return {"ok": True, "ref": payload["instruction_id"], "mode": MODE_WS,
|
||||
"note": f"已入 ws 出口队列{hint}", "error": None}
|
||||
|
||||
|
||||
# ================================================================ 撤单
|
||||
def cancel(*, instruction_id: str, dispatch_ref=None) -> dict:
|
||||
"""请求撤单。shadow/plan_x 无撤单语义, 只回执由 executor 置本地状态。"""
|
||||
"""请求撤单。shadow 无下游撤单语义, 只回执由 executor 置本地状态。
|
||||
|
||||
ws 模式下入参 instruction_id 是**父指令**, 名下可能有多张已发出的子单 —— 全部标记
|
||||
待撤, 由 ws 进程逐张发 cancel_order。已终态的子单不动 (协议 §4.3: 撤单前已全成会回
|
||||
reject{ALREADY_FINAL})。
|
||||
"""
|
||||
m = mode()
|
||||
if m != MODE_CHANNEL_Y:
|
||||
if m != MODE_WS:
|
||||
return {"ok": True, "mode": m, "note": "本模式无下游撤单动作, 仅本地置撤销"}
|
||||
try:
|
||||
n = execute("UPDATE pms_order_request SET cancel_flag = 1, update_time = :ts "
|
||||
"WHERE instruction_id = :iid AND status IN ('NEW', 'ACCEPTED', 'EXECUTING')",
|
||||
{"iid": instruction_id, "ts": datetime.now()})
|
||||
return {"ok": True, "mode": m, "note": f"已置 cancel_flag, 影响 {n} 行"}
|
||||
cancel_id = wsc.new_cancel_id(int(datetime.now().strftime("%Y%m%d")))
|
||||
n = qmt_repo.request_cancel(parent_id=instruction_id, cancel_id=cancel_id)
|
||||
except Exception as e:
|
||||
logger.exception("撤单请求落表失败 %s", instruction_id)
|
||||
return {"ok": False, "mode": m, "error": f"{type(e).__name__}: {e}"}
|
||||
if not n:
|
||||
return {"ok": True, "mode": m, "cancelled": 0,
|
||||
"note": "该指令名下没有在途子单, 仅本地置撤销"}
|
||||
note = f"已标记 {n} 张在途委托待撤 (cancel_id={cancel_id})"
|
||||
ch = channel_status()
|
||||
if not ch["online"]:
|
||||
note += f"; 连接当前 {ch['conn_state']}, 重连后发出"
|
||||
return {"ok": True, "mode": m, "cancelled": n, "cancel_id": cancel_id, "note": note}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,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)
|
||||
|
|
@ -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 # 单笔敞口告警线 (占规模)
|
||||
|
|
|
|||
101
ddl_pms_v1.sql
101
ddl_pms_v1.sql
|
|
@ -2,9 +2,10 @@
|
|||
-- tradingSystem (PMS) V1 建表 DDL
|
||||
-- 目标库: 153 代理侧 (与 decision_ledger 等同库), 一律经代理严格单表访问
|
||||
-- 字符集: utf8mb4; 代码格式: Tushare 点式 (600000.SH); 时区: Asia/Shanghai
|
||||
-- 对应设计: POSITION_MGMT_DESIGN.md V0.4 §11
|
||||
-- 注: pms_order_request (指令通道) 归属待 B1.7 协商, 其 DDL 见
|
||||
-- QMT_INTERFACE_REQUIREMENTS.md, 不在本文件建立。
|
||||
-- 对应设计: POSITION_MGMT_DESIGN.md V0.4 §11 (1~10 号表)
|
||||
-- QMT_WS_PROTOCOL.md V1.0 (11~13 号表, 2026-07-28 追加)
|
||||
-- 注: pms_order_request (原表轮询通道) 已作废 —— 双方改定 WebSocket 直连, 见文件尾部
|
||||
-- 三张 ws 通道表。该表本就未在此文件建立, 无需处理。
|
||||
-- =====================================================================
|
||||
|
||||
-- 1. 命令表 (参数命令 + 任务命令; 参数命令当前值 = 该类型最新一条 EFFECTIVE 记录)
|
||||
|
|
@ -186,3 +187,97 @@ CREATE TABLE IF NOT EXISTS pms_runtime_param (
|
|||
updated_by VARCHAR(32) NOT NULL DEFAULT 'user',
|
||||
updated_at DATETIME NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='页面可调参数当前值';
|
||||
|
||||
-- =====================================================================
|
||||
-- ws 直连通道三表 (2026-07-28 追加; 协议 QMT_WS_PROTOCOL.md V1.0)
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 为什么是三张而不是塞进已有表:
|
||||
-- * 出口队列必须**持久**。ws 是常驻进程、executor 在 celery worker 里, 两个进程之间
|
||||
-- 递指令得有个落点; 落在业务表上则「先记账后动作」这条铁律字面成立 —— 落表即记账,
|
||||
-- ws 进程崩了重启队列还在, 页面也能直接看到在途委托。
|
||||
-- * 上行消息必须**先落库再 ack** (协议 §4.5)。收到就 ack、然后崩在落库前, 那段数据
|
||||
-- QMT 那边已经清了, 永久丢失。所以 inbox 是独立表且 ack 在它之后。
|
||||
-- * seq 水位跨重启不能回退, 且更新频率高, 不适合塞 pms_runtime_param
|
||||
-- (那张表有 5 秒缓存, 且是给页面调参用的)。
|
||||
-- =====================================================================
|
||||
|
||||
-- 11. QMT 委托表 (子单) —— 兼下发出口队列
|
||||
-- 一行 = 协议里的一条 place_order = 交易所的一张委托 (§8「一条指令 = 一张委托」)。
|
||||
-- pms_instruction 是父指令 (承载一个方案条目的总量), 本表是它分日/分笔出手的子单。
|
||||
CREATE TABLE IF NOT EXISTS pms_qmt_order (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
instruction_id VARCHAR(64) NOT NULL UNIQUE COMMENT '协议幂等键 (§2.2), 也是子单主键',
|
||||
parent_id VARCHAR(64) NOT NULL COMMENT '父指令 pms_instruction.instruction_id',
|
||||
ts_code VARCHAR(16) NOT NULL COMMENT '点式 600000.SH',
|
||||
side VARCHAR(8) NOT NULL COMMENT 'buy/sell',
|
||||
qty INT NOT NULL COMMENT '股数; 买入整百, 清仓允许零股尾数',
|
||||
limit_price DECIMAL(10,2) NOT NULL COMMENT '限价, 2位小数 (协议不接受 null)',
|
||||
valid_until BIGINT NOT NULL COMMENT '有效期截止 epoch 毫秒, 到点 QMT 自动撤',
|
||||
intent VARCHAR(8) NOT NULL DEFAULT 'OPEN' COMMENT 'OPEN/FILL/ADD/DCA/TRIM/EXIT/T0',
|
||||
note VARCHAR(200) NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'QUEUED'
|
||||
COMMENT '本地: QUEUED待发/SENDING已认领/SENT已发出/SEND_FAILED/ABORTED未发即作废; '
|
||||
'协议: ACCEPTED/SUBMITTED/PARTIAL/FILLED/CANCELLED/EXPIRED/REJECTED',
|
||||
broker_order_id VARCHAR(64) NULL COMMENT 'QMT 回的委托号',
|
||||
cum_qty INT NOT NULL DEFAULT 0 COMMENT '本委托累计成交股数 (§5.4 口径)',
|
||||
cum_avg_price DECIMAL(10,3) NULL COMMENT '本委托累计成交均价',
|
||||
leaves_qty INT NULL COMMENT '未成交剩余',
|
||||
cancel_state VARCHAR(12) NOT NULL DEFAULT 'NONE'
|
||||
COMMENT 'NONE/REQUESTED(已请求撤)/SENT(撤单已发出) —— 区分 CANCELLED 与 EXPIRED 的本地依据',
|
||||
cancel_id VARCHAR(64) NULL COMMENT 'CXL-yyyymmdd-8位随机',
|
||||
cancel_req_at DATETIME NULL,
|
||||
reject_code VARCHAR(32) NULL COMMENT '协议 §7.3 拒绝码',
|
||||
reject_reason VARCHAR(300) NULL,
|
||||
send_attempts INT NOT NULL DEFAULT 0,
|
||||
sent_at DATETIME NULL,
|
||||
final_at DATETIME NULL COMMENT '进终态时刻',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
KEY idx_status (status),
|
||||
KEY idx_parent (parent_id),
|
||||
KEY idx_cancel (cancel_state),
|
||||
KEY idx_broker (broker_order_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='QMT 委托子单 (兼下发出口队列)';
|
||||
|
||||
-- 12. 上行消息收件箱 —— 「确认前必须已落库」的那个"库"
|
||||
-- seq 作主键 = 协议 §6.1 第一层去重 (补发时同一条消息 seq 不变);
|
||||
-- dedup_key 唯一 = 第二层去重 (trade_no)。任一层命中即丢弃。
|
||||
CREATE TABLE IF NOT EXISTS pms_qmt_inbox (
|
||||
seq BIGINT PRIMARY KEY COMMENT 'QMT 侧全局单调递增序号 (第一层去重)',
|
||||
msg_id VARCHAR(64) NOT NULL,
|
||||
msg_type VARCHAR(24) NOT NULL COMMENT 'trade/order_update/ack/reject/snapshot/...',
|
||||
corr_id VARCHAR(64) NULL COMMENT '关联的 instruction_id 或 cancel_id',
|
||||
dedup_key VARCHAR(80) NULL UNIQUE COMMENT '第二层去重键, 目前只有 trade:{trade_no}',
|
||||
payload_json TEXT NOT NULL COMMENT '原样存 payload, 供事后追溯与下批入账消费',
|
||||
msg_ts BIGINT NOT NULL COMMENT '对端发送时刻 epoch 毫秒',
|
||||
received_at DATETIME NOT NULL,
|
||||
processed TINYINT NOT NULL DEFAULT 0
|
||||
COMMENT '0=待入账(仅 trade) / 1=已入账 / 2=通道自处理完毕无需入账',
|
||||
processed_at DATETIME NULL,
|
||||
process_note VARCHAR(300) NULL,
|
||||
KEY idx_pending (processed, seq),
|
||||
KEY idx_corr (corr_id),
|
||||
KEY idx_type (msg_type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='QMT 上行消息收件箱';
|
||||
|
||||
-- 13. 通道状态 (恒一行, id=1)
|
||||
-- last_seq 是**连续前缀水位**, 不是收到的最大 seq —— 中间缺一条就不能往前跨,
|
||||
-- 否则那条就永久丢了 (§4.5 累积确认语义)。真值可由 inbox 重算, 本行是缓存。
|
||||
CREATE TABLE IF NOT EXISTS pms_ws_state (
|
||||
id TINYINT PRIMARY KEY COMMENT '恒为 1',
|
||||
last_seq BIGINT NOT NULL DEFAULT 0 COMMENT '已落库的连续水位 (hello.last_seq 取它)',
|
||||
acked_seq BIGINT NOT NULL DEFAULT 0 COMMENT '已发出 ack_seq 的水位',
|
||||
server_seq BIGINT NOT NULL DEFAULT 0 COMMENT 'hello_ack 里对端自报的最新序号',
|
||||
conn_state VARCHAR(12) NOT NULL DEFAULT 'INIT'
|
||||
COMMENT 'INIT/CONNECTING/ONLINE/OFFLINE/STOPPED —— dispatcher 据此决定放不放行',
|
||||
connected_at DATETIME NULL,
|
||||
heartbeat_at DATETIME NULL COMMENT 'ws 进程存活心跳; 陈旧即视为进程已死, 一律拒发',
|
||||
resync_flag TINYINT NOT NULL DEFAULT 0 COMMENT '1=对端补不齐, 须走全量对账 (§6.2)',
|
||||
last_error VARCHAR(300) NULL,
|
||||
stat_json TEXT NULL COMMENT '收发计数/重连次数等, 页面运维抽屉展示',
|
||||
updated_at DATETIME NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='ws 通道状态与序号水位';
|
||||
|
||||
INSERT INTO pms_ws_state (id, last_seq, acked_seq, server_seq, conn_state, updated_at)
|
||||
VALUES (1, 0, 0, 0, 'INIT', NOW())
|
||||
ON DUPLICATE KEY UPDATE id = id;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)} 项告警")
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue