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.
Files changed (51) hide show
  1. {xtquant_share-1.3.0/xtquant_share.egg-info → xtquant_share-1.4.0}/PKG-INFO +3 -1
  2. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/README.md +2 -0
  3. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/pyproject.toml +1 -1
  4. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_quote_events.py +130 -3
  5. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_server.py +64 -0
  6. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/__init__.py +1 -1
  7. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/server.py +250 -65
  8. {xtquant_share-1.3.0 → xtquant_share-1.4.0/xtquant_share.egg-info}/PKG-INFO +3 -1
  9. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/LICENSE +0 -0
  10. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/setup.cfg +0 -0
  11. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_auth.py +0 -0
  12. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_channels_bigqmt.py +0 -0
  13. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_channels_health.py +0 -0
  14. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_channels_router.py +0 -0
  15. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_client.py +0 -0
  16. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_client_permission.py +0 -0
  17. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_integration.py +0 -0
  18. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_qmt_datadir_factor.py +0 -0
  19. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_remote_data.py +0 -0
  20. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_server_channel_routing.py +0 -0
  21. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_server_download_history_data2_compat.py +0 -0
  22. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_server_download_timeout.py +0 -0
  23. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_server_permission.py +0 -0
  24. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_tools_common.py +0 -0
  25. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/tests/test_trader_events.py +0 -0
  26. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/.env.example +0 -0
  27. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/auth.py +0 -0
  28. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/channels/__init__.py +0 -0
  29. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/channels/bigqmt.py +0 -0
  30. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/channels/health.py +0 -0
  31. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/channels/router.py +0 -0
  32. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/check_env.py +0 -0
  33. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/client.py +0 -0
  34. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/clients.yaml.example +0 -0
  35. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/qmt_datadir/__init__.py +0 -0
  36. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/qmt_datadir/divid.py +0 -0
  37. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/qmt_datadir/factor.py +0 -0
  38. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/qmt_datadir/kline.py +0 -0
  39. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/qmt_datadir/misc.py +0 -0
  40. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/qmt_datadir/reader.py +0 -0
  41. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/qmt_datadir/sector.py +0 -0
  42. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/qmt_watchdog.py +0 -0
  43. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/tools/__init__.py +0 -0
  44. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/tools/common.py +0 -0
  45. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/tools/xtdata.py +0 -0
  46. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xqshare/tools/xttrader.py +0 -0
  47. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xtquant_share.egg-info/SOURCES.txt +0 -0
  48. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xtquant_share.egg-info/dependency_links.txt +0 -0
  49. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xtquant_share.egg-info/entry_points.txt +0 -0
  50. {xtquant_share-1.3.0 → xtquant_share-1.4.0}/xtquant_share.egg-info/requires.txt +0 -0
  51. {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.0
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.3.0"
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 and next(iter(qec._subscribers.values())) is client_cb, \
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"])
@@ -4,7 +4,7 @@ XtQuant Share (xqshare) - Transparent remote proxy for xtquant library
4
4
  Allows using xtquant on macOS/Linux by proxying calls to a Windows server.
5
5
  """
6
6
 
7
- __version__ = "1.3.0"
7
+ __version__ = "1.4.0"
8
8
  __author__ = "Jason Hu"
9
9
 
10
10
  from .client import (
@@ -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
- self._subscribers = {} # id(client_callback_netref) -> client_callback_netref
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 = list(self._subscribers.values())
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
- self._subscribers[id(client_callback)] = client_callback
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
- # 自动清理本次连接创建的所有 trader 实例,防止 session 资源泄漏
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 实例,供 on_disconnect 时自动清理
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
- start_result = trader.start()
1180
- logger.info(f"[大QMT Trader] start() 返回: {start_result}")
1181
- if start_result not in (0, None):
1182
- raise RuntimeError(f"启动大QMT 交易线程失败,错误码: {start_result}")
1183
-
1184
- # bigqmt connect() 同步:内部 RPC ping + 合成 on_account_status;失败抛异常
1185
- connect_result = trader.connect()
1186
- logger.info(f"[大QMT Trader] connect() 返回: {connect_result}")
1187
- if connect_result not in (0, None):
1188
- raise ConnectionError(f"大QMT 连接失败: 错误码 {connect_result}")
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
- start_result = trader.start()
1251
- logger.info(f"[Trader] start() 返回: {start_result}")
1252
- if start_result not in (0, None):
1253
- raise RuntimeError(f"启动交易线程失败,错误码: {start_result}")
1254
-
1255
- connect_result = trader.connect()
1256
- logger.info(f"[Trader] connect() 返回: {connect_result}")
1257
- if connect_result not in (0, None):
1258
- error_codes = {
1259
- -1: "交易服务器未连接",
1260
- -2: "账号未登录",
1261
- -3: "请求超时",
1262
- -4: "资金账号不存在",
1263
- }
1264
- raise ConnectionError(f"连接失败: {error_codes.get(connect_result, f'错误码 {connect_result}')}")
1265
-
1266
- # 等待真实的交易连接建立
1267
- connected = callback.wait_for_connected(connect_timeout)
1268
- logger.info(f"[Trader] on_connected 回调状态: {connected}")
1269
- if not connected:
1270
- # 某些版本的 xtquant 可能不会触发 on_connected 回调,
1271
- # 尝试用 query_stock_asset 验证连接是否真正可用
1272
- logger.info("[Trader] on_connected 未触发,尝试 query_stock_asset 验证连接...")
1273
- try:
1274
- from xtquant.xttype import StockAccount
1275
- account_id = os.environ.get("QMT_ACCOUNT_ID")
1276
- if account_id:
1277
- test_account = StockAccount(account_id)
1278
- asset = trader.query_stock_asset(test_account)
1279
- logger.info(f"[Trader] query_stock_asset 验证成功: {asset}")
1280
- connected = True
1281
- except Exception as e:
1282
- logger.warning(f"[Trader] query_stock_asset 验证失败: {e}")
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
- if not connected:
1285
- trader.stop()
1286
- raise TimeoutError(
1287
- f"等待交易连接建立超时({connect_timeout}秒),"
1288
- "请检查 MiniQMT 是否已登录交易账号,或设置更长的 --connect-timeout"
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.0
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