xtquant-share 1.3.0__tar.gz → 1.4.0__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.4.0}/PKG-INFO +3 -1
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/README.md +2 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/pyproject.toml +1 -1
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_quote_events.py +130 -3
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_server.py +64 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/__init__.py +1 -1
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/server.py +250 -65
- {xtquant_share-1.3.0 → xtquant_share-1.4.0/xtquant_share.egg-info}/PKG-INFO +3 -1
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/LICENSE +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/setup.cfg +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_auth.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_channels_bigqmt.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_channels_health.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_channels_router.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_client.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_client_permission.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_integration.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_qmt_datadir_factor.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_remote_data.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_server_channel_routing.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_server_download_history_data2_compat.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_server_download_timeout.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_server_permission.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_tools_common.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_trader_events.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/.env.example +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/auth.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/channels/__init__.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/channels/bigqmt.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/channels/health.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/channels/router.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/check_env.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/client.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/clients.yaml.example +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/qmt_datadir/__init__.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/qmt_datadir/divid.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/qmt_datadir/factor.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/qmt_datadir/kline.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/qmt_datadir/misc.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/qmt_datadir/reader.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/qmt_datadir/sector.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/qmt_watchdog.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/tools/__init__.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/tools/common.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/tools/xtdata.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/tools/xttrader.py +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xtquant_share.egg-info/SOURCES.txt +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xtquant_share.egg-info/dependency_links.txt +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xtquant_share.egg-info/entry_points.txt +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xtquant_share.egg-info/requires.txt +0 -0
- {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xtquant_share.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: xtquant-share
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: Xtreme Quant Share - eXtreme transparent sharing proxy for XtQuant on macOS/Linux
|
|
5
5
|
Author-email: Jason Hu <63170682@qq.com>
|
|
6
6
|
License-Expression: GPL-3.0-only
|
|
@@ -50,6 +50,7 @@ Dynamic: license-file
|
|
|
50
50
|
- ✅ **认证加密** - 支持 HMAC token 认证,可选 SSL/TLS 加密
|
|
51
51
|
- ✅ **断线重连** - 自动检测断线并重连,指数退避策略
|
|
52
52
|
- ✅ **心跳保活** - 定期心跳检测,保持连接活跃
|
|
53
|
+
- ✅ **服务实例标识** - `get_instance_id()` 轻量感知 server 重启;行情订阅幂等自动去重,阻断底层 miniquote 订阅累积
|
|
53
54
|
- ✅ **异步回调** - 支持交易事件(on_stock_*)与行情 tick 订阅回调(`xtdata.register_quote_callback` + `subscribe_whole_quote`)
|
|
54
55
|
- ✅ **无感切换** - 大QMT ↔ miniQMT 无感切换(行情侧),自动探测 miniQMT 可用性,不可用自动切 bigqmt 通道(复用 xtquant_big_convert)
|
|
55
56
|
- ✅ **完整日志** - API调用日志,记录函数名、参数、耗时
|
|
@@ -801,6 +802,7 @@ print(status)
|
|
|
801
802
|
|
|
802
803
|
| 版本 | 日期 | 更新内容 |
|
|
803
804
|
|------|------|----------|
|
|
805
|
+
| 1.4.0 | 2026-10-03 | 新增 `get_instance_id()` 服务实例标识(轻量感知 server 重启);行情订阅幂等(重复订阅自动释放旧 seq,阻断 miniquote 订阅累积泄漏) |
|
|
804
806
|
| 1.1.1 | 2026-03-18 | 新增 xtview 模块支持(视图控制、调度任务管理),兼容不同 xtquant 版本 |
|
|
805
807
|
| 1.1.0 | 2026-03-17 | 交易功能优化完善、`xqshare-server` 命令、`.env` 配置支持、远程对象传输性能优化 |
|
|
806
808
|
| 1.0.4 | 2026-03-09 | JSON 输出优化:远程 DataFrame 高效序列化、`--compact` 参数、全局参数位置灵活、嵌套结构支持 |
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
- ✅ **认证加密** - 支持 HMAC token 认证,可选 SSL/TLS 加密
|
|
9
9
|
- ✅ **断线重连** - 自动检测断线并重连,指数退避策略
|
|
10
10
|
- ✅ **心跳保活** - 定期心跳检测,保持连接活跃
|
|
11
|
+
- ✅ **服务实例标识** - `get_instance_id()` 轻量感知 server 重启;行情订阅幂等自动去重,阻断底层 miniquote 订阅累积
|
|
11
12
|
- ✅ **异步回调** - 支持交易事件(on_stock_*)与行情 tick 订阅回调(`xtdata.register_quote_callback` + `subscribe_whole_quote`)
|
|
12
13
|
- ✅ **无感切换** - 大QMT ↔ miniQMT 无感切换(行情侧),自动探测 miniQMT 可用性,不可用自动切 bigqmt 通道(复用 xtquant_big_convert)
|
|
13
14
|
- ✅ **完整日志** - API调用日志,记录函数名、参数、耗时
|
|
@@ -759,6 +760,7 @@ print(status)
|
|
|
759
760
|
|
|
760
761
|
| 版本 | 日期 | 更新内容 |
|
|
761
762
|
|------|------|----------|
|
|
763
|
+
| 1.4.0 | 2026-10-03 | 新增 `get_instance_id()` 服务实例标识(轻量感知 server 重启);行情订阅幂等(重复订阅自动释放旧 seq,阻断 miniquote 订阅累积泄漏) |
|
|
762
764
|
| 1.1.1 | 2026-03-18 | 新增 xtview 模块支持(视图控制、调度任务管理),兼容不同 xtquant 版本 |
|
|
763
765
|
| 1.1.0 | 2026-03-17 | 交易功能优化完善、`xqshare-server` 命令、`.env` 配置支持、远程对象传输性能优化 |
|
|
764
766
|
| 1.0.4 | 2026-03-09 | JSON 输出优化:远程 DataFrame 高效序列化、`--compact` 参数、全局参数位置灵活、嵌套结构支持 |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "xtquant-share"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.4.0"
|
|
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
|
|
|
@@ -193,6 +272,54 @@ class TestLoggingProxyQuoteIntercept:
|
|
|
193
272
|
finally:
|
|
194
273
|
qec.stop()
|
|
195
274
|
|
|
275
|
+
def test_subscribe_idempotent_unsubscribe_old_seq(self):
|
|
276
|
+
"""同一 client 重复订阅同一 code_list,应先 unsubscribe 旧 seq 再订阅(防 miniquote 累积)"""
|
|
277
|
+
mock = MagicMock()
|
|
278
|
+
mock.subscribe_whole_quote.side_effect = [1, 2]
|
|
279
|
+
mock.unsubscribe_quote = MagicMock()
|
|
280
|
+
qec = _QuoteEventCallback()
|
|
281
|
+
try:
|
|
282
|
+
proxy = self._proxy(mock, qec)
|
|
283
|
+
client_cb = MagicMock()
|
|
284
|
+
proxy.subscribe_whole_quote(["000001.SZ"], callback=client_cb) # seq=1
|
|
285
|
+
proxy.subscribe_whole_quote(["000001.SZ"], callback=client_cb) # seq=2,先 unsubscribe 1
|
|
286
|
+
|
|
287
|
+
mock.unsubscribe_quote.assert_called_once_with(1)
|
|
288
|
+
assert mock.subscribe_whole_quote.call_count == 2
|
|
289
|
+
finally:
|
|
290
|
+
qec.stop()
|
|
291
|
+
|
|
292
|
+
def test_subscribe_different_codes_no_unsubscribe(self):
|
|
293
|
+
"""不同 code_list 的订阅互不影响,不应触发 unsubscribe"""
|
|
294
|
+
mock = MagicMock()
|
|
295
|
+
mock.subscribe_whole_quote.side_effect = [1, 2]
|
|
296
|
+
mock.unsubscribe_quote = MagicMock()
|
|
297
|
+
qec = _QuoteEventCallback()
|
|
298
|
+
try:
|
|
299
|
+
proxy = self._proxy(mock, qec)
|
|
300
|
+
proxy.subscribe_whole_quote(["000001.SZ"])
|
|
301
|
+
proxy.subscribe_whole_quote(["000002.SZ"])
|
|
302
|
+
|
|
303
|
+
mock.unsubscribe_quote.assert_not_called()
|
|
304
|
+
finally:
|
|
305
|
+
qec.stop()
|
|
306
|
+
|
|
307
|
+
def test_subscription_seq_record_and_clear(self):
|
|
308
|
+
"""seq 管理:record → get 命中,clear 后 unsubscribe 所有 seq"""
|
|
309
|
+
qec = _QuoteEventCallback()
|
|
310
|
+
try:
|
|
311
|
+
key = ("subscribe_whole_quote", ("000001.SZ",))
|
|
312
|
+
qec.record_subscription("c1", key, 1)
|
|
313
|
+
assert qec.get_subscription_seq("c1", key) == 1
|
|
314
|
+
assert qec.get_subscription_seq("c1", ("subscribe_whole_quote", ("000002.SZ",))) is None
|
|
315
|
+
|
|
316
|
+
unsubscribed = []
|
|
317
|
+
qec.clear_client_subscriptions("c1", lambda seq: unsubscribed.append(seq))
|
|
318
|
+
assert unsubscribed == [1]
|
|
319
|
+
assert qec.get_subscription_seq("c1", key) is None
|
|
320
|
+
finally:
|
|
321
|
+
qec.stop()
|
|
322
|
+
|
|
196
323
|
|
|
197
324
|
class TestRemoteQuoteCallback:
|
|
198
325
|
"""客户端行情回调接收器"""
|
|
@@ -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
|
|
|
@@ -434,5 +477,26 @@ class TestInitDatadirReader:
|
|
|
434
477
|
XtQuantService._xtdata = original_xtdata
|
|
435
478
|
|
|
436
479
|
|
|
480
|
+
class TestInstanceId:
|
|
481
|
+
"""服务实例标识(instance_id)与订阅幂等辅助函数"""
|
|
482
|
+
|
|
483
|
+
def test_instance_id_is_nonempty_str(self):
|
|
484
|
+
from xqshare import server as server_mod
|
|
485
|
+
assert isinstance(server_mod._INSTANCE_ID, str)
|
|
486
|
+
assert len(server_mod._INSTANCE_ID) > 0
|
|
487
|
+
|
|
488
|
+
def test_exposed_get_instance_id(self):
|
|
489
|
+
from xqshare import server as server_mod
|
|
490
|
+
svc = XtQuantService()
|
|
491
|
+
assert svc.exposed_get_instance_id() == server_mod._INSTANCE_ID
|
|
492
|
+
|
|
493
|
+
def test_normalize_code_key(self):
|
|
494
|
+
from xqshare.server import _normalize_code_key
|
|
495
|
+
assert _normalize_code_key(["000001.SZ"]) == ("000001.SZ",)
|
|
496
|
+
assert _normalize_code_key(["SZ", "SH"]) == ("SH", "SZ") # 排序去序
|
|
497
|
+
assert _normalize_code_key("000001.SZ") == ("000001.SZ",)
|
|
498
|
+
assert _normalize_code_key(("000001.SZ", "000002.SZ")) == ("000001.SZ", "000002.SZ")
|
|
499
|
+
|
|
500
|
+
|
|
437
501
|
if __name__ == "__main__":
|
|
438
502
|
pytest.main([__file__, "-v"])
|
|
@@ -13,6 +13,7 @@ import logging
|
|
|
13
13
|
import functools
|
|
14
14
|
import json
|
|
15
15
|
import threading
|
|
16
|
+
import uuid
|
|
16
17
|
from datetime import datetime
|
|
17
18
|
from typing import Any, Dict, Optional
|
|
18
19
|
|
|
@@ -563,12 +564,20 @@ class _QuoteEventCallback:
|
|
|
563
564
|
# key 用 id(netref)(本地内存地址)而非 netref 本身:
|
|
564
565
|
# set 的 add/discard 会对 netref 求 hash(触发 RPC),客户端断开后
|
|
565
566
|
# 该 RPC 会抛 EOFError,导致 dispatch 线程崩溃。id() 是纯本地操作。
|
|
566
|
-
|
|
567
|
+
# value 记录 client_info,供 on_disconnect 按连接清理(修复 netref 累积泄漏)。
|
|
568
|
+
self._subscribers = {} # id(client_callback_netref) -> {callback, client_info}
|
|
569
|
+
# 订阅幂等:client_info -> {code_key -> seq}。code_key = (方法名, 规范化code_list)。
|
|
570
|
+
# 同一 client 重复订阅同一 code_key 时先 unsubscribe 旧 seq 再订阅,
|
|
571
|
+
# 避免 miniquote 侧订阅累积(曾导致 miniquote 内存泄漏拖垮整机)。
|
|
572
|
+
self._subscriptions = {}
|
|
567
573
|
self._running = True
|
|
568
574
|
self._queue = []
|
|
569
575
|
self._queue_lock = threading.Lock()
|
|
570
576
|
self._event_thread = threading.Thread(target=self._dispatch_loop, daemon=True)
|
|
571
577
|
self._event_thread.start()
|
|
578
|
+
# 订阅者数量告警水位:超过阈值打 warning,用于观察是否仍存在泄漏
|
|
579
|
+
self._max_subscribers_warning = _get_max_quote_subscribers()
|
|
580
|
+
self._warned = False
|
|
572
581
|
|
|
573
582
|
def __call__(self, data):
|
|
574
583
|
"""使路由器可被 xtquant 作为函数式 callback 调用。
|
|
@@ -604,7 +613,7 @@ class _QuoteEventCallback:
|
|
|
604
613
|
continue
|
|
605
614
|
for payload in batch:
|
|
606
615
|
with self._lock:
|
|
607
|
-
callbacks =
|
|
616
|
+
callbacks = [entry['callback'] for entry in self._subscribers.values()]
|
|
608
617
|
for cb in callbacks:
|
|
609
618
|
try:
|
|
610
619
|
cb.exposed_on_quote_event(payload)
|
|
@@ -613,15 +622,90 @@ class _QuoteEventCallback:
|
|
|
613
622
|
logger.warning(f"[QuoteEvent] 推送到客户端失败,自动移除: {e}")
|
|
614
623
|
self.unregister(cb)
|
|
615
624
|
|
|
616
|
-
def register(self, client_callback):
|
|
625
|
+
def register(self, client_callback, client_info: Optional[str] = None):
|
|
617
626
|
with self._lock:
|
|
618
|
-
|
|
627
|
+
if client_info is not None:
|
|
628
|
+
# 幂等化:同一 client_info 重复 register(客户端断线重连后新 netref
|
|
629
|
+
# 的 id 不同)时,先移除该 client 的旧 entry,避免按 id(netref) 累积。
|
|
630
|
+
stale = [
|
|
631
|
+
cid for cid, entry in self._subscribers.items()
|
|
632
|
+
if entry['client_info'] == client_info
|
|
633
|
+
]
|
|
634
|
+
for cid in stale:
|
|
635
|
+
self._subscribers.pop(cid, None)
|
|
636
|
+
self._subscribers[id(client_callback)] = {
|
|
637
|
+
'callback': client_callback,
|
|
638
|
+
'client_info': client_info,
|
|
639
|
+
}
|
|
640
|
+
self._maybe_warn_subscriber_count()
|
|
619
641
|
logger.info(f"[QuoteEvent] 注册订阅者 total={len(self._subscribers)}")
|
|
620
642
|
|
|
621
643
|
def unregister(self, client_callback):
|
|
622
644
|
with self._lock:
|
|
623
645
|
self._subscribers.pop(id(client_callback), None)
|
|
624
646
|
|
|
647
|
+
def clear_client_callbacks(self, client_info: Optional[str]):
|
|
648
|
+
"""按连接清理该客户端注册的所有 quote 回调(on_disconnect 调用)。"""
|
|
649
|
+
if client_info is None:
|
|
650
|
+
return
|
|
651
|
+
with self._lock:
|
|
652
|
+
stale = [
|
|
653
|
+
cid for cid, entry in self._subscribers.items()
|
|
654
|
+
if entry['client_info'] == client_info
|
|
655
|
+
]
|
|
656
|
+
for cid in stale:
|
|
657
|
+
self._subscribers.pop(cid, None)
|
|
658
|
+
if stale:
|
|
659
|
+
logger.info(
|
|
660
|
+
f"[QuoteEvent] 清理断开客户端的订阅者: client={client_info} "
|
|
661
|
+
f"removed={len(stale)} total={len(self._subscribers)}"
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
def get_subscription_seq(self, client_info: Optional[str], code_key):
|
|
665
|
+
"""查该 client 是否已订阅过 code_key,返回旧 seq(未订阅返回 None)。"""
|
|
666
|
+
with self._lock:
|
|
667
|
+
subs = self._subscriptions.get(client_info)
|
|
668
|
+
if subs:
|
|
669
|
+
return subs.get(code_key)
|
|
670
|
+
return None
|
|
671
|
+
|
|
672
|
+
def record_subscription(self, client_info: Optional[str], code_key, seq):
|
|
673
|
+
"""记录该 client 对 code_key 的最新订阅 seq(供下次重复订阅时先释放旧 seq)。"""
|
|
674
|
+
if client_info is None or seq is None:
|
|
675
|
+
return
|
|
676
|
+
with self._lock:
|
|
677
|
+
self._subscriptions.setdefault(client_info, {})[code_key] = seq
|
|
678
|
+
|
|
679
|
+
def clear_client_subscriptions(self, client_info: Optional[str], unsubscribe_func):
|
|
680
|
+
"""清理该 client 的所有 miniquote 订阅(unsubscribe 所有 seq),on_disconnect 调用。"""
|
|
681
|
+
if client_info is None:
|
|
682
|
+
return
|
|
683
|
+
with self._lock:
|
|
684
|
+
subs = self._subscriptions.pop(client_info, None)
|
|
685
|
+
if not subs:
|
|
686
|
+
return
|
|
687
|
+
for seq in subs.values():
|
|
688
|
+
try:
|
|
689
|
+
unsubscribe_func(seq)
|
|
690
|
+
except Exception as exc:
|
|
691
|
+
logger.warning(f"[QuoteEvent] 清理订阅失败 seq={seq}: {exc}")
|
|
692
|
+
logger.info(
|
|
693
|
+
f"[QuoteEvent] 清理断开客户端的订阅: client={client_info} removed={len(subs)}"
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
def _maybe_warn_subscriber_count(self):
|
|
697
|
+
"""订阅者数量超过水位时打 warning(回落时复位,便于反复告警)。"""
|
|
698
|
+
n = len(self._subscribers)
|
|
699
|
+
if n > self._max_subscribers_warning:
|
|
700
|
+
if not self._warned:
|
|
701
|
+
self._warned = True
|
|
702
|
+
logger.warning(
|
|
703
|
+
f"[QuoteEvent] 订阅者数量异常: total={n} "
|
|
704
|
+
f"(阈值 {self._max_subscribers_warning}),可能存在回调泄漏"
|
|
705
|
+
)
|
|
706
|
+
elif self._warned:
|
|
707
|
+
self._warned = False
|
|
708
|
+
|
|
625
709
|
def stop(self):
|
|
626
710
|
self._running = False
|
|
627
711
|
|
|
@@ -634,6 +718,40 @@ _QUOTE_SUBSCRIBE_CALLBACK_POS = {
|
|
|
634
718
|
}
|
|
635
719
|
|
|
636
720
|
|
|
721
|
+
# ==================== 服务实例标识 ====================
|
|
722
|
+
|
|
723
|
+
# server 进程启动时生成的唯一标识,用于让客户端轻量感知 server 重启:
|
|
724
|
+
# 客户端通过 get_instance_id() 对比该值,一旦变化说明 server 重启过,
|
|
725
|
+
# 内存态的订阅/回调状态已丢失,需要重新订阅。避免用「重复订阅 + seq 回退」
|
|
726
|
+
# 这种重且带副作用的方式探测重启(曾导致 miniquote 订阅累积泄漏)。
|
|
727
|
+
_INSTANCE_ID = uuid.uuid4().hex
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _normalize_code_key(code_list) -> tuple:
|
|
731
|
+
"""把订阅的 code_list 规范化为可哈希的元组,用于订阅幂等去重。
|
|
732
|
+
|
|
733
|
+
subscribe_whole_quote 的 code_list 是 list(如 ['688523.SH'] / ['SH','SZ']),
|
|
734
|
+
subscribe_quote/quote2 的 stock_code 是单个 str。统一转成排序后的 tuple。
|
|
735
|
+
"""
|
|
736
|
+
if isinstance(code_list, (list, tuple, set)):
|
|
737
|
+
return tuple(sorted(str(c) for c in code_list))
|
|
738
|
+
if isinstance(code_list, str):
|
|
739
|
+
return (code_list,)
|
|
740
|
+
return (str(code_list),)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _unsubscribe_safely(target, seq):
|
|
744
|
+
"""安全地取消一个 miniquote 订阅(unsubscribe_quote),失败不抛出。"""
|
|
745
|
+
if seq is None:
|
|
746
|
+
return
|
|
747
|
+
try:
|
|
748
|
+
unsubscribe = getattr(target, 'unsubscribe_quote', None)
|
|
749
|
+
if unsubscribe is not None:
|
|
750
|
+
unsubscribe(seq)
|
|
751
|
+
except Exception as exc:
|
|
752
|
+
logger.warning(f"[QuoteEvent] 取消旧订阅失败 seq={seq}: {exc}")
|
|
753
|
+
|
|
754
|
+
|
|
637
755
|
# ==================== download 类调用超时隔离 ====================
|
|
638
756
|
|
|
639
757
|
# 需要超时/线程隔离的 download 类方法名(盘后券商挂起会永久阻塞 worker 线程)
|
|
@@ -655,6 +773,15 @@ def _get_download_timeout() -> int:
|
|
|
655
773
|
DOWNLOAD_TIMEOUT_SECONDS = _get_download_timeout()
|
|
656
774
|
|
|
657
775
|
|
|
776
|
+
def _get_max_quote_subscribers() -> int:
|
|
777
|
+
"""读取 quote 订阅者告警水位,默认 500,可用 env XQSHARE_MAX_QUOTE_SUBSCRIBERS 覆盖。"""
|
|
778
|
+
raw = os.environ.get("XQSHARE_MAX_QUOTE_SUBSCRIBERS", "500")
|
|
779
|
+
try:
|
|
780
|
+
return int(raw)
|
|
781
|
+
except (TypeError, ValueError):
|
|
782
|
+
return 500
|
|
783
|
+
|
|
784
|
+
|
|
658
785
|
def _call_download_with_timeout(fn, *args, timeout=None, name=None, **kwargs):
|
|
659
786
|
"""在独立 daemon 线程执行下载,主线程带超时等待。
|
|
660
787
|
|
|
@@ -752,6 +879,7 @@ class LoggingProxy:
|
|
|
752
879
|
if callable(attr):
|
|
753
880
|
def wrapper(*args, **kwargs):
|
|
754
881
|
full_name = f"{target_name}.{name}"
|
|
882
|
+
_sub_key = None # 订阅幂等:本次调用的 code_key,供 _log_call 后记录 seq
|
|
755
883
|
|
|
756
884
|
# 权限检查
|
|
757
885
|
if permission_checker and account_level:
|
|
@@ -788,20 +916,30 @@ class LoggingProxy:
|
|
|
788
916
|
if target_name == 'xtdata' and quote_event_callback is not None:
|
|
789
917
|
if name in _QUOTE_SUBSCRIBE_CALLBACK_POS:
|
|
790
918
|
pos = _QUOTE_SUBSCRIBE_CALLBACK_POS[name]
|
|
919
|
+
# 订阅幂等:提取 code_key(方法名 + 规范化 code_list),
|
|
920
|
+
# 同一 client 重复订阅时先 unsubscribe 旧 seq,避免 miniquote 累积。
|
|
921
|
+
_code_list = _deliver(args[0]) if args else kwargs.get('stock_code')
|
|
922
|
+
if _code_list is not None:
|
|
923
|
+
_sub_key = (name, _normalize_code_key(_code_list))
|
|
791
924
|
client_callback = kwargs.pop('callback', None)
|
|
792
925
|
if client_callback is not None:
|
|
793
|
-
# 客户端传了接收器(netref
|
|
794
|
-
quote_event_callback.register(client_callback)
|
|
926
|
+
# 客户端传了接收器(netref),注册到路由器(带 client_info 供断线清理)
|
|
927
|
+
quote_event_callback.register(client_callback, get_client_info())
|
|
795
928
|
kwargs['callback'] = quote_event_callback
|
|
796
929
|
elif len(args) > pos:
|
|
797
930
|
args = list(args)
|
|
798
931
|
if args[pos] is not None:
|
|
799
|
-
quote_event_callback.register(args[pos])
|
|
932
|
+
quote_event_callback.register(args[pos], get_client_info())
|
|
800
933
|
args[pos] = quote_event_callback
|
|
801
934
|
args = tuple(args)
|
|
802
935
|
else:
|
|
803
936
|
# 未传回调:仍用服务端路由器接管(此时无订阅者,数据被丢弃)
|
|
804
937
|
kwargs['callback'] = quote_event_callback
|
|
938
|
+
# 幂等:重复订阅先释放旧 seq(失败不阻断本次订阅)
|
|
939
|
+
if _sub_key is not None:
|
|
940
|
+
old_seq = quote_event_callback.get_subscription_seq(get_client_info(), _sub_key)
|
|
941
|
+
if old_seq is not None:
|
|
942
|
+
_unsubscribe_safely(target, old_seq)
|
|
805
943
|
|
|
806
944
|
# download 类调用统一加超时隔离:对直通底层 xtquant 的 download
|
|
807
945
|
# 方法(无 exposed_* 封装的)包进 _call_download_with_timeout,
|
|
@@ -814,6 +952,12 @@ class LoggingProxy:
|
|
|
814
952
|
else:
|
|
815
953
|
result = _log_call(full_name, get_client_info(), attr, *args, **kwargs)
|
|
816
954
|
|
|
955
|
+
# 订阅幂等:记录本次订阅返回的 seq,供下次重复订阅时先释放旧 seq
|
|
956
|
+
if _sub_key is not None:
|
|
957
|
+
_seq = _deliver(result)
|
|
958
|
+
if isinstance(_seq, int):
|
|
959
|
+
quote_event_callback.record_subscription(get_client_info(), _sub_key, _seq)
|
|
960
|
+
|
|
817
961
|
# 如果返回的是复杂对象(非基本类型),递归包装
|
|
818
962
|
if result is not None and hasattr(result, '__class__'):
|
|
819
963
|
if not isinstance(result, (int, float, str, bool, list, dict, tuple, type(None), bytes)):
|
|
@@ -915,9 +1059,21 @@ class XtQuantService(rpyc.Service):
|
|
|
915
1059
|
def on_disconnect(self, conn):
|
|
916
1060
|
client_info = getattr(self, '_client_info', 'unknown')
|
|
917
1061
|
logger.info(f"[断开] 客户端离开: {client_info}")
|
|
918
|
-
#
|
|
1062
|
+
# 清理本连接注册的 quote 行情回调(根因:netref 随重连累积,只进不出)
|
|
1063
|
+
qec = XtQuantService._quote_event_callback
|
|
1064
|
+
if qec is not None:
|
|
1065
|
+
qec.clear_client_callbacks(client_info)
|
|
1066
|
+
# 清理本连接向 miniquote 建立的所有订阅(unsubscribe 所有 seq),
|
|
1067
|
+
# 避免 miniquote 侧订阅累积泄漏(根因:反复订阅不取消)。
|
|
1068
|
+
qec.clear_client_subscriptions(
|
|
1069
|
+
client_info,
|
|
1070
|
+
lambda seq: _unsubscribe_safely(XtQuantService._xtdata, seq),
|
|
1071
|
+
)
|
|
1072
|
+
# 自动清理本次连接创建的所有 trader 实例及其事件路由器,
|
|
1073
|
+
# 含停止 _TraderEventCallback 的 dispatch daemon 线程(否则线程持
|
|
1074
|
+
# 有回调对象与 netref,随断线累积泄漏)
|
|
919
1075
|
traders = getattr(self, '_traders', [])
|
|
920
|
-
for trader in traders:
|
|
1076
|
+
for trader, event_callback in traders:
|
|
921
1077
|
# disconnect() 在部分 xtquant 版本中不存在,仅作可选清理
|
|
922
1078
|
if hasattr(trader, 'disconnect'):
|
|
923
1079
|
try:
|
|
@@ -930,6 +1086,12 @@ class XtQuantService(rpyc.Service):
|
|
|
930
1086
|
logger.info(f"[清理Trader] 已自动 stop trader | client={client_info}")
|
|
931
1087
|
except Exception as e:
|
|
932
1088
|
logger.warning(f"[清理Trader] stop trader 失败: {e} | client={client_info}")
|
|
1089
|
+
if event_callback is not None and hasattr(event_callback, 'stop'):
|
|
1090
|
+
try:
|
|
1091
|
+
event_callback.stop()
|
|
1092
|
+
logger.info(f"[清理Trader] 已停止事件路由器线程 | client={client_info}")
|
|
1093
|
+
except Exception as e:
|
|
1094
|
+
logger.warning(f"[清理Trader] 停止事件路由器线程失败: {e} | client={client_info}")
|
|
933
1095
|
|
|
934
1096
|
def _delayed_disconnect(self, delay: float = 0.5):
|
|
935
1097
|
"""延迟断开连接,确保异常能传输到客户端"""
|
|
@@ -976,6 +1138,16 @@ class XtQuantService(rpyc.Service):
|
|
|
976
1138
|
def exposed_heartbeat(self):
|
|
977
1139
|
return "pong"
|
|
978
1140
|
|
|
1141
|
+
@log_api_call("get_instance_id")
|
|
1142
|
+
def exposed_get_instance_id(self):
|
|
1143
|
+
"""返回本 server 进程的实例标识。
|
|
1144
|
+
|
|
1145
|
+
供客户端轻量感知 server 重启:该值在每次 server 进程启动时变化。
|
|
1146
|
+
客户端可周期性对比(例如复用现有 heartbeat 流程或单独轮询),
|
|
1147
|
+
一旦变化即说明订阅/回调等内存态已丢失,需要重新订阅。
|
|
1148
|
+
"""
|
|
1149
|
+
return _INSTANCE_ID
|
|
1150
|
+
|
|
979
1151
|
# ==================== 模块代理接口 ====================
|
|
980
1152
|
|
|
981
1153
|
@log_api_call("get_xtdata")
|
|
@@ -1098,13 +1270,13 @@ class XtQuantService(rpyc.Service):
|
|
|
1098
1270
|
|
|
1099
1271
|
logger.info(f"[创建Trader] userdata_path={userdata_path} | session_id={session_id}")
|
|
1100
1272
|
|
|
1101
|
-
# 记录 trader 实例,供 on_disconnect 时自动清理
|
|
1102
|
-
self._traders.append(trader)
|
|
1103
|
-
|
|
1104
1273
|
# 每个 trader 独立一个事件路由器,用于把 on_stock_* 推送给远端客户端
|
|
1105
1274
|
event_callback = _TraderEventCallback()
|
|
1106
1275
|
trader.register_callback(event_callback)
|
|
1107
1276
|
|
|
1277
|
+
# 记录 trader + 事件路由器,供 on_disconnect 时自动清理(含停止线程)
|
|
1278
|
+
self._traders.append((trader, event_callback))
|
|
1279
|
+
|
|
1108
1280
|
# 用 LoggingProxy 包装 trader,支持日志记录、序列化和事件回调透传
|
|
1109
1281
|
return LoggingProxy(
|
|
1110
1282
|
trader, 'xttrader',
|
|
@@ -1143,8 +1315,8 @@ class XtQuantService(rpyc.Service):
|
|
|
1143
1315
|
)
|
|
1144
1316
|
trader.register_callback(adapter)
|
|
1145
1317
|
|
|
1146
|
-
# 记录 trader
|
|
1147
|
-
self._traders.append(trader)
|
|
1318
|
+
# 记录 trader + 事件路由器,供 on_disconnect 时自动清理(含停止线程)
|
|
1319
|
+
self._traders.append((trader, event_callback))
|
|
1148
1320
|
|
|
1149
1321
|
return LoggingProxy(
|
|
1150
1322
|
trader, 'xttrader',
|
|
@@ -1176,18 +1348,23 @@ class XtQuantService(rpyc.Service):
|
|
|
1176
1348
|
|
|
1177
1349
|
logger.info(f"[创建并连接大QMT Trader] account_id={account_id} | session_id={session_id}")
|
|
1178
1350
|
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1351
|
+
try:
|
|
1352
|
+
start_result = trader.start()
|
|
1353
|
+
logger.info(f"[大QMT Trader] start() 返回: {start_result}")
|
|
1354
|
+
if start_result not in (0, None):
|
|
1355
|
+
raise RuntimeError(f"启动大QMT 交易线程失败,错误码: {start_result}")
|
|
1356
|
+
|
|
1357
|
+
# bigqmt connect() 同步:内部 RPC ping + 合成 on_account_status;失败抛异常
|
|
1358
|
+
connect_result = trader.connect()
|
|
1359
|
+
logger.info(f"[大QMT Trader] connect() 返回: {connect_result}")
|
|
1360
|
+
if connect_result not in (0, None):
|
|
1361
|
+
raise ConnectionError(f"大QMT 连接失败: 错误码 {connect_result}")
|
|
1362
|
+
except Exception:
|
|
1363
|
+
# 失败路径也要停止事件路由器线程,避免泄漏
|
|
1364
|
+
event_callback.stop()
|
|
1365
|
+
raise
|
|
1189
1366
|
|
|
1190
|
-
self._traders.append(trader)
|
|
1367
|
+
self._traders.append((trader, event_callback))
|
|
1191
1368
|
|
|
1192
1369
|
return LoggingProxy(
|
|
1193
1370
|
trader, 'xttrader',
|
|
@@ -1247,48 +1424,56 @@ class XtQuantService(rpyc.Service):
|
|
|
1247
1424
|
|
|
1248
1425
|
logger.info(f"[创建并连接Trader] userdata_path={userdata_path} | session_id={session_id}")
|
|
1249
1426
|
|
|
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
|
-
|
|
1427
|
+
try:
|
|
1428
|
+
start_result = trader.start()
|
|
1429
|
+
logger.info(f"[Trader] start() 返回: {start_result}")
|
|
1430
|
+
if start_result not in (0, None):
|
|
1431
|
+
raise RuntimeError(f"启动交易线程失败,错误码: {start_result}")
|
|
1432
|
+
|
|
1433
|
+
connect_result = trader.connect()
|
|
1434
|
+
logger.info(f"[Trader] connect() 返回: {connect_result}")
|
|
1435
|
+
if connect_result not in (0, None):
|
|
1436
|
+
error_codes = {
|
|
1437
|
+
-1: "交易服务器未连接",
|
|
1438
|
+
-2: "账号未登录",
|
|
1439
|
+
-3: "请求超时",
|
|
1440
|
+
-4: "资金账号不存在",
|
|
1441
|
+
}
|
|
1442
|
+
raise ConnectionError(f"连接失败: {error_codes.get(connect_result, f'错误码 {connect_result}')}")
|
|
1443
|
+
|
|
1444
|
+
# 等待真实的交易连接建立
|
|
1445
|
+
connected = callback.wait_for_connected(connect_timeout)
|
|
1446
|
+
logger.info(f"[Trader] on_connected 回调状态: {connected}")
|
|
1447
|
+
if not connected:
|
|
1448
|
+
# 某些版本的 xtquant 可能不会触发 on_connected 回调,
|
|
1449
|
+
# 尝试用 query_stock_asset 验证连接是否真正可用
|
|
1450
|
+
logger.info("[Trader] on_connected 未触发,尝试 query_stock_asset 验证连接...")
|
|
1451
|
+
try:
|
|
1452
|
+
from xtquant.xttype import StockAccount
|
|
1453
|
+
account_id = os.environ.get("QMT_ACCOUNT_ID")
|
|
1454
|
+
if account_id:
|
|
1455
|
+
test_account = StockAccount(account_id)
|
|
1456
|
+
asset = trader.query_stock_asset(test_account)
|
|
1457
|
+
logger.info(f"[Trader] query_stock_asset 验证成功: {asset}")
|
|
1458
|
+
connected = True
|
|
1459
|
+
except Exception as e:
|
|
1460
|
+
logger.warning(f"[Trader] query_stock_asset 验证失败: {e}")
|
|
1283
1461
|
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1462
|
+
if not connected:
|
|
1463
|
+
raise TimeoutError(
|
|
1464
|
+
f"等待交易连接建立超时({connect_timeout}秒),"
|
|
1465
|
+
"请检查 MiniQMT 是否已登录交易账号,或设置更长的 --connect-timeout"
|
|
1466
|
+
)
|
|
1467
|
+
except Exception:
|
|
1468
|
+
# 失败路径:停止 trader 与事件路由器线程,避免泄漏
|
|
1469
|
+
try:
|
|
1470
|
+
trader.stop()
|
|
1471
|
+
except Exception:
|
|
1472
|
+
pass
|
|
1473
|
+
callback.stop()
|
|
1474
|
+
raise
|
|
1290
1475
|
|
|
1291
|
-
self._traders.append(trader)
|
|
1476
|
+
self._traders.append((trader, callback))
|
|
1292
1477
|
|
|
1293
1478
|
return LoggingProxy(
|
|
1294
1479
|
trader, 'xttrader',
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: xtquant-share
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: Xtreme Quant Share - eXtreme transparent sharing proxy for XtQuant on macOS/Linux
|
|
5
5
|
Author-email: Jason Hu <63170682@qq.com>
|
|
6
6
|
License-Expression: GPL-3.0-only
|
|
@@ -50,6 +50,7 @@ Dynamic: license-file
|
|
|
50
50
|
- ✅ **认证加密** - 支持 HMAC token 认证,可选 SSL/TLS 加密
|
|
51
51
|
- ✅ **断线重连** - 自动检测断线并重连,指数退避策略
|
|
52
52
|
- ✅ **心跳保活** - 定期心跳检测,保持连接活跃
|
|
53
|
+
- ✅ **服务实例标识** - `get_instance_id()` 轻量感知 server 重启;行情订阅幂等自动去重,阻断底层 miniquote 订阅累积
|
|
53
54
|
- ✅ **异步回调** - 支持交易事件(on_stock_*)与行情 tick 订阅回调(`xtdata.register_quote_callback` + `subscribe_whole_quote`)
|
|
54
55
|
- ✅ **无感切换** - 大QMT ↔ miniQMT 无感切换(行情侧),自动探测 miniQMT 可用性,不可用自动切 bigqmt 通道(复用 xtquant_big_convert)
|
|
55
56
|
- ✅ **完整日志** - API调用日志,记录函数名、参数、耗时
|
|
@@ -801,6 +802,7 @@ print(status)
|
|
|
801
802
|
|
|
802
803
|
| 版本 | 日期 | 更新内容 |
|
|
803
804
|
|------|------|----------|
|
|
805
|
+
| 1.4.0 | 2026-10-03 | 新增 `get_instance_id()` 服务实例标识(轻量感知 server 重启);行情订阅幂等(重复订阅自动释放旧 seq,阻断 miniquote 订阅累积泄漏) |
|
|
804
806
|
| 1.1.1 | 2026-03-18 | 新增 xtview 模块支持(视图控制、调度任务管理),兼容不同 xtquant 版本 |
|
|
805
807
|
| 1.1.0 | 2026-03-17 | 交易功能优化完善、`xqshare-server` 命令、`.env` 配置支持、远程对象传输性能优化 |
|
|
806
808
|
| 1.0.4 | 2026-03-09 | JSON 输出优化:远程 DataFrame 高效序列化、`--compact` 参数、全局参数位置灵活、嵌套结构支持 |
|
|
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.4.0}/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
|