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」落地日历恢复
This commit is contained in:
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user