tradingSystem/app/services/dispatcher.py

244 lines
12 KiB
Python
Raw Normal View History

# -*- 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` 表保留未用, 不必删表
留着作废路径比删掉更危险 后来者会以为它可用
"""
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
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
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 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
# ================================================================ 下发
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:
"""递一手给下游。返回 {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)
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 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}
# ================================================================ 撤单
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})
"""
m = mode()
2026-07-28 15:48:57 +08:00
if m != MODE_WS:
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)
except Exception as e:
2026-07-28 15:48:57 +08:00
logger.exception("撤单请求落表失败 %s", instruction_id)
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}