diff --git a/scripts/qmt_relogin/patch_market_bigqmt_20260911.py b/scripts/qmt_relogin/patch_market_bigqmt_20260911.py new file mode 100644 index 0000000..6f1664d --- /dev/null +++ b/scripts/qmt_relogin/patch_market_bigqmt_20260911.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +"""Contract fix 2026-09-11: server-side get_trading_dates output normalization. + +The native xtdata SDK path returns 'YYYYMMDD' strings whenever any local +quote source has been reachable; the ContextInfo path returns epoch-ms ints. +Consumers (bullet_trade miniqmt provider ~line 1824) do ts/1000, so both +shapes must leave the server as Beijing-midnight epoch-ms ints. +Idempotent: re-running on a patched file exits 0. + +Run ON THE VPS: C:\\Python310\\python.exe -X utf8 patch_market_bigqmt_20260911.py +""" +import io +import shutil +import sys + +P = r"C:\sanguo_bigqmt\bigqmt_signal_trader\adapters\market_bigqmt.py" +BAK = P + ".bak_20260911" + +OLD = ( + " return self._native_or_context(\n" + ' "get_trading_dates", _via_context, market, start_time, end_time, count\n' + " )" +) +NEW = ( + " return _norm_trading_dates(self._native_or_context(\n" + ' "get_trading_dates", _via_context, market, start_time, end_time, count))' +) + +HELPER = ''' + +def _norm_trading_dates(result): + """Contract fix 2026-09-11: native returns 'YYYYMMDD' strings whenever a + local quote source has been reachable; ContextInfo returns epoch-ms ints. + Normalize to Beijing-midnight epoch ms (official xtquant contract; + consumers do ts/1000 with local-timezone fromtimestamp, server runs + Asia/Shanghai so datetime().timestamp() is the exact inverse). + All-numeric lists pass through untouched; mixed/odd payloads keep + original elements that are not 8-digit strings.""" + if not isinstance(result, list) or not result: + return result + if all(isinstance(x, (int, float)) and not isinstance(x, bool) for x in result): + return result + out = [] + for x in result: + if isinstance(x, str) and len(x) == 8 and x.isdigit(): + import datetime as _dt + out.append(int(_dt.datetime(int(x[:4]), int(x[4:6]), int(x[6:8])).timestamp() * 1000)) + else: + out.append(x) + return out +''' + +src = io.open(P, encoding="utf-8").read() + +if "_norm_trading_dates(self._native_or_context" in src: + print("ALREADY_PATCHED") + sys.exit(0) + +if src.count(OLD) != 1: + print("ANCHOR_NOT_UNIQUE: %d" % src.count(OLD)) + sys.exit(1) + +shutil.copyfile(P, BAK) +src = src.replace(OLD, NEW) +src = src.rstrip("\n") + "\n" + HELPER +io.open(P, "w", encoding="utf-8", newline="\n").write(src) +print("PATCHED ok, backup=%s" % BAK) diff --git a/scripts/qmt_relogin/patch_market_bigqmt_deploy_20260911.py b/scripts/qmt_relogin/patch_market_bigqmt_deploy_20260911.py new file mode 100644 index 0000000..f9e3c1e --- /dev/null +++ b/scripts/qmt_relogin/patch_market_bigqmt_deploy_20260911.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +"""Contract fix 2026-09-11: server-side get_trading_dates output normalization. + +The native xtdata SDK path returns 'YYYYMMDD' strings whenever any local +quote source has been reachable; the ContextInfo path returns epoch-ms ints. +Consumers (bullet_trade miniqmt provider ~line 1824) do ts/1000, so both +shapes must leave the server as Beijing-midnight epoch-ms ints. +Idempotent: re-running on a patched file exits 0. + +Run ON THE VPS: C:\\Python310\\python.exe -X utf8 patch_market_bigqmt_20260911.py +""" +import io +import shutil +import sys + +P = r"C:\国金QMT交易端模拟\python\bigqmt_signal_trader\adapters\market_bigqmt.py" +BAK = P + ".bak_20260911" + +OLD = ( + " return self._native_or_context(\n" + ' "get_trading_dates", _via_context, market, start_time, end_time, count\n' + " )" +) +NEW = ( + " return _norm_trading_dates(self._native_or_context(\n" + ' "get_trading_dates", _via_context, market, start_time, end_time, count))' +) + +HELPER = ''' + +def _norm_trading_dates(result): + """Contract fix 2026-09-11: native returns 'YYYYMMDD' strings whenever a + local quote source has been reachable; ContextInfo returns epoch-ms ints. + Normalize to Beijing-midnight epoch ms (official xtquant contract; + consumers do ts/1000 with local-timezone fromtimestamp, server runs + Asia/Shanghai so datetime().timestamp() is the exact inverse). + All-numeric lists pass through untouched; mixed/odd payloads keep + original elements that are not 8-digit strings.""" + if not isinstance(result, list) or not result: + return result + if all(isinstance(x, (int, float)) and not isinstance(x, bool) for x in result): + return result + out = [] + for x in result: + if isinstance(x, str) and len(x) == 8 and x.isdigit(): + import datetime as _dt + out.append(int(_dt.datetime(int(x[:4]), int(x[4:6]), int(x[6:8])).timestamp() * 1000)) + else: + out.append(x) + return out +''' + +src = io.open(P, encoding="utf-8").read() + +if "_norm_trading_dates(self._native_or_context" in src: + print("ALREADY_PATCHED") + sys.exit(0) + +if src.count(OLD) != 1: + print("ANCHOR_NOT_UNIQUE: %d" % src.count(OLD)) + sys.exit(1) + +shutil.copyfile(P, BAK) +src = src.replace(OLD, NEW) +src = src.rstrip("\n") + "\n" + HELPER +io.open(P, "w", encoding="utf-8", newline="\n").write(src) +print("PATCHED ok, backup=%s" % BAK) diff --git a/scripts/qmt_relogin/patch_shim_xtdata_20260911.py b/scripts/qmt_relogin/patch_shim_xtdata_20260911.py new file mode 100644 index 0000000..da5fa3d --- /dev/null +++ b/scripts/qmt_relogin/patch_shim_xtdata_20260911.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +"""B-pack client-side fix 2026-09-11: shim get_trading_dates output normalization. + +The server-side fix (market_bigqmt.py patch) normalizes the RPC answer, but +fresh client processes (xt_eod wrapper, future engines) load THIS shim first. +Defense in depth: normalize here too, so the contract holds even if the server +side regresses (e.g. server file restored from a backup). +Idempotent: re-running on a patched file exits 0. + +Run ON THE VPS: C:\\Python310\\python.exe -X utf8 patch_shim_xtdata_20260911.py +""" +import io +import shutil +import sys + +P = r"C:\sanguo_bigqmt\xtquant_bridge\xtquant\xtdata.py" +BAK = P + ".bak_20260911" + +OLD = '''def get_trading_dates(market, start_time="", end_time="", count=-1): + return _compat.xtdata.get_trading_dates(market, start_time, end_time, count)''' + +NEW = '''def _norm_calendar_ms(result): + """Contract fix 2026-09-11: 'YYYYMMDD' strings -> Beijing-midnight epoch ms. + + The bridge server's native xtdata path returns string dates whenever a + local quote source has been reachable (2026-09-11 incident: all 12 engine + schedulers starved on ts/1000 TypeErrors). Consumers divide by 1000 with + local-timezone fromtimestamp, and this host runs Asia/Shanghai, so + datetime().timestamp() is the exact inverse. All-numeric lists pass + through untouched.""" + if not isinstance(result, list) or not result: + return result + if all(isinstance(x, (int, float)) and not isinstance(x, bool) for x in result): + return result + out = [] + for x in result: + if isinstance(x, str) and len(x) == 8 and x.isdigit(): + import datetime as _dt + out.append(int(_dt.datetime(int(x[:4]), int(x[4:6]), int(x[6:8])).timestamp() * 1000)) + else: + out.append(x) + return out + + +def get_trading_dates(market, start_time="", end_time="", count=-1): + return _norm_calendar_ms(_compat.xtdata.get_trading_dates(market, start_time, end_time, count))''' + +src = io.open(P, encoding="utf-8").read() + +if "_norm_calendar_ms(_compat.xtdata.get_trading_dates" in src: + print("ALREADY_PATCHED") + sys.exit(0) + +if src.count(OLD) != 1: + print("ANCHOR_NOT_UNIQUE: %d" % src.count(OLD)) + sys.exit(1) + +shutil.copyfile(P, BAK) +src = src.replace(OLD, NEW) +io.open(P, "w", encoding="utf-8", newline="\n").write(src) +print("PATCHED ok, backup=%s" % BAK) diff --git a/scripts/qmt_relogin/qmt_gate_common.py b/scripts/qmt_relogin/qmt_gate_common.py index 36dbf6d..22effe6 100644 --- a/scripts/qmt_relogin/qmt_gate_common.py +++ b/scripts/qmt_relogin/qmt_gate_common.py @@ -83,6 +83,62 @@ def procs_alive(): return {name: ('"%s"' % name) in out for name in QMT_PROCS} +CALENDAR_PROBE_CODE = ( + "from xtquant import xtdata; " + "r = xtdata.get_trading_dates('SH', '20260101', '20261231'); " + "e = r[-1] if isinstance(r, list) and r else None; " + "print('CAL', type(e).__name__, repr(e))" +) + + +def calendar_probe(timeout=25): + """Fifth gate (added 2026-09-11): fresh child process fetches the trading + calendar through the bridge and checks the element type. + + On 2026-09-11 all 12 engine schedulers starved because the bridge returned + 'YYYYMMDD' STRINGS from its native xtdata path while every other probe + (process / ping / asset / probe order) stayed green. Consumers divide by + 1000, so a string element is a hard contract break. + + Returns (state, detail): state in ok / bad / empty / exec. + ok - non-empty numeric elements (epoch ms), contract healthy + bad - string elements -> RED (relogin restarts the patched server, + which normalizes the output again) + empty - probe shape got no data (not proof the bridge is bad) -> AMBER + exec - child process failed -> AMBER + """ + import os + env = dict(os.environ) + env["PYTHONPATH"] = r"C:\sanguo_bigqmt\xtquant_bridge" + env["BIGQMT_ACCOUNT_ID"] = ACCOUNT + with open(r"C:\redis\redis.conf") as f: + conf = f.read() + env["BIGQMT_REDIS_PASSWORD"] = re.search( + r"^requirepass (\S+)", conf, re.M).group(1) + try: + r = subprocess.run( + ["C:\\Python310\\python.exe", "-X", "utf8", "-c", + CALENDAR_PROBE_CODE], + capture_output=True, text=True, timeout=timeout, env=env) + except Exception as exc: + return "exec", "CAL_PROBE_EXEC %s %s" % (type(exc).__name__, + str(exc)[:100]) + cal = next((l for l in (r.stdout or "").splitlines() + if l.startswith("CAL ")), "") + if not cal: + return "exec", "CAL_PROBE_NO_OUT rc=%s %s" % ( + r.returncode, (r.stderr or "").strip()[:100]) + parts = cal.split(None, 2) + if len(parts) < 3: + return "exec", "CAL_PROBE_BAD '%s'" % cal[:80] + etype, eval_ = parts[1], parts[2][:40] + if etype == "NoneType": + return "empty", "calendar probe returned no data (%s)" % eval_ + if etype in ("int", "float"): + return "ok", "calendar elem=%s %s" % (etype, eval_) + return "bad", "CALENDAR_STR_CONTRACT elem=%s %s" % (etype, eval_) + + def relogin_running(): """True if the v2 relogin python is mid-flight (avoid double trigger).""" try: diff --git a/scripts/qmt_relogin/qmt_relogin.py b/scripts/qmt_relogin/qmt_relogin.py index c339aba..5658ed0 100644 --- a/scripts/qmt_relogin/qmt_relogin.py +++ b/scripts/qmt_relogin/qmt_relogin.py @@ -161,12 +161,11 @@ def bridge_alive(timeout_seconds=150, rc=None): def warn_data_leg(): - """xt_eod (21:00) runs on XtMiniQmt.exe; its absence is tonight's data - gap, not a trading failure -- warn loudly but stay green.""" - st = _procs_alive() - if not st.get("XtMiniQmt.exe"): - log("WARN: XtMiniQmt.exe absent -> tonight's xt_eod leg will fail; " - "family=%s" % st) + """2026-09-11: xt_eod switched to the big-QMT bridge (xtquant_bridge + shim), so XtMiniQmt.exe absence is NORMAL and no longer a data gap. + Kept as a no-op placeholder because the relogin flow used to log a + misleading warning here; see xt-eod-bridge-leg-switch-20260911.""" + return None def main(): @@ -209,7 +208,6 @@ def main(): if got: log("bridge up after exe-mode (remembered session) via %r ok=%s" % (got[0], got[1].get("ok"))) - warn_data_leg() log("=== RELOGIN_DONE ===") return 0 @@ -237,7 +235,6 @@ def main(): log("FATAL: bridge rpc did not answer after login") return 5 log("bridge rpc answering via %r: ok=%s" % (got[0], got[1].get("ok"))) - warn_data_leg() log("=== RELOGIN_DONE ===") return 0 diff --git a/scripts/qmt_relogin/qmt_sentinel.py b/scripts/qmt_relogin/qmt_sentinel.py index 644d42c..e04a1f4 100644 --- a/scripts/qmt_relogin/qmt_sentinel.py +++ b/scripts/qmt_relogin/qmt_sentinel.py @@ -14,9 +14,9 @@ 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) +from qmt_gate_common import (ACCOUNT, bridge_ping, calendar_probe, gate_json, + gate_log, procs_alive, redis_client, + relogin_running, trigger_relogin, wait_bridge) RECOVER_TIMEOUT = 600 # 10 min budget for relogin round trip @@ -55,6 +55,12 @@ def main(): if not up: gate_log("SENTINEL", "RED", "bridge ping dead (%s)" % detail) return recover("bridge-dead") + cal_state, cal_detail = calendar_probe() + if cal_state == "bad": + gate_log("SENTINEL", "RED", cal_detail) + return recover("calendar-str-contract") + if cal_state in ("empty", "exec"): + gate_log("SENTINEL", "AMBER", "fifth-probe inconclusive: %s" % cal_detail) 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),