From 2b2ae3c4d83bcf74b5f4326c99b88521059f5bbb Mon Sep 17 00:00:00 2001 From: claude_dev Date: Thu, 10 Sep 2026 07:24:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(ops):=20=E5=A4=A7QMT=E5=93=A8=E5=85=B5+09:?= =?UTF-8?q?15=E7=9B=98=E5=89=8D=E6=8E=A2=E9=92=88=E4=B8=89=E5=B1=82?= =?UTF-8?q?=E9=98=B2=E5=BE=A1=E4=B8=8A=E7=BA=BF=E2=80=94=E2=80=9430min?= =?UTF-8?q?=E5=B7=A1=E6=A3=80+=E5=BF=85=E4=B8=8D=E6=88=90=E4=BA=A4?= =?UTF-8?q?=E5=8D=95=E5=8D=B3=E6=92=A4+gate=E7=95=99=E7=97=95=20[vps]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - qmt_sentinel.py: sanguo-qmt-sentinel 每30min(进程+桥RPC ping判红,账户类仅记录); 红->schtasks /run sanguo-qmt-relogin->轮询桥恢复<=10min->复检留痕;无退避(拍板#4) - qmt_probe_0915.py: sanguo-qmt-probe0915 周一至五09:15; 510050.SH买100@0.01 FIX_PRICE必不成交单(09-09 09:40实证参数形状)+即撤; status57废单=会话陈旧 ->触发relogin赶开盘; 窗口硬卡09:15:00-09:19:30(09:20-09:25不可撤) - qmt_gate_common.py: redis/ping/tasklist/触发/轮询/gate留痕共享库 - 留痕: C:\sanguo_bigqmt\gate\qmt_gate_YYYYMMDD.log + qmt_gate_latest.json - ui_act.py 修复: activate仅contains时KeyError; credtype LocalFree类型错; 新增text op (typewrite连打丢数字/shift字符,验证码须key/hotkey逐字符打) - 实测: 首夜14跳全绿cash零漂移; 下单RPC管线ok=True; SKIP/退出码/桥死分支全验 - schtask XML x2(SYSTEM+HighestAvailable,照sanguo-xt-daily模式) --- scripts/qmt_relogin/qmt_gate_common.py | 122 ++++++++++++ scripts/qmt_relogin/qmt_probe0915_task.xml | 42 +++++ scripts/qmt_relogin/qmt_probe_0915.py | 209 +++++++++++++++++++++ scripts/qmt_relogin/qmt_sentinel.py | 107 +++++++++++ scripts/qmt_relogin/qmt_sentinel_task.xml | 40 ++++ scripts/qmt_relogin/ui_act.py | 11 +- 6 files changed, 529 insertions(+), 2 deletions(-) create mode 100644 scripts/qmt_relogin/qmt_gate_common.py create mode 100644 scripts/qmt_relogin/qmt_probe0915_task.xml create mode 100644 scripts/qmt_relogin/qmt_probe_0915.py create mode 100644 scripts/qmt_relogin/qmt_sentinel.py create mode 100644 scripts/qmt_relogin/qmt_sentinel_task.xml diff --git a/scripts/qmt_relogin/qmt_gate_common.py b/scripts/qmt_relogin/qmt_gate_common.py new file mode 100644 index 0000000..36dbf6d --- /dev/null +++ b/scripts/qmt_relogin/qmt_gate_common.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +"""Shared helpers for the QMT gate scripts (qmt_sentinel.py / qmt_probe_0915.py). + +Design: scripts judge AVAILABILITY (process + bridge RPC + counter behaviour), +agents/humans judge CORRECTNESS (cash baseline drift is recorded, never red). +Audit trail: C:\\sanguo_bigqmt\\gate\\qmt_gate_YYYYMMDD.log (append) + +qmt_gate_latest.json (overwrite status light). +""" +import datetime +import json +import re +import subprocess + +ACCOUNT = "66639661" +GATE_DIR = r"C:\sanguo_bigqmt\gate" +RELOGIN_TASK = "sanguo-qmt-relogin" +QMT_PROCS = ("XtItClient.exe", "XtMiniQmt.exe", "miniquote.exe") + + +def now_str(): + return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def gate_log(kind, verdict, msg): + line = "%s [%s] %s %s" % (now_str(), kind, verdict, msg) + try: + import os + os.makedirs(GATE_DIR, exist_ok=True) + path = r"%s\qmt_gate_%s.log" % ( + GATE_DIR, datetime.datetime.now().strftime("%Y%m%d")) + with open(path, "a", encoding="utf-8") as f: + f.write(line + "\n") + except Exception as exc: + line += " (log-write-failed %r)" % exc + print(line, flush=True) + return line + + +def gate_json(kind, verdict, **detail): + import os + payload = {"ts": now_str(), "kind": kind, "verdict": verdict} + payload.update(detail) + try: + os.makedirs(GATE_DIR, exist_ok=True) + with open(r"%s\qmt_gate_latest.json" % GATE_DIR, "w", + encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, default=str, indent=1) + except Exception as exc: + print("GATE_JSON_WRITE_FAIL %r" % exc, flush=True) + return payload + + +def redis_client(): + import redis + with open(r"C:\redis\redis.conf") as f: + pw = re.search(r"^requirepass (\S+)", f.read(), re.M).group(1) + return redis.Redis(host="127.0.0.1", port=6379, db=5, password=pw, + socket_connect_timeout=5, decode_responses=True) + + +def bridge_ping(rc, timeout=10): + """(up, detail) — up means the RPC server answered ping with ok+pong.""" + from bigqmt_signal_trader.redis_rpc import call_redis_rpc + try: + r = call_redis_rpc(rc, ACCOUNT, "ping", {}, timeout_seconds=timeout) + except Exception as exc: + return False, "RPC_DOWN %s %s" % (type(exc).__name__, str(exc)[:120]) + if not isinstance(r, dict): + return False, "RPC_BAD %r" % (r,) + if not r.get("ok"): + return False, "RPC_ERR %s" % str(r.get("error") or r)[:150] + d = r.get("data") or {} + if not d.get("pong"): + return False, "RPC_NO_PONG %s" % str(d)[:150] + return True, "server_time=%s rev=%s allow_order=%s" % ( + d.get("server_time"), d.get("rpc_revision"), d.get("allow_order_methods")) + + +def procs_alive(): + """QMT family process presence via tasklist (no psutil dependency).""" + out = subprocess.run(["tasklist", "/FO", "CSV", "/NH"], + capture_output=True, text=True, timeout=30).stdout or "" + return {name: ('"%s"' % name) in out for name in QMT_PROCS} + + +def relogin_running(): + """True if the v2 relogin python is mid-flight (avoid double trigger).""" + try: + import psutil + except ImportError: + return False + for p in psutil.process_iter(["pid", "name", "cmdline"]): + try: + if "python" not in (p.info["name"] or "").lower(): + continue + cmd = " ".join(p.info["cmdline"] or []) + except Exception: + continue + if "qmt_relogin.py" in cmd: + return True + return False + + +def trigger_relogin(): + r = subprocess.run(["schtasks", "/run", "/tn", RELOGIN_TASK], + capture_output=True, text=True, timeout=30) + ok = r.returncode == 0 + return ok, ((r.stdout or "") + (r.stderr or "")).strip()[:120] + + +def wait_bridge(rc, timeout_sec, kind): + """Poll bridge ping until it answers; every probe logged once at the end.""" + import time + t0 = time.time() + last = "" + while time.time() - t0 < timeout_sec: + up, detail = bridge_ping(rc) + if up: + return True, int(time.time() - t0), detail + last = detail + time.sleep(5) + return False, int(time.time() - t0), last diff --git a/scripts/qmt_relogin/qmt_probe0915_task.xml b/scripts/qmt_relogin/qmt_probe0915_task.xml new file mode 100644 index 0000000..53e2c95 --- /dev/null +++ b/scripts/qmt_relogin/qmt_probe0915_task.xml @@ -0,0 +1,42 @@ + + + + Big QMT pre-open ACTIVE trade probe 09:15 Mon-Fri: 510050 x100 @0.01 never-fill order + instant cancel. Junked order = stale session -> triggers sanguo-qmt-relogin before the open. Audit trail C:\sanguo_bigqmt\gate\qmt_gate_*.log + + + + 2026-09-10T09:15:00 + true + + + + + + + + + 1 + + + + + false + false + PT12M + IgnoreNew + true + true + + + + S-1-5-18 + HighestAvailable + + + + + C:\Python310\python.exe + -X utf8 C:\sanguo_bigqmt\qmt_probe_0915.py + + + diff --git a/scripts/qmt_relogin/qmt_probe_0915.py b/scripts/qmt_relogin/qmt_probe_0915.py new file mode 100644 index 0000000..36105bd --- /dev/null +++ b/scripts/qmt_relogin/qmt_probe_0915.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- +"""sanguo-qmt-probe0915 — pre-open ACTIVE trade probe (the only gold standard +for session-day freshness: passive queries cannot see a stale session). + +Order shape (proven 2026-09-09 09:40 on this exact account/counter): + 510050.SH BUY 100 @ 0.01 FIX_PRICE — a price that can NEVER fill, so the + probe is risk-free; what matters is how the counter treats it. + accepted (status 48..56) -> session is day-current -> GREEN -> cancel it + junked (status 57 / reject msg) -> stale session -> RED -> trigger + sanguo-qmt-relogin (~4min, back before the 09:30 open) + +Window is hard-coded 09:15:00-09:19:30: 09:20-09:25 is the no-cancel auction +phase — an order placed there could not be pulled before the open. + +Exit codes: 0 green | 1 red | 2 amber/skip. +""" +import datetime +import sys +import time + +from qmt_gate_common import (ACCOUNT, bridge_ping, gate_json, gate_log, + procs_alive, redis_client, relogin_running, + trigger_relogin, wait_bridge) + +STOCK = "510050.SH" +WINDOW_START = (9, 15, 0) +WINDOW_END = (9, 19, 30) +LIVE_STATUSES = {48, 49, 50, 55, 56} # unreported..reported, part/full fill +CANCEL_FAMILY = {51, 52, 53, 54} # accepted then (part) cancelled +JUNK_STATUS = 57 # ORDER_JUNK = counter rejection +HOLIDAY_WORDS = ("非交易", "休市", "闭市", "非交易日", "节假日") +RECOVER_TIMEOUT = 360 # 6 min: back before the 09:30 open + + +def _in_window(now): + hms = (now.hour, now.minute, now.second) + if hms < WINDOW_START: + wait = (WINDOW_START[0] * 3600 + WINDOW_START[1] * 60 + WINDOW_START[2] + - (hms[0] * 3600 + hms[1] * 60 + hms[2])) + return "early", wait + if hms > WINDOW_END: + return "late", 0 + return "in", 0 + + +def rpc(rc, method, params, timeout=30): + from bigqmt_signal_trader.redis_rpc import call_redis_rpc + return call_redis_rpc(rc, ACCOUNT, method, params, timeout_seconds=timeout) + + +def find_order(rc, remark): + """The probe order of today by exact remark, from the counter's order list.""" + for _ in range(5): + try: + r = rpc(rc, "query_stock_orders", {"account_id": ACCOUNT}) + orders = r.get("data") if isinstance(r.get("data"), list) else [] + for o in orders: + if str(o.get("remark") or o.get("user_order_id") or "") == remark: + return o + except Exception as exc: + gate_log("PROBE0915", "NOTE", "orders query err %r" % exc) + time.sleep(2) + return None + + +def place(rc, remark): + res = rpc(rc, "order_stock", { + "account_id": ACCOUNT, "stock_code": STOCK, "order_type": 23, + "order_volume": 100, "price_type": 11, "price": 0.01, + "strategy_name": "probe", "order_remark": remark}) + gate_log("PROBE0915", "NOTE", + "order placed remark=%s res=%s" % (remark, str(res)[:160])) + + +def cancel(rc, order): + sysid = str(order.get("order_sys_id") or "") + if not sysid: + gate_log("PROBE0915", "WARN", "no order_sys_id to cancel: %s" + % str(order)[:160]) + return False + for attempt in range(3): + try: + r = rpc(rc, "cancel_order_stock_sysid", + {"account_id": ACCOUNT, "market": "", "order_sysid": sysid}) + ok = "ok" in r and r.get("ok") is not False + gate_log("PROBE0915", "NOTE", "cancel sysid=%s try%d -> %s" + % (sysid, attempt + 1, str(r)[:120])) + except Exception as exc: + gate_log("PROBE0915", "WARN", "cancel exc %r" % exc) + ok = False + time.sleep(2) + cur = find_order(rc, str(order.get("remark") or "")) + if cur and int(cur.get("status") or 0) in (CANCEL_FAMILY | LIVE_STATUSES): + st = int(cur.get("status") or 0) + if st in CANCEL_FAMILY: + return True + elif cur is None: + return True # gone from the list: fully pulled + gate_log("PROBE0915", "CRITICAL", + "PROBE_ORDER_LEFT_LIVE sysid=%s status=%s — cannot fill @0.01, " + "day-end settlement clears it; review manually" + % (sysid, (cur or {}).get("status"))) + return False + + +def main(): + now = datetime.datetime.now() + remark = "probe:gate:%s" % now.strftime("%Y%m%d") + state, wait = _in_window(now) + if state == "late": + gate_log("PROBE0915", "SKIP", "past 09:19:30 window, no probe today") + gate_json("probe0915", "SKIP", reason="late") + return 2 + if state == "early": + gate_log("PROBE0915", "NOTE", "early by %ds, waiting for window" % wait) + time.sleep(min(wait + 1, 300)) + now = datetime.datetime.now() + if _in_window(now)[0] != "in": + gate_log("PROBE0915", "SKIP", "window lost while waiting") + gate_json("probe0915", "SKIP", reason="window-lost") + return 2 + + try: + rc = redis_client() + except Exception as exc: + gate_log("PROBE0915", "AMBER", "redis connect failed %r" % exc) + return 2 + up, detail = bridge_ping(rc) + if not up: + gate_log("PROBE0915", "AMBER", + "bridge already down pre-order (%s); relogin is sentinel's " + "job, skipping probe" % detail) + return 2 + + existing = find_order(rc, remark) + if existing is None: + place(rc, remark) + # counter reporting can lag at the 09:15:00 boundary second + # (night test 09-09: after-hours submissions stayed invisible); + # poll patiently — the cancel window still has minutes of margin. + for _ in range(3): + time.sleep(3) + existing = find_order(rc, remark) + if existing is not None: + break + if existing is None: + gate_log("PROBE0915", "AMBER", + "order not visible after 5 queries; cannot judge, no trigger") + gate_json("probe0915", "AMBER", reason="order-not-visible") + return 2 + + status = int(existing.get("status") or 0) + msg = str(existing.get("status_msg") or "")[:100] + gate_log("PROBE0915", "NOTE", + "order status=%d msg=%r sysid=%s" % ( + status, msg, existing.get("order_sys_id"))) + + if status == JUNK_STATUS or (msg and any(w in msg for w in ("废", "拒"))): + if any(w in msg for w in HOLIDAY_WORDS): + gate_log("PROBE0915", "AMBER", + "junked with holiday wording (%r) — not a staleness " + "signal, no trigger" % msg) + gate_json("probe0915", "AMBER", status=status, msg=msg) + return 2 + gate_log("PROBE0915", "RED", + "counter junked the probe order -> session stale; " + "triggering relogin") + return recover(rc, "junked:%s" % msg) + + # accepted by the counter in any form -> session is day-current + ok_cancel = True + if status in LIVE_STATUSES: + ok_cancel = cancel(rc, existing) + gate_log("PROBE0915", "GREEN", + "order accepted (status=%d) -> session day-current; cancelled=%s" + % (status, ok_cancel)) + gate_json("probe0915", "GREEN", status=status, cancelled=ok_cancel, + sysid=str(existing.get("order_sys_id"))) + return 0 + + +def recover(rc, reason): + if relogin_running(): + gate_log("PROBE0915", "SKIP", "relogin already in flight (%s)" % reason) + else: + ok, out = trigger_relogin() + if not ok: + gate_log("PROBE0915", "AMBER", "schtasks /run failed: %s" % out) + return 2 + gate_log("PROBE0915", "ACTION", + "relogin triggered (%s); polling bridge <=%ds" % (reason, + RECOVER_TIMEOUT)) + ok, took, detail = wait_bridge(rc, RECOVER_TIMEOUT, "PROBE0915") + procs = procs_alive() + if ok: + gate_log("PROBE0915", "RECOVERED", + "bridge back in %ds before open (%s) procs=%s" % ( + took, detail, procs)) + gate_json("probe0915", "RED_RECOVERED", reason=reason, took_sec=took) + return 1 # the session WAS stale this morning; red stays on record + gate_log("PROBE0915", "RED", + "NOT recovered in %ds (%s) — manual attention before open" % ( + took, detail)) + gate_json("probe0915", "RED", reason=reason, took_sec=took, procs=procs) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/qmt_relogin/qmt_sentinel.py b/scripts/qmt_relogin/qmt_sentinel.py new file mode 100644 index 0000000..644d42c --- /dev/null +++ b/scripts/qmt_relogin/qmt_sentinel.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +"""sanguo-qmt-sentinel — every-30min availability patrol for the Big QMT stack. + +Verdict rules (2026-09-09 design, user-approved): + judge RED only on: XtItClient.exe gone OR bridge RPC ping dead. + account-class probes (asset/positions) and the data leg (XtMiniQmt/miniquote) + are recorded as telemetry only — post-close false-reds by design. + RED -> schtasks /run sanguo-qmt-relogin (reuse v2, never copy login code) + -> poll bridge <=10min -> re-check -> full audit trail in gate log. + No backoff on consecutive failures (user decision #4): every tick independent, + failures just stack in the log; the next tick retries naturally. + +Exit codes: 0 green/recovered | 1 red-remains | 2 amber (cannot judge). +""" +import json + +from qmt_gate_common import (ACCOUNT, bridge_ping, gate_json, gate_log, + procs_alive, redis_client, relogin_running, + trigger_relogin, wait_bridge) + +RECOVER_TIMEOUT = 600 # 10 min budget for relogin round trip + + +def telemetry(rc): + """Recorded, never judged: allow_order bit, asset cash, queue length.""" + from bigqmt_signal_trader.redis_rpc import call_redis_rpc + t = {} + try: + r = call_redis_rpc(rc, ACCOUNT, "query_stock_asset", + {"account_id": ACCOUNT}, timeout_seconds=15) + d = r.get("data") or {} + t["cash"] = d.get("cash") + t["mktval"] = d.get("market_value") + except Exception as exc: + t["asset_err"] = "%s %s" % (type(exc).__name__, str(exc)[:80]) + try: + t["queue_len"] = rc.llen("bigqmt:rpc:queue:%s" % ACCOUNT) + except Exception as exc: + t["queue_err"] = str(exc)[:60] + return t + + +def main(): + procs = procs_alive() + if not procs.get("XtItClient.exe"): + msg = "XtItClient.exe DEAD family=%s" % procs + gate_log("SENTINEL", "RED", msg) + return recover("process-dead") + try: + rc = redis_client() + except Exception as exc: + gate_log("SENTINEL", "AMBER", "redis connect failed %r" % exc) + return 2 + up, detail = bridge_ping(rc) + if not up: + gate_log("SENTINEL", "RED", "bridge ping dead (%s)" % detail) + return recover("bridge-dead") + t = telemetry(rc) + msg = "proc=1 bridge=up %s mini=%d miniquote=%d cash=%s mktval=%s q=%s" % ( + detail, procs.get("XtMiniQmt.exe", 0), procs.get("miniquote.exe", 0), + t.get("cash"), t.get("mktval"), t.get("queue_len")) + gate_log("SENTINEL", "GREEN", msg) + gate_json("sentinel", "GREEN", bridge=detail, procs=procs, **t) + return 0 + + +def recover(reason): + """Trigger v2 relogin, wait for the bridge, re-check, log the outcome.""" + try: + rc = redis_client() + except Exception as exc: + gate_log("SENTINEL", "AMBER", "redis failed pre-recovery %r" % exc) + return 2 + if relogin_running(): + gate_log("SENTINEL", "SKIP", + "relogin already in flight (%s); let it finish" % reason) + ok, took, detail = wait_bridge(rc, RECOVER_TIMEOUT, "SENTINEL") + return 0 if ok else 1 + ok, out = trigger_relogin() + if not ok: + gate_log("SENTINEL", "AMBER", + "schtasks /run %s failed: %s" % (reason, out)) + return 2 + gate_log("SENTINEL", "ACTION", + "relogin triggered (%s); polling bridge <=%ds" % (reason, + RECOVER_TIMEOUT)) + ok, took, detail = wait_bridge(rc, RECOVER_TIMEOUT, "SENTINEL") + procs = procs_alive() + if ok and procs.get("XtItClient.exe"): + t = telemetry(rc) + gate_log("SENTINEL", "RECOVERED", + "bridge back in %ds (%s) procs=%s cash=%s" % ( + took, detail, procs, t.get("cash"))) + gate_json("sentinel", "RECOVERED", reason=reason, took_sec=took, + bridge=detail, procs=procs, **t) + return 0 + gate_log("SENTINEL", "RED", + "NOT recovered in %ds (bridge=%s procs=%s) — no backoff by " + "design, next tick retries" % (took, detail, procs)) + gate_json("sentinel", "RED", reason=reason, took_sec=took, bridge=detail, + procs=procs) + return 1 + + +if __name__ == "__main__": + import sys + sys.exit(main()) diff --git a/scripts/qmt_relogin/qmt_sentinel_task.xml b/scripts/qmt_relogin/qmt_sentinel_task.xml new file mode 100644 index 0000000..1364d40 --- /dev/null +++ b/scripts/qmt_relogin/qmt_sentinel_task.xml @@ -0,0 +1,40 @@ + + + + Big QMT 30min availability sentinel: XtItClient process + bridge RPC ping; RED triggers sanguo-qmt-relogin then polls recovery. Audit trail C:\sanguo_bigqmt\gate\qmt_gate_*.log + + + + 2026-09-10T00:10:00 + true + + 1 + + + PT30M + P1D + false + + + + + false + false + PT12M + IgnoreNew + true + true + + + + S-1-5-18 + HighestAvailable + + + + + C:\Python310\python.exe + -X utf8 C:\sanguo_bigqmt\qmt_sentinel.py + + + diff --git a/scripts/qmt_relogin/ui_act.py b/scripts/qmt_relogin/ui_act.py index 8ab86c5..8a6da67 100644 --- a/scripts/qmt_relogin/ui_act.py +++ b/scripts/qmt_relogin/ui_act.py @@ -60,7 +60,8 @@ for a in plan: if op == "activate": import ctypes user32 = ctypes.windll.user32 - h = user32.FindWindowW(None, a["title"]) + title = a.get("title") + h = user32.FindWindowW(None, title) if title else 0 if not h and a.get("contains"): Ws = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p) found = [] @@ -136,7 +137,13 @@ for a in plan: time.sleep(0.4) pyautogui.typewrite(pw, interval=0.06) print("CREDTYPE typed len=%d" % len(pw)) - ctypes.windll.kernel32.LocalFree(ctypes.c_void_p(bout.pbData)) + ctypes.windll.kernel32.LocalFree(bout.pbData) + if op == "text": + # type arbitrary (ASCII) text at x,y — used for captcha fields + pyautogui.click(a["x"], a["y"]) + time.sleep(0.3) + pyautogui.typewrite(a["s"], interval=0.08) + print("TEXT typed len=%d" % len(a["s"])) if op == "info": im = pyautogui.screenshot() print("SIZE_cursor=%s SIZE_bitmap=%s" % (pyautogui.size(), im.size))