xtquant-share 1.3.0__tar.gz → 1.3.1__tar.gz
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.
- {xtquant_share-1.3.0/xtquant_share.egg-info → xtquant_share-1.3.1}/PKG-INFO +1 -1
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/pyproject.toml +1 -1
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/tests/test_quote_events.py +82 -3
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/tests/test_server.py +43 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xqshare/__init__.py +1 -1
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xqshare/server.py +146 -65
- {xtquant_share-1.3.0 → xtquant_share-1.3.1/xtquant_share.egg-info}/PKG-INFO +1 -1
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/LICENSE +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/README.md +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/setup.cfg +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/tests/test_auth.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/tests/test_channels_bigqmt.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/tests/test_channels_health.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/tests/test_channels_router.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/tests/test_client.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/tests/test_client_permission.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/tests/test_integration.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/tests/test_qmt_datadir_factor.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/tests/test_remote_data.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/tests/test_server_channel_routing.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/tests/test_server_download_history_data2_compat.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/tests/test_server_download_timeout.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/tests/test_server_permission.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/tests/test_tools_common.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/tests/test_trader_events.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xqshare/.env.example +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xqshare/auth.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xqshare/channels/__init__.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xqshare/channels/bigqmt.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xqshare/channels/health.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xqshare/channels/router.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xqshare/check_env.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xqshare/client.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xqshare/clients.yaml.example +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xqshare/qmt_datadir/__init__.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xqshare/qmt_datadir/divid.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xqshare/qmt_datadir/factor.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xqshare/qmt_datadir/kline.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xqshare/qmt_datadir/misc.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xqshare/qmt_datadir/reader.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xqshare/qmt_datadir/sector.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xqshare/qmt_watchdog.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xqshare/tools/__init__.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xqshare/tools/common.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xqshare/tools/xtdata.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xqshare/tools/xttrader.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xtquant_share.egg-info/SOURCES.txt +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xtquant_share.egg-info/dependency_links.txt +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xtquant_share.egg-info/entry_points.txt +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xtquant_share.egg-info/requires.txt +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.3.1}/xtquant_share.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "xtquant-share"
|
|
7
|
-
version = "1.3.
|
|
7
|
+
version = "1.3.1"
|
|
8
8
|
description = "Xtreme Quant Share - eXtreme transparent sharing proxy for XtQuant on macOS/Linux"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "GPL-3.0-only"
|
|
@@ -30,7 +30,7 @@ sys.modules['xtquant.xttype'] = mock_xttype
|
|
|
30
30
|
from xqshare.server import _init_logging
|
|
31
31
|
_init_logging("WARNING")
|
|
32
32
|
|
|
33
|
-
from xqshare.server import LoggingProxy, _QuoteEventCallback
|
|
33
|
+
from xqshare.server import LoggingProxy, XtQuantService, _QuoteEventCallback
|
|
34
34
|
from xqshare.client import RemoteQuoteCallback
|
|
35
35
|
from xqshare.auth import AccountLevel
|
|
36
36
|
|
|
@@ -118,6 +118,83 @@ class TestQuoteEventCallback:
|
|
|
118
118
|
finally:
|
|
119
119
|
qec.stop()
|
|
120
120
|
|
|
121
|
+
def test_register_idempotent_per_client(self):
|
|
122
|
+
"""同一 client_info 重复 register(断线重连后新 netref)应覆盖旧 entry 而非累积"""
|
|
123
|
+
qec = _QuoteEventCallback()
|
|
124
|
+
try:
|
|
125
|
+
old_cb = MagicMock()
|
|
126
|
+
new_cb = MagicMock()
|
|
127
|
+
qec.register(old_cb, client_info="c1@127.0.0.1:1")
|
|
128
|
+
qec.register(new_cb, client_info="c1@127.0.0.1:1")
|
|
129
|
+
|
|
130
|
+
assert len(qec._subscribers) == 1, "重连后旧 netref 应被覆盖,不累积"
|
|
131
|
+
entry = next(iter(qec._subscribers.values()))
|
|
132
|
+
assert entry['callback'] is new_cb
|
|
133
|
+
finally:
|
|
134
|
+
qec.stop()
|
|
135
|
+
|
|
136
|
+
def test_register_without_client_info_no_dedup(self):
|
|
137
|
+
"""client_info 为 None 时不做幂等去重(保持原语义,按 id 累积)"""
|
|
138
|
+
qec = _QuoteEventCallback()
|
|
139
|
+
try:
|
|
140
|
+
qec.register(MagicMock())
|
|
141
|
+
qec.register(MagicMock())
|
|
142
|
+
assert len(qec._subscribers) == 2
|
|
143
|
+
finally:
|
|
144
|
+
qec.stop()
|
|
145
|
+
|
|
146
|
+
def test_clear_client_callbacks(self):
|
|
147
|
+
"""clear_client_callbacks 只清理指定 client 的回调,不影响其他 client"""
|
|
148
|
+
qec = _QuoteEventCallback()
|
|
149
|
+
try:
|
|
150
|
+
cb1, cb2, cb3 = MagicMock(), MagicMock(), MagicMock()
|
|
151
|
+
qec.register(cb1, client_info="c1@127.0.0.1:1")
|
|
152
|
+
qec.register(cb2, client_info="c1@127.0.0.1:1")
|
|
153
|
+
qec.register(cb3, client_info="c2@127.0.0.1:2")
|
|
154
|
+
|
|
155
|
+
qec.clear_client_callbacks("c1@127.0.0.1:1")
|
|
156
|
+
|
|
157
|
+
remaining = [e['callback'] for e in qec._subscribers.values()]
|
|
158
|
+
assert remaining == [cb3], "只应保留其他 client 的回调"
|
|
159
|
+
finally:
|
|
160
|
+
qec.stop()
|
|
161
|
+
|
|
162
|
+
def test_on_disconnect_clears_quote_subscribers(self):
|
|
163
|
+
"""on_disconnect 应清理该连接注册的 quote 回调,避免 netref 累积泄漏"""
|
|
164
|
+
qec = _QuoteEventCallback()
|
|
165
|
+
original = XtQuantService._quote_event_callback
|
|
166
|
+
XtQuantService._quote_event_callback = qec
|
|
167
|
+
try:
|
|
168
|
+
service = XtQuantService()
|
|
169
|
+
service._client_info = "c1@127.0.0.1:1"
|
|
170
|
+
service._traders = []
|
|
171
|
+
qec.register(MagicMock(), client_info="c1@127.0.0.1:1")
|
|
172
|
+
qec.register(MagicMock(), client_info="c2@127.0.0.1:2")
|
|
173
|
+
|
|
174
|
+
service.on_disconnect(MagicMock())
|
|
175
|
+
|
|
176
|
+
remaining = [e['client_info'] for e in qec._subscribers.values()]
|
|
177
|
+
assert remaining == ["c2@127.0.0.1:2"], "断开连接的客户端回调应被清理"
|
|
178
|
+
finally:
|
|
179
|
+
qec.stop()
|
|
180
|
+
XtQuantService._quote_event_callback = original
|
|
181
|
+
|
|
182
|
+
def test_on_disconnect_without_quote_callback(self):
|
|
183
|
+
"""quote 单例未初始化时 on_disconnect 不应报错,也不应创建单例"""
|
|
184
|
+
original = XtQuantService._quote_event_callback
|
|
185
|
+
XtQuantService._quote_event_callback = None
|
|
186
|
+
try:
|
|
187
|
+
service = XtQuantService()
|
|
188
|
+
service._client_info = "c1@127.0.0.1:1"
|
|
189
|
+
service._traders = []
|
|
190
|
+
|
|
191
|
+
service.on_disconnect(MagicMock())
|
|
192
|
+
|
|
193
|
+
assert XtQuantService._quote_event_callback is None, \
|
|
194
|
+
"不应在断开时懒创建 quote 单例"
|
|
195
|
+
finally:
|
|
196
|
+
XtQuantService._quote_event_callback = original
|
|
197
|
+
|
|
121
198
|
|
|
122
199
|
class TestLoggingProxyQuoteIntercept:
|
|
123
200
|
"""LoggingProxy 拦截 xtdata 订阅并接管 callback"""
|
|
@@ -143,8 +220,10 @@ class TestLoggingProxyQuoteIntercept:
|
|
|
143
220
|
|
|
144
221
|
kwargs = mock.subscribe_whole_quote.call_args.kwargs
|
|
145
222
|
assert kwargs["callback"] is qec, "原生 callback 应由服务端路由器接管"
|
|
146
|
-
assert len(qec._subscribers) == 1
|
|
147
|
-
|
|
223
|
+
assert len(qec._subscribers) == 1, "客户端回调应注册到路由器"
|
|
224
|
+
entry = next(iter(qec._subscribers.values()))
|
|
225
|
+
assert entry['callback'] is client_cb
|
|
226
|
+
assert entry['client_info'] == "test-client", "应记录 client_info 供断线清理"
|
|
148
227
|
finally:
|
|
149
228
|
qec.stop()
|
|
150
229
|
|
|
@@ -195,6 +195,49 @@ class TestXtQuantService:
|
|
|
195
195
|
assert "test_token" not in service._tokens
|
|
196
196
|
|
|
197
197
|
|
|
198
|
+
class TestTraderCallbackCleanup:
|
|
199
|
+
"""测试 trader 事件路由器线程的清理(防泄漏)"""
|
|
200
|
+
|
|
201
|
+
def test_on_disconnect_stops_trader_event_callback(self):
|
|
202
|
+
"""on_disconnect 应停止该连接创建的事件路由器 dispatch 线程"""
|
|
203
|
+
from xqshare.server import _TraderEventCallback
|
|
204
|
+
service = XtQuantService()
|
|
205
|
+
service._conn = Mock()
|
|
206
|
+
service._conn.peer = "127.0.0.1:12345"
|
|
207
|
+
service._client_info = "test_client@127.0.0.1:12345"
|
|
208
|
+
trader = Mock()
|
|
209
|
+
cb = _TraderEventCallback()
|
|
210
|
+
service._traders = [(trader, cb)]
|
|
211
|
+
try:
|
|
212
|
+
service.on_disconnect(service._conn)
|
|
213
|
+
|
|
214
|
+
assert cb._running is False, "on_disconnect 应停止事件路由器线程"
|
|
215
|
+
trader.stop.assert_called_once()
|
|
216
|
+
finally:
|
|
217
|
+
cb.stop()
|
|
218
|
+
|
|
219
|
+
def test_trader_event_callback_stop_terminates_thread(self):
|
|
220
|
+
"""_TraderEventCallback.stop() 应让 dispatch daemon 线程退出"""
|
|
221
|
+
from xqshare.server import _TraderEventCallback
|
|
222
|
+
cb = _TraderEventCallback()
|
|
223
|
+
thread = cb._event_thread
|
|
224
|
+
assert thread.is_alive(), "dispatch 线程应在创建后运行"
|
|
225
|
+
|
|
226
|
+
cb.stop()
|
|
227
|
+
thread.join(timeout=3.0)
|
|
228
|
+
assert not thread.is_alive(), "stop() 后 dispatch 线程应退出"
|
|
229
|
+
|
|
230
|
+
def test_trader_event_callback_stop_idempotent(self):
|
|
231
|
+
"""stop() 多次调用应无副作用"""
|
|
232
|
+
from xqshare.server import _TraderEventCallback
|
|
233
|
+
cb = _TraderEventCallback()
|
|
234
|
+
thread = cb._event_thread
|
|
235
|
+
cb.stop()
|
|
236
|
+
cb.stop()
|
|
237
|
+
thread.join(timeout=3.0)
|
|
238
|
+
assert not thread.is_alive()
|
|
239
|
+
|
|
240
|
+
|
|
198
241
|
class TestSSLContext:
|
|
199
242
|
"""测试 SSL 上下文创建"""
|
|
200
243
|
|
|
@@ -563,12 +563,16 @@ class _QuoteEventCallback:
|
|
|
563
563
|
# key 用 id(netref)(本地内存地址)而非 netref 本身:
|
|
564
564
|
# set 的 add/discard 会对 netref 求 hash(触发 RPC),客户端断开后
|
|
565
565
|
# 该 RPC 会抛 EOFError,导致 dispatch 线程崩溃。id() 是纯本地操作。
|
|
566
|
-
|
|
566
|
+
# value 记录 client_info,供 on_disconnect 按连接清理(修复 netref 累积泄漏)。
|
|
567
|
+
self._subscribers = {} # id(client_callback_netref) -> {callback, client_info}
|
|
567
568
|
self._running = True
|
|
568
569
|
self._queue = []
|
|
569
570
|
self._queue_lock = threading.Lock()
|
|
570
571
|
self._event_thread = threading.Thread(target=self._dispatch_loop, daemon=True)
|
|
571
572
|
self._event_thread.start()
|
|
573
|
+
# 订阅者数量告警水位:超过阈值打 warning,用于观察是否仍存在泄漏
|
|
574
|
+
self._max_subscribers_warning = _get_max_quote_subscribers()
|
|
575
|
+
self._warned = False
|
|
572
576
|
|
|
573
577
|
def __call__(self, data):
|
|
574
578
|
"""使路由器可被 xtquant 作为函数式 callback 调用。
|
|
@@ -604,7 +608,7 @@ class _QuoteEventCallback:
|
|
|
604
608
|
continue
|
|
605
609
|
for payload in batch:
|
|
606
610
|
with self._lock:
|
|
607
|
-
callbacks =
|
|
611
|
+
callbacks = [entry['callback'] for entry in self._subscribers.values()]
|
|
608
612
|
for cb in callbacks:
|
|
609
613
|
try:
|
|
610
614
|
cb.exposed_on_quote_event(payload)
|
|
@@ -613,15 +617,58 @@ class _QuoteEventCallback:
|
|
|
613
617
|
logger.warning(f"[QuoteEvent] 推送到客户端失败,自动移除: {e}")
|
|
614
618
|
self.unregister(cb)
|
|
615
619
|
|
|
616
|
-
def register(self, client_callback):
|
|
620
|
+
def register(self, client_callback, client_info: Optional[str] = None):
|
|
617
621
|
with self._lock:
|
|
618
|
-
|
|
622
|
+
if client_info is not None:
|
|
623
|
+
# 幂等化:同一 client_info 重复 register(客户端断线重连后新 netref
|
|
624
|
+
# 的 id 不同)时,先移除该 client 的旧 entry,避免按 id(netref) 累积。
|
|
625
|
+
stale = [
|
|
626
|
+
cid for cid, entry in self._subscribers.items()
|
|
627
|
+
if entry['client_info'] == client_info
|
|
628
|
+
]
|
|
629
|
+
for cid in stale:
|
|
630
|
+
self._subscribers.pop(cid, None)
|
|
631
|
+
self._subscribers[id(client_callback)] = {
|
|
632
|
+
'callback': client_callback,
|
|
633
|
+
'client_info': client_info,
|
|
634
|
+
}
|
|
635
|
+
self._maybe_warn_subscriber_count()
|
|
619
636
|
logger.info(f"[QuoteEvent] 注册订阅者 total={len(self._subscribers)}")
|
|
620
637
|
|
|
621
638
|
def unregister(self, client_callback):
|
|
622
639
|
with self._lock:
|
|
623
640
|
self._subscribers.pop(id(client_callback), None)
|
|
624
641
|
|
|
642
|
+
def clear_client_callbacks(self, client_info: Optional[str]):
|
|
643
|
+
"""按连接清理该客户端注册的所有 quote 回调(on_disconnect 调用)。"""
|
|
644
|
+
if client_info is None:
|
|
645
|
+
return
|
|
646
|
+
with self._lock:
|
|
647
|
+
stale = [
|
|
648
|
+
cid for cid, entry in self._subscribers.items()
|
|
649
|
+
if entry['client_info'] == client_info
|
|
650
|
+
]
|
|
651
|
+
for cid in stale:
|
|
652
|
+
self._subscribers.pop(cid, None)
|
|
653
|
+
if stale:
|
|
654
|
+
logger.info(
|
|
655
|
+
f"[QuoteEvent] 清理断开客户端的订阅者: client={client_info} "
|
|
656
|
+
f"removed={len(stale)} total={len(self._subscribers)}"
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
def _maybe_warn_subscriber_count(self):
|
|
660
|
+
"""订阅者数量超过水位时打 warning(回落时复位,便于反复告警)。"""
|
|
661
|
+
n = len(self._subscribers)
|
|
662
|
+
if n > self._max_subscribers_warning:
|
|
663
|
+
if not self._warned:
|
|
664
|
+
self._warned = True
|
|
665
|
+
logger.warning(
|
|
666
|
+
f"[QuoteEvent] 订阅者数量异常: total={n} "
|
|
667
|
+
f"(阈值 {self._max_subscribers_warning}),可能存在回调泄漏"
|
|
668
|
+
)
|
|
669
|
+
elif self._warned:
|
|
670
|
+
self._warned = False
|
|
671
|
+
|
|
625
672
|
def stop(self):
|
|
626
673
|
self._running = False
|
|
627
674
|
|
|
@@ -655,6 +702,15 @@ def _get_download_timeout() -> int:
|
|
|
655
702
|
DOWNLOAD_TIMEOUT_SECONDS = _get_download_timeout()
|
|
656
703
|
|
|
657
704
|
|
|
705
|
+
def _get_max_quote_subscribers() -> int:
|
|
706
|
+
"""读取 quote 订阅者告警水位,默认 500,可用 env XQSHARE_MAX_QUOTE_SUBSCRIBERS 覆盖。"""
|
|
707
|
+
raw = os.environ.get("XQSHARE_MAX_QUOTE_SUBSCRIBERS", "500")
|
|
708
|
+
try:
|
|
709
|
+
return int(raw)
|
|
710
|
+
except (TypeError, ValueError):
|
|
711
|
+
return 500
|
|
712
|
+
|
|
713
|
+
|
|
658
714
|
def _call_download_with_timeout(fn, *args, timeout=None, name=None, **kwargs):
|
|
659
715
|
"""在独立 daemon 线程执行下载,主线程带超时等待。
|
|
660
716
|
|
|
@@ -790,13 +846,13 @@ class LoggingProxy:
|
|
|
790
846
|
pos = _QUOTE_SUBSCRIBE_CALLBACK_POS[name]
|
|
791
847
|
client_callback = kwargs.pop('callback', None)
|
|
792
848
|
if client_callback is not None:
|
|
793
|
-
# 客户端传了接收器(netref
|
|
794
|
-
quote_event_callback.register(client_callback)
|
|
849
|
+
# 客户端传了接收器(netref),注册到路由器(带 client_info 供断线清理)
|
|
850
|
+
quote_event_callback.register(client_callback, get_client_info())
|
|
795
851
|
kwargs['callback'] = quote_event_callback
|
|
796
852
|
elif len(args) > pos:
|
|
797
853
|
args = list(args)
|
|
798
854
|
if args[pos] is not None:
|
|
799
|
-
quote_event_callback.register(args[pos])
|
|
855
|
+
quote_event_callback.register(args[pos], get_client_info())
|
|
800
856
|
args[pos] = quote_event_callback
|
|
801
857
|
args = tuple(args)
|
|
802
858
|
else:
|
|
@@ -915,9 +971,15 @@ class XtQuantService(rpyc.Service):
|
|
|
915
971
|
def on_disconnect(self, conn):
|
|
916
972
|
client_info = getattr(self, '_client_info', 'unknown')
|
|
917
973
|
logger.info(f"[断开] 客户端离开: {client_info}")
|
|
918
|
-
#
|
|
974
|
+
# 清理本连接注册的 quote 行情回调(根因:netref 随重连累积,只进不出)
|
|
975
|
+
qec = XtQuantService._quote_event_callback
|
|
976
|
+
if qec is not None:
|
|
977
|
+
qec.clear_client_callbacks(client_info)
|
|
978
|
+
# 自动清理本次连接创建的所有 trader 实例及其事件路由器,
|
|
979
|
+
# 含停止 _TraderEventCallback 的 dispatch daemon 线程(否则线程持
|
|
980
|
+
# 有回调对象与 netref,随断线累积泄漏)
|
|
919
981
|
traders = getattr(self, '_traders', [])
|
|
920
|
-
for trader in traders:
|
|
982
|
+
for trader, event_callback in traders:
|
|
921
983
|
# disconnect() 在部分 xtquant 版本中不存在,仅作可选清理
|
|
922
984
|
if hasattr(trader, 'disconnect'):
|
|
923
985
|
try:
|
|
@@ -930,6 +992,12 @@ class XtQuantService(rpyc.Service):
|
|
|
930
992
|
logger.info(f"[清理Trader] 已自动 stop trader | client={client_info}")
|
|
931
993
|
except Exception as e:
|
|
932
994
|
logger.warning(f"[清理Trader] stop trader 失败: {e} | client={client_info}")
|
|
995
|
+
if event_callback is not None and hasattr(event_callback, 'stop'):
|
|
996
|
+
try:
|
|
997
|
+
event_callback.stop()
|
|
998
|
+
logger.info(f"[清理Trader] 已停止事件路由器线程 | client={client_info}")
|
|
999
|
+
except Exception as e:
|
|
1000
|
+
logger.warning(f"[清理Trader] 停止事件路由器线程失败: {e} | client={client_info}")
|
|
933
1001
|
|
|
934
1002
|
def _delayed_disconnect(self, delay: float = 0.5):
|
|
935
1003
|
"""延迟断开连接,确保异常能传输到客户端"""
|
|
@@ -1098,13 +1166,13 @@ class XtQuantService(rpyc.Service):
|
|
|
1098
1166
|
|
|
1099
1167
|
logger.info(f"[创建Trader] userdata_path={userdata_path} | session_id={session_id}")
|
|
1100
1168
|
|
|
1101
|
-
# 记录 trader 实例,供 on_disconnect 时自动清理
|
|
1102
|
-
self._traders.append(trader)
|
|
1103
|
-
|
|
1104
1169
|
# 每个 trader 独立一个事件路由器,用于把 on_stock_* 推送给远端客户端
|
|
1105
1170
|
event_callback = _TraderEventCallback()
|
|
1106
1171
|
trader.register_callback(event_callback)
|
|
1107
1172
|
|
|
1173
|
+
# 记录 trader + 事件路由器,供 on_disconnect 时自动清理(含停止线程)
|
|
1174
|
+
self._traders.append((trader, event_callback))
|
|
1175
|
+
|
|
1108
1176
|
# 用 LoggingProxy 包装 trader,支持日志记录、序列化和事件回调透传
|
|
1109
1177
|
return LoggingProxy(
|
|
1110
1178
|
trader, 'xttrader',
|
|
@@ -1143,8 +1211,8 @@ class XtQuantService(rpyc.Service):
|
|
|
1143
1211
|
)
|
|
1144
1212
|
trader.register_callback(adapter)
|
|
1145
1213
|
|
|
1146
|
-
# 记录 trader
|
|
1147
|
-
self._traders.append(trader)
|
|
1214
|
+
# 记录 trader + 事件路由器,供 on_disconnect 时自动清理(含停止线程)
|
|
1215
|
+
self._traders.append((trader, event_callback))
|
|
1148
1216
|
|
|
1149
1217
|
return LoggingProxy(
|
|
1150
1218
|
trader, 'xttrader',
|
|
@@ -1176,18 +1244,23 @@ class XtQuantService(rpyc.Service):
|
|
|
1176
1244
|
|
|
1177
1245
|
logger.info(f"[创建并连接大QMT Trader] account_id={account_id} | session_id={session_id}")
|
|
1178
1246
|
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1247
|
+
try:
|
|
1248
|
+
start_result = trader.start()
|
|
1249
|
+
logger.info(f"[大QMT Trader] start() 返回: {start_result}")
|
|
1250
|
+
if start_result not in (0, None):
|
|
1251
|
+
raise RuntimeError(f"启动大QMT 交易线程失败,错误码: {start_result}")
|
|
1252
|
+
|
|
1253
|
+
# bigqmt connect() 同步:内部 RPC ping + 合成 on_account_status;失败抛异常
|
|
1254
|
+
connect_result = trader.connect()
|
|
1255
|
+
logger.info(f"[大QMT Trader] connect() 返回: {connect_result}")
|
|
1256
|
+
if connect_result not in (0, None):
|
|
1257
|
+
raise ConnectionError(f"大QMT 连接失败: 错误码 {connect_result}")
|
|
1258
|
+
except Exception:
|
|
1259
|
+
# 失败路径也要停止事件路由器线程,避免泄漏
|
|
1260
|
+
event_callback.stop()
|
|
1261
|
+
raise
|
|
1189
1262
|
|
|
1190
|
-
self._traders.append(trader)
|
|
1263
|
+
self._traders.append((trader, event_callback))
|
|
1191
1264
|
|
|
1192
1265
|
return LoggingProxy(
|
|
1193
1266
|
trader, 'xttrader',
|
|
@@ -1247,48 +1320,56 @@ class XtQuantService(rpyc.Service):
|
|
|
1247
1320
|
|
|
1248
1321
|
logger.info(f"[创建并连接Trader] userdata_path={userdata_path} | session_id={session_id}")
|
|
1249
1322
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1323
|
+
try:
|
|
1324
|
+
start_result = trader.start()
|
|
1325
|
+
logger.info(f"[Trader] start() 返回: {start_result}")
|
|
1326
|
+
if start_result not in (0, None):
|
|
1327
|
+
raise RuntimeError(f"启动交易线程失败,错误码: {start_result}")
|
|
1328
|
+
|
|
1329
|
+
connect_result = trader.connect()
|
|
1330
|
+
logger.info(f"[Trader] connect() 返回: {connect_result}")
|
|
1331
|
+
if connect_result not in (0, None):
|
|
1332
|
+
error_codes = {
|
|
1333
|
+
-1: "交易服务器未连接",
|
|
1334
|
+
-2: "账号未登录",
|
|
1335
|
+
-3: "请求超时",
|
|
1336
|
+
-4: "资金账号不存在",
|
|
1337
|
+
}
|
|
1338
|
+
raise ConnectionError(f"连接失败: {error_codes.get(connect_result, f'错误码 {connect_result}')}")
|
|
1339
|
+
|
|
1340
|
+
# 等待真实的交易连接建立
|
|
1341
|
+
connected = callback.wait_for_connected(connect_timeout)
|
|
1342
|
+
logger.info(f"[Trader] on_connected 回调状态: {connected}")
|
|
1343
|
+
if not connected:
|
|
1344
|
+
# 某些版本的 xtquant 可能不会触发 on_connected 回调,
|
|
1345
|
+
# 尝试用 query_stock_asset 验证连接是否真正可用
|
|
1346
|
+
logger.info("[Trader] on_connected 未触发,尝试 query_stock_asset 验证连接...")
|
|
1347
|
+
try:
|
|
1348
|
+
from xtquant.xttype import StockAccount
|
|
1349
|
+
account_id = os.environ.get("QMT_ACCOUNT_ID")
|
|
1350
|
+
if account_id:
|
|
1351
|
+
test_account = StockAccount(account_id)
|
|
1352
|
+
asset = trader.query_stock_asset(test_account)
|
|
1353
|
+
logger.info(f"[Trader] query_stock_asset 验证成功: {asset}")
|
|
1354
|
+
connected = True
|
|
1355
|
+
except Exception as e:
|
|
1356
|
+
logger.warning(f"[Trader] query_stock_asset 验证失败: {e}")
|
|
1283
1357
|
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1358
|
+
if not connected:
|
|
1359
|
+
raise TimeoutError(
|
|
1360
|
+
f"等待交易连接建立超时({connect_timeout}秒),"
|
|
1361
|
+
"请检查 MiniQMT 是否已登录交易账号,或设置更长的 --connect-timeout"
|
|
1362
|
+
)
|
|
1363
|
+
except Exception:
|
|
1364
|
+
# 失败路径:停止 trader 与事件路由器线程,避免泄漏
|
|
1365
|
+
try:
|
|
1366
|
+
trader.stop()
|
|
1367
|
+
except Exception:
|
|
1368
|
+
pass
|
|
1369
|
+
callback.stop()
|
|
1370
|
+
raise
|
|
1290
1371
|
|
|
1291
|
-
self._traders.append(trader)
|
|
1372
|
+
self._traders.append((trader, callback))
|
|
1292
1373
|
|
|
1293
1374
|
return LoggingProxy(
|
|
1294
1375
|
trader, 'xttrader',
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{xtquant_share-1.3.0 → xtquant_share-1.3.1}/tests/test_server_download_history_data2_compat.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|