fix(ops): 桥日历类型契约三重修复——server部署位+shim双归一化+哨兵第五探针 [vps]——09-11事故:mini栈残留令桥native路径返YYYYMMDD字符串,消费端ts/1000 TypeError致12引擎调度器全天停摆(四件套闸门全绿的盲区);修=①market_bigqmt.py get_trading_dates出口归一化(北京午夜毫秒,双部署位:源码副本+国金python运行位,.bak留档)②xtquant_bridge/xtdata.py同款(客户端fresh进程双保险)③哨兵第五探针calendar_probe(fresh子进程断言日历元素数字类型,字符串=RED自动触发relogin,空/执行异常=AMBER不误红);顺手relogin stale WARN清理(xt_eod已切桥mini缺席=正常);实测20:03桥回20:04六引擎「新交易日09-11」落地日历恢复
CI/CD / test (push) Failing after 12m45s
CI/CD / nas-deploy (push) Has been skipped
CI/CD / nas-verify (push) Has been skipped

This commit is contained in:
2026-09-11 20:08:42 +08:00
parent e478aba80f
commit a1abe12298
6 changed files with 265 additions and 11 deletions
@@ -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)
@@ -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)
@@ -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)
+56
View File
@@ -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:
+5 -8
View File
@@ -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
+9 -3
View File
@@ -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),