2026-07-28 09:10:07 +08:00
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
"""
|
|
|
|
|
指令下发通道 (设计 §9 权限移交的落点)
|
|
|
|
|
======================================
|
2026-07-28 15:48:57 +08:00
|
|
|
两个适配器, 由参数 `PMS_DISPATCH_MODE` 切换, **默认 shadow**:
|
|
|
|
|
|
|
|
|
|
shadow 影子运行 —— 只记账不下发。指令照常过规则闸、照常置 DISPATCHED,
|
|
|
|
|
等用户人工在 QMT 侧执行, 成交由回放按 FIFO 认领回来。
|
|
|
|
|
这是设计 §9 的一期口径:「通道未通前由用户人工执行、PMS 记账跟踪」。
|
|
|
|
|
ws WebSocket 长连接直连 QMT 执行服务, 协议见 QMT_WS_PROTOCOL.md V1.0。
|
|
|
|
|
|
|
|
|
|
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` 表保留未用, 不必删表。
|
|
|
|
|
|
|
|
|
|
留着作废路径比删掉更危险 —— 后来者会以为它可用。
|
2026-07-28 09:10:07 +08:00
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
2026-07-28 15:48:57 +08:00
|
|
|
from app.core import ws_codec as wsc
|
|
|
|
|
from app.repo import qmt_repo
|
2026-07-28 09:10:07 +08:00
|
|
|
from app.services import param_store
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger("pms.dispatch")
|
|
|
|
|
|
2026-07-28 15:48:57 +08:00
|
|
|
MODE_SHADOW, MODE_WS = "shadow", "ws"
|
|
|
|
|
MODES = (MODE_SHADOW, MODE_WS)
|
|
|
|
|
|
|
|
|
|
# ws 进程心跳超过这个时长没更新, 就认为进程已经没了 (它每 2 秒写一次)
|
|
|
|
|
DEFAULT_STALE_SEC = 15
|
2026-07-28 09:10:07 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def mode() -> str:
|
|
|
|
|
m = (param_store.get("PMS_DISPATCH_MODE", MODE_SHADOW) or MODE_SHADOW).strip()
|
|
|
|
|
return m if m in MODES else MODE_SHADOW
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 15:48:57 +08:00
|
|
|
# ================================================================ 通道健康
|
|
|
|
|
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
|
2026-07-28 09:10:07 +08:00
|
|
|
|
|
|
|
|
|
2026-07-28 15:48:57 +08:00
|
|
|
def describe() -> dict:
|
|
|
|
|
m = mode()
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ================================================================ 下发
|
2026-07-28 09:10:07 +08:00
|
|
|
def dispatch(*, instruction_id: str, ts_code: str, side: str, qty: int, limit_price=None,
|
2026-07-28 15:48:57 +08:00
|
|
|
valid_until=None, stock_name=None, intent=None, parent_id=None,
|
|
|
|
|
note=None) -> dict:
|
2026-07-28 09:10:07 +08:00
|
|
|
"""递一手给下游。返回 {ok, ref, mode, note, error}; ok=False 时 executor 不改指令状态。"""
|
|
|
|
|
m = mode()
|
|
|
|
|
try:
|
2026-07-28 15:48:57 +08:00
|
|
|
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)
|
2026-07-28 09:10:07 +08:00
|
|
|
return _shadow(instruction_id, side)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.exception("下发失败 %s", instruction_id)
|
|
|
|
|
return {"ok": False, "ref": None, "mode": m, "note": "",
|
|
|
|
|
"error": f"{type(e).__name__}: {e}"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _shadow(instruction_id: str, side: str) -> dict:
|
|
|
|
|
return {"ok": True, "ref": f"manual:{instruction_id}", "mode": MODE_SHADOW,
|
|
|
|
|
"note": f"影子运行: 请在 QMT 侧人工{'买入' if side == 'buy' else '卖出'}, "
|
|
|
|
|
f"成交由回放认领", "error": None}
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 15:48:57 +08:00
|
|
|
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 _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"(卖出不受此限)"}
|
2026-07-28 09:10:07 +08:00
|
|
|
|
2026-07-28 15:48:57 +08:00
|
|
|
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}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ================================================================ 撤单
|
2026-07-28 09:10:07 +08:00
|
|
|
def cancel(*, instruction_id: str, dispatch_ref=None) -> dict:
|
2026-07-28 15:48:57 +08:00
|
|
|
"""请求撤单。shadow 无下游撤单语义, 只回执由 executor 置本地状态。
|
|
|
|
|
|
|
|
|
|
ws 模式下入参 instruction_id 是**父指令**, 名下可能有多张已发出的子单 —— 全部标记
|
|
|
|
|
待撤, 由 ws 进程逐张发 cancel_order。已终态的子单不动 (协议 §4.3: 撤单前已全成会回
|
|
|
|
|
reject{ALREADY_FINAL})。
|
|
|
|
|
"""
|
2026-07-28 09:10:07 +08:00
|
|
|
m = mode()
|
2026-07-28 15:48:57 +08:00
|
|
|
if m != MODE_WS:
|
2026-07-28 09:10:07 +08:00
|
|
|
return {"ok": True, "mode": m, "note": "本模式无下游撤单动作, 仅本地置撤销"}
|
|
|
|
|
try:
|
2026-07-28 15:48:57 +08:00
|
|
|
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)
|
2026-07-28 09:10:07 +08:00
|
|
|
except Exception as e:
|
2026-07-28 15:48:57 +08:00
|
|
|
logger.exception("撤单请求落表失败 %s", instruction_id)
|
2026-07-28 09:10:07 +08:00
|
|
|
return {"ok": False, "mode": m, "error": f"{type(e).__name__}: {e}"}
|
2026-07-28 15:48:57 +08:00
|
|
|
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}
|