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 +29 -0
- qmt_sdk/api_surface.py +48 -0
- qmt_sdk/backends/__init__.py +4 -0
- qmt_sdk/backends/miniqmt.py +11 -0
- qmt_sdk/backends/qmt.py +27 -0
- qmt_sdk/bridge/__init__.py +6 -0
- qmt_sdk/bridge/client.py +179 -0
- qmt_sdk/bridge/config.py +38 -0
- qmt_sdk/bridge/exceptions.py +26 -0
- qmt_sdk/bridge/protocol.py +20 -0
- qmt_sdk/client.py +43 -0
- qmt_sdk/data.py +624 -0
- qmt_sdk/financial.py +23 -0
- qmt_sdk/instruments.py +72 -0
- qmt_sdk/jobs.py +12 -0
- qmt_sdk/market.py +71 -0
- qmt_sdk/qmt.py +24 -0
- qmt_sdk-0.3.0.data/data/qmt_strategy/XTQUANT_COMPAT_BRIDGE.py +811 -0
- qmt_sdk-0.3.0.data/data/qmt_strategy/XTQUANT_COMPAT_BRIDGE_LAUNCHER.py +17 -0
- qmt_sdk-0.3.0.dist-info/METADATA +204 -0
- qmt_sdk-0.3.0.dist-info/RECORD +32 -0
- qmt_sdk-0.3.0.dist-info/WHEEL +5 -0
- qmt_sdk-0.3.0.dist-info/licenses/LICENSE +192 -0
- qmt_sdk-0.3.0.dist-info/top_level.txt +2 -0
- xtquant_compat/__init__.py +7 -0
- xtquant_compat/api_surface.py +48 -0
- xtquant_compat/client.py +16 -0
- xtquant_compat/config.py +5 -0
- xtquant_compat/exceptions.py +9 -0
- xtquant_compat/official_xtdata_api.json +2649 -0
- xtquant_compat/protocol.py +20 -0
- xtquant_compat/xtdata.py +595 -0
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
|
qmt_sdk/backends/qmt.py
ADDED
|
@@ -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)
|
qmt_sdk/bridge/client.py
ADDED
|
@@ -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
|
qmt_sdk/bridge/config.py
ADDED
|
@@ -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)
|