qmt-sdk 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
qmt_sdk/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """Unified QMT SDK.
2
+
3
+ The SDK exposes one client for QMT and MiniQMT backends. The existing
4
+ ``xtquant_compat`` module remains available as a migration surface, while new
5
+ code should use the domain clients below.
6
+ """
7
+
8
+ from .client import QmtClient
9
+ from .bridge import configure
10
+ import os
11
+ import sys
12
+ import sysconfig
13
+
14
+
15
+ def get_template_dir():
16
+ """Return the installed directory containing QMT strategy templates."""
17
+ candidates = []
18
+ data_root = sysconfig.get_path("data")
19
+ if data_root:
20
+ candidates.append(os.path.join(data_root, "qmt_strategy"))
21
+ # Source checkouts remain directly usable without building a wheel.
22
+ candidates.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "qmt_strategy")))
23
+ for path in candidates:
24
+ if os.path.isfile(os.path.join(path, "XTQUANT_COMPAT_BRIDGE.py")):
25
+ return path
26
+ raise FileNotFoundError("QMT strategy templates are not installed")
27
+
28
+ __all__ = ["QmtClient", "configure", "get_template_dir"]
29
+ __version__ = "0.3.0"
qmt_sdk/api_surface.py ADDED
@@ -0,0 +1,48 @@
1
+ """Install environment-dependent adapters from the official API snapshot."""
2
+
3
+ import inspect
4
+ import json
5
+ from importlib import resources
6
+
7
+
8
+ def load_official_api_spec():
9
+ with resources.open_text("xtquant_compat", "official_xtdata_api.json", encoding="utf-8") as stream:
10
+ return json.load(stream)
11
+
12
+
13
+ def _annotation(name):
14
+ return {"str": str, "list": list, "int": int, "float": float, "bool": bool}.get(name, name)
15
+
16
+
17
+ def _signature(item):
18
+ parameters = []
19
+ for source in item["parameters"]:
20
+ kind = getattr(inspect.Parameter, source["kind"])
21
+ default = source.get("default", inspect.Parameter.empty)
22
+ annotation = _annotation(source["annotation"]) if "annotation" in source else inspect.Parameter.empty
23
+ parameters.append(inspect.Parameter(
24
+ source["name"], kind=kind, default=default, annotation=annotation,
25
+ ))
26
+ return inspect.Signature(parameters)
27
+
28
+
29
+ def install_missing_api(module_globals, request):
30
+ spec = load_official_api_spec()
31
+ for item in spec["functions"]:
32
+ name = item["name"]
33
+ if callable(module_globals.get(name)):
34
+ continue
35
+ signature = _signature(item)
36
+
37
+ def adapter(*args, __name=name, __signature=signature, **kwargs):
38
+ bound = __signature.bind(*args, **kwargs)
39
+ bound.apply_defaults()
40
+ return request(__name, **bound.arguments)
41
+
42
+ adapter.__name__ = name
43
+ adapter.__qualname__ = name
44
+ adapter.__doc__ = "Environment-dependent adapter for xtquant.xtdata.%s." % name
45
+ adapter.__module__ = module_globals.get("__name__", "xtquant_compat.xtdata")
46
+ adapter.__signature__ = signature
47
+ module_globals[name] = adapter
48
+ return spec
@@ -0,0 +1,4 @@
1
+ from .qmt import QmtBackend
2
+ from .miniqmt import MiniQmtBackend
3
+
4
+ __all__ = ["QmtBackend", "MiniQmtBackend"]
@@ -0,0 +1,11 @@
1
+ """MiniQMT backend.
2
+
3
+ The initial implementation shares the same client contract. A direct native
4
+ MiniQMT transport can be added later without changing domain clients.
5
+ """
6
+
7
+ from .qmt import QmtBackend
8
+
9
+
10
+ class MiniQmtBackend(QmtBackend):
11
+ pass
@@ -0,0 +1,27 @@
1
+ """QMT runtime backend using the shared file bridge."""
2
+
3
+ from .. import data
4
+ from ..bridge import get_client
5
+
6
+
7
+ class QmtBackend:
8
+ """Backend that exposes the unified request operations."""
9
+
10
+ def call(self, method, *args, **kwargs):
11
+ # Typed market methods use the native query path directly. This
12
+ # avoids legacy xtdata wrappers in the embedded runtime (some builds
13
+ # import pandas there) and preserves QMT's raw payload.
14
+ if method in ("get_market_data_ex", "get_market_data") and args:
15
+ names = ("fields", "stock_code", "period", "start_time", "end_time",
16
+ "count", "dividend_type", "fill_data")
17
+ if method == "get_market_data_ex":
18
+ names += ("subscribe",)
19
+ params = dict(zip(names, args))
20
+ params.update(kwargs)
21
+ return self.query(method, params)
22
+ function = getattr(data, method)
23
+ return function(*args, **kwargs)
24
+
25
+ def query(self, method, params=None, timeout=None):
26
+ """Call a query method through the shared bridge without xtquant shaping."""
27
+ return get_client().request(method, params or {}, timeout=timeout)
@@ -0,0 +1,6 @@
1
+ """Shared file bridge transport used by all qmt_sdk domains."""
2
+
3
+ from .client import FileBridgeClient, get_client, reset_client
4
+ from .config import configure, get_config
5
+
6
+ __all__ = ["FileBridgeClient", "get_client", "reset_client", "configure", "get_config"]
@@ -0,0 +1,179 @@
1
+ import os
2
+ import json
3
+ import pickle
4
+ import threading
5
+ import time
6
+ import uuid
7
+
8
+ from .config import get_config
9
+ from .exceptions import BridgeRemoteError, BridgeTimeoutError
10
+ from .protocol import PROTOCOL_VERSION, atomic_write_json, read_json
11
+
12
+
13
+ class FileBridgeClient:
14
+ def __init__(self, config):
15
+ self.config = config
16
+ self.client_id = "%s-%s" % (os.getpid(), uuid.uuid4().hex)
17
+ self._callbacks = {}
18
+ self._stop = threading.Event()
19
+ self._event_thread = None
20
+ for name in ("requests", "responses", "errors", "events", "processed", "cancellations", "status"):
21
+ os.makedirs(os.path.join(config.root, name), exist_ok=True)
22
+
23
+ def request(self, method, params=None, timeout=None):
24
+ request_id = uuid.uuid4().hex
25
+ filename = "REQ_%s.json" % request_id
26
+ payload = {
27
+ "protocol_version": PROTOCOL_VERSION,
28
+ "request_id": request_id,
29
+ "client_id": self.client_id,
30
+ "method": method,
31
+ "params": params or {},
32
+ "created_at": time.time(),
33
+ }
34
+ atomic_write_json(os.path.join(self.config.root, "requests", filename), payload)
35
+ atomic_write_json(os.path.join(self.config.root, "status", request_id + ".json"), {
36
+ "request_id": request_id, "state": "pending", "processed": 0,
37
+ "total": 0, "failed": 0, "created_at": payload["created_at"],
38
+ })
39
+ deadline = time.monotonic() + (self.config.timeout if timeout is None else float(timeout))
40
+ try:
41
+ while time.monotonic() < deadline:
42
+ for folder in ("responses", "errors"):
43
+ path = os.path.join(self.config.root, folder, filename)
44
+ if not os.path.exists(path):
45
+ continue
46
+ try:
47
+ result = read_json(path)
48
+ except (OSError, ValueError, json.JSONDecodeError):
49
+ time.sleep(min(self.config.poll_interval, 0.05))
50
+ continue
51
+ try:
52
+ os.remove(path)
53
+ except OSError:
54
+ pass
55
+ if folder == "errors" or not result.get("ok", False):
56
+ if result.get("cancelled"):
57
+ from .exceptions import BridgeCancelledError
58
+ raise BridgeCancelledError(result.get("error") or "request cancelled")
59
+ message = result.get("error") or "unknown QMT bridge error"
60
+ if "NotImplementedError" in message or "unavailable" in message.lower():
61
+ from .exceptions import BridgeMethodNotSupportedError
62
+ raise BridgeMethodNotSupportedError(message)
63
+ raise BridgeRemoteError(message)
64
+ if result.get("data_format") == "pickle-v1":
65
+ binary_name = os.path.basename(str(result.get("data_file", "")))
66
+ binary_path = os.path.join(self.config.root, "responses", binary_name)
67
+ try:
68
+ with open(binary_path, "rb") as stream:
69
+ return pickle.load(stream)
70
+ finally:
71
+ try:
72
+ os.remove(binary_path)
73
+ except OSError:
74
+ pass
75
+ return result.get("data")
76
+ time.sleep(self.config.poll_interval)
77
+ except KeyboardInterrupt:
78
+ self.cancel(request_id)
79
+ raise
80
+ self.cancel(request_id)
81
+ raise BridgeTimeoutError("timeout waiting for %s (%s)" % (method, request_id))
82
+
83
+ def cancel(self, request_id):
84
+ atomic_write_json(os.path.join(self.config.root, "cancellations", request_id + ".json"), {
85
+ "request_id": request_id, "created_at": time.time(),
86
+ })
87
+
88
+ def request_status(self, request_id):
89
+ path = os.path.join(self.config.root, "status", str(request_id) + ".json")
90
+ if not os.path.exists(path):
91
+ return None
92
+ try:
93
+ return read_json(path)
94
+ except (OSError, ValueError, json.JSONDecodeError):
95
+ return None
96
+
97
+ def subscribe_method(self, method, params, callback):
98
+ result = self.request(method, params)
99
+ subscription_id = result.get("subscription_id") if isinstance(result, dict) else result
100
+ if callback is not None:
101
+ self._callbacks[str(subscription_id)] = callback
102
+ self._ensure_event_thread()
103
+ return subscription_id
104
+
105
+ def subscribe(self, stock_code, period, start_time, end_time, count, callback):
106
+ return self.subscribe_method("subscribe_quote", {
107
+ "stock_code": stock_code,
108
+ "period": period,
109
+ "start_time": start_time,
110
+ "end_time": end_time,
111
+ "count": count,
112
+ }, callback)
113
+
114
+ def unsubscribe(self, subscription_id):
115
+ return self.unsubscribe_method("unsubscribe_quote", subscription_id)
116
+
117
+ def unsubscribe_method(self, method, subscription_id, parameter="subscription_id"):
118
+ self._callbacks.pop(str(subscription_id), None)
119
+ return self.request(method, {parameter: subscription_id})
120
+
121
+ def _ensure_event_thread(self):
122
+ if self._event_thread and self._event_thread.is_alive():
123
+ return
124
+ self._stop.clear()
125
+ self._event_thread = threading.Thread(target=self._event_loop, name="xtquant-compat-events", daemon=True)
126
+ self._event_thread.start()
127
+
128
+ def _event_loop(self):
129
+ event_root = os.path.join(self.config.root, "events", self.client_id)
130
+ processed_root = os.path.join(self.config.root, "processed", self.client_id)
131
+ while not self._stop.wait(self.config.poll_interval):
132
+ if not os.path.isdir(event_root):
133
+ continue
134
+ for subscription_id in sorted(os.listdir(event_root)):
135
+ folder = os.path.join(event_root, subscription_id)
136
+ if not os.path.isdir(folder):
137
+ continue
138
+ callback = self._callbacks.get(subscription_id)
139
+ if callback is None:
140
+ continue
141
+ for name in sorted(n for n in os.listdir(folder) if n.endswith(".json")):
142
+ source = os.path.join(folder, name)
143
+ try:
144
+ event = read_json(source)
145
+ callback(event.get("data"))
146
+ destination = os.path.join(processed_root, subscription_id, name)
147
+ os.makedirs(os.path.dirname(destination), exist_ok=True)
148
+ os.replace(source, destination)
149
+ except FileNotFoundError:
150
+ continue
151
+ except Exception:
152
+ # Keep the event for at-least-once retry after callback failure.
153
+ time.sleep(self.config.poll_interval)
154
+ break
155
+
156
+ def close(self):
157
+ self._stop.set()
158
+ if self._event_thread and self._event_thread.is_alive():
159
+ self._event_thread.join(timeout=1)
160
+
161
+
162
+ _client = None
163
+ _client_lock = threading.Lock()
164
+
165
+
166
+ def get_client():
167
+ global _client
168
+ with _client_lock:
169
+ if _client is None:
170
+ _client = FileBridgeClient(get_config())
171
+ return _client
172
+
173
+
174
+ def reset_client():
175
+ global _client
176
+ with _client_lock:
177
+ if _client is not None:
178
+ _client.close()
179
+ _client = None
@@ -0,0 +1,38 @@
1
+ import os
2
+ import threading
3
+ from dataclasses import dataclass, replace
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class Config:
8
+ root: str = os.environ.get(
9
+ "XTQUANT_COMPAT_ROOT", r"D:\FinTools\QMT\xtquant_compat_bridge",
10
+ )
11
+ timeout: float = float(os.environ.get("XTQUANT_COMPAT_TIMEOUT", "30"))
12
+ poll_interval: float = float(os.environ.get("XTQUANT_COMPAT_POLL_INTERVAL", "0.05"))
13
+
14
+
15
+ _lock = threading.Lock()
16
+ _config = Config()
17
+
18
+
19
+ def configure(root=None, timeout=None, poll_interval=None):
20
+ """Configure the global file transport and reset the active client."""
21
+ global _config
22
+ changes = {}
23
+ if root is not None:
24
+ changes["root"] = os.path.abspath(os.fspath(root))
25
+ if timeout is not None:
26
+ changes["timeout"] = float(timeout)
27
+ if poll_interval is not None:
28
+ changes["poll_interval"] = float(poll_interval)
29
+ with _lock:
30
+ _config = replace(_config, **changes)
31
+ from .client import reset_client
32
+ reset_client()
33
+ return _config
34
+
35
+
36
+ def get_config():
37
+ with _lock:
38
+ return _config
@@ -0,0 +1,26 @@
1
+ class XtQuantCompatError(RuntimeError):
2
+ """Base error raised by xtquant-compat."""
3
+
4
+
5
+ class BridgeTimeoutError(XtQuantCompatError, TimeoutError):
6
+ """The QMT bridge did not respond before the configured timeout."""
7
+
8
+
9
+ class BridgeRemoteError(XtQuantCompatError):
10
+ """The QMT bridge returned an exception."""
11
+
12
+
13
+ class BridgeCancelledError(XtQuantCompatError):
14
+ """A long-running bridge request was cancelled."""
15
+
16
+
17
+ class BridgeUnavailableError(XtQuantCompatError):
18
+ """The file bridge is not running or has restarted."""
19
+
20
+
21
+ class BridgeMethodNotSupportedError(BridgeRemoteError):
22
+ """The active QMT build does not expose the requested method."""
23
+
24
+
25
+ class BridgePartialResultError(BridgeRemoteError):
26
+ """A request returned only a partial result."""
@@ -0,0 +1,20 @@
1
+ import json
2
+ import os
3
+ import uuid
4
+
5
+ PROTOCOL_VERSION = 1
6
+
7
+
8
+ def atomic_write_json(path, payload):
9
+ os.makedirs(os.path.dirname(path), exist_ok=True)
10
+ temp = "%s.%s.tmp" % (path, uuid.uuid4().hex)
11
+ with open(temp, "w", encoding="utf-8") as stream:
12
+ json.dump(payload, stream, ensure_ascii=False, separators=(",", ":"))
13
+ stream.flush()
14
+ os.fsync(stream.fileno())
15
+ os.replace(temp, path)
16
+
17
+
18
+ def read_json(path):
19
+ with open(path, "r", encoding="utf-8") as stream:
20
+ return json.load(stream)
qmt_sdk/client.py ADDED
@@ -0,0 +1,43 @@
1
+ """High-level QMT client facade backed by the existing bridge client."""
2
+
3
+ from .backends import MiniQmtBackend, QmtBackend
4
+ from .financial import FinancialClient
5
+ from .instruments import InstrumentClient
6
+ from .jobs import JobClient
7
+ from .market import MarketClient
8
+ from .qmt import QmtQueryClient
9
+
10
+
11
+ class QmtClient:
12
+ """Unified domain-oriented client.
13
+
14
+ ``backend`` is reserved for future direct MiniQMT/QMT selection; the
15
+ current implementation uses the configured file bridge for both.
16
+ """
17
+
18
+ def __init__(self, backend="qmt"):
19
+ if hasattr(backend, "call"):
20
+ self.backend_name = "custom"
21
+ self.backend = backend
22
+ elif backend in ("qmt", "auto"):
23
+ self.backend_name = "qmt"
24
+ self.backend = QmtBackend()
25
+ elif backend in ("mini", "miniqmt"):
26
+ self.backend_name = "miniqmt"
27
+ self.backend = MiniQmtBackend()
28
+ else:
29
+ raise ValueError("unsupported backend: %s" % backend)
30
+ self.market = MarketClient(self.backend)
31
+ self.financial = FinancialClient(self.backend)
32
+ self.instruments = InstrumentClient(self.backend)
33
+ self.jobs = JobClient(self.backend)
34
+ self.qmt = QmtQueryClient(self)
35
+
36
+ def query(self, method, params=None, timeout=None):
37
+ """Invoke a query capability by bridge method name.
38
+
39
+ This is the extension point for QMT-native read APIs that do not have
40
+ a MiniQMT equivalent yet. Domain clients should be preferred once a
41
+ capability has stable parameters and return semantics.
42
+ """
43
+ return self.backend.query(method, params=params, timeout=timeout)