tradingSystem/app/ws/runner.py

685 lines
37 KiB
Python
Raw Normal View History

2026-07-28 15:48:57 +08:00
# -*- 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)
2026-07-29 09:14:38 +08:00
self._db_ready = False # 通道三表是否可用 (缺表时空转重试, 不写心跳)
2026-07-28 15:48:57 +08:00
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}
2026-07-29 09:14:38 +08:00
logger.warning("参数刷新失败, 沿用上一份: %s", _brief_err(e))
2026-07-28 15:48:57 +08:00
@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()
2026-07-29 09:14:38 +08:00
# 注意: 不在这里做 _boot —— 建表检查放进连接循环, 通道没启用时就完全不碰库,
# 表建好之后也能自己恢复, 不必重启容器。
2026-07-28 15:48:57 +08:00
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()
2026-07-29 09:14:38 +08:00
async def _boot(self) -> bool:
"""启动自检: 三表在不在 → 恢复水位 → 把上次崩在半路的 SENDING 退回队列。
返回 False 表示还不能开工 (通常是表没建), 由连接循环退回空转并定期重试
"""
2026-07-28 15:48:57 +08:00
try:
await _db(qmt_repo.ensure_state)
2026-07-29 09:14:38 +08:00
except Exception as e:
self._db_ready = False
msg = _brief_err(e)
if _looks_like_missing_table(msg):
# 缺表是**部署少跑了一步**, 不是异常。给一行照着做就能好的话,
# 而不是六十行 SQLAlchemy 堆栈 —— 后者会把真正该看的信息埋掉。
logger.error(
"ws 通道三表还没建 (%s)。先建表再起本进程:\n"
" docker compose run --rm pms-web python scripts/init_db.py --yes\n"
" docker compose run --rm pms-web python scripts/check_db.py\n"
" 建完**不用重启容器**, 本进程每 60 秒自己重试。在此之前不写心跳, "
"dispatcher 会据此拒发指令 —— 这是对的。", msg)
else:
logger.error("通道表自检失败 (每 60 秒重试): %s", msg)
return False
self._db_ready = True
try:
2026-07-28 15:48:57 +08:00
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:
2026-07-29 09:14:38 +08:00
logger.error("启动自检失败, 水位按 0 起算 (下轮重连会重试): %s", _brief_err(e))
return False
return True
2026-07-28 15:48:57 +08:00
def _operable(self) -> bool:
2026-07-29 09:14:38 +08:00
"""通道是否**有可能**工作 (已启用 + 密钥齐 + 表已建)。不满足就不写心跳 —— 见 _beat_loop。"""
2026-07-28 15:48:57 +08:00
seed, peer = self._secrets()
2026-07-29 09:14:38 +08:00
return bool(self._p("enabled", False) and seed and peer and self._db_ready)
2026-07-28 15:48:57 +08:00
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:
2026-07-29 09:14:38 +08:00
logger.warning("心跳写入失败 (库不可用?): %s", _brief_err(e))
2026-07-28 15:48:57 +08:00
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 "
2026-07-29 09:14:38 +08:00
"须在 .env 注入 (协议 §10.1.1)。生成与自检: "
"python scripts/gen_keys.py [--check]。未配齐前不连接, 也不写心跳 —— "
2026-07-28 15:48:57 +08:00
"dispatcher 会因此一律拒发, 这是对的", error=True)
continue
2026-07-29 09:14:38 +08:00
if not self._db_ready and not await self._boot():
# 建表提示已在 _boot 里打过一次, 这里只安静等 —— 不重复刷屏
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(self._stop.wait(), timeout=60)
continue
2026-07-28 15:48:57 +08:00
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。
2026-07-29 09:14:38 +08:00
logger.warning("通道状态更新失败 %s %s: %s", type_, iid, _brief_err(e))
2026-07-28 15:48:57 +08:00
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:
2026-07-29 09:14:38 +08:00
logger.warning("水位落库失败, 本轮不 ack (宁可让对端多留一会儿): %s", _brief_err(e))
2026-07-28 15:48:57 +08:00
return
try:
await self._send(wsc.T_ACK_SEQ, wsc.ack_seq_payload(self._last_seq), seed)
except Exception as e:
2026-07-29 09:14:38 +08:00
logger.warning("ack_seq 发送失败 (下轮重试): %s", _brief_err(e))
2026-07-28 15:48:57 +08:00
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
2026-07-29 09:14:38 +08:00
logger.exception("出口轮询异常 (不产生新指令, 下轮继续): %s", _brief_err(e))
2026-07-28 15:48:57 +08:00
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"))
2026-07-29 09:14:38 +08:00
def _brief_err(e: BaseException) -> str:
"""异常压成一行。
SQLAlchemy 的报错里会把整条 SQL 和全部参数带上, 动辄十几行; 再叠一层
logger.exception 的堆栈, 一个表还没建能刷出六十行, 真正有用的那半句反而被埋掉
日志是给人看的, 定位靠的是第一句话
"""
first = str(e).splitlines()[0].strip()
return f"{type(e).__name__}: {first[:220]}"
def _looks_like_missing_table(msg: str) -> bool:
"""这条报错是不是「表不存在」。MySQL 是 1146, ShardingSphere 代理回的是 10002。"""
low = msg.lower()
return ("does not exist" in low or "doesn't exist" in low
or "1146" in low or "10002" in low)
2026-07-28 15:48:57 +08:00
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)