# -*- coding: utf-8 -*- """ 指令下发通道 (设计 §9 权限移交的落点) ====================================== 两个适配器, 由参数 `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` 表保留未用, 不必删表。 留着作废路径比删掉更危险 —— 后来者会以为它可用。 """ from __future__ import annotations import logging from datetime import datetime 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_WS = "shadow", "ws" MODES = (MODE_SHADOW, MODE_WS) # ws 进程心跳超过这个时长没更新, 就认为进程已经没了 (它每 2 秒写一次) DEFAULT_STALE_SEC = 15 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 # ================================================================ 通道健康 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() 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, intent=None, parent_id=None, note=None) -> dict: """递一手给下游。返回 {ok, ref, mode, note, error}; ok=False 时 executor 不改指令状态。""" m = mode() try: 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) 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} 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"(卖出不受此限)"} 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 无下游撤单语义, 只回执由 executor 置本地状态。 ws 模式下入参 instruction_id 是**父指令**, 名下可能有多张已发出的子单 —— 全部标记 待撤, 由 ws 进程逐张发 cancel_order。已终态的子单不动 (协议 §4.3: 撤单前已全成会回 reject{ALREADY_FINAL})。 """ m = mode() if m != MODE_WS: return {"ok": True, "mode": m, "note": "本模式无下游撤单动作, 仅本地置撤销"} try: 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}