xtquant-share 1.2.41__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.
Files changed (51) hide show
  1. {xtquant_share-1.2.41/xtquant_share.egg-info → xtquant_share-1.3.1}/PKG-INFO +50 -1
  2. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/README.md +45 -0
  3. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/pyproject.toml +6 -1
  4. xtquant_share-1.3.1/tests/test_channels_bigqmt.py +127 -0
  5. xtquant_share-1.3.1/tests/test_channels_health.py +64 -0
  6. xtquant_share-1.3.1/tests/test_channels_router.py +102 -0
  7. xtquant_share-1.3.1/tests/test_qmt_datadir_factor.py +112 -0
  8. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/tests/test_quote_events.py +82 -3
  9. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/tests/test_server.py +43 -0
  10. xtquant_share-1.3.1/tests/test_server_channel_routing.py +90 -0
  11. xtquant_share-1.3.1/tests/test_server_download_timeout.py +128 -0
  12. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/__init__.py +1 -1
  13. xtquant_share-1.3.1/xqshare/channels/__init__.py +29 -0
  14. xtquant_share-1.3.1/xqshare/channels/bigqmt.py +239 -0
  15. xtquant_share-1.3.1/xqshare/channels/health.py +140 -0
  16. xtquant_share-1.3.1/xqshare/channels/router.py +179 -0
  17. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/client.py +21 -0
  18. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/qmt_datadir/__init__.py +7 -0
  19. xtquant_share-1.3.1/xqshare/qmt_datadir/factor.py +148 -0
  20. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/qmt_datadir/reader.py +34 -0
  21. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/server.py +381 -62
  22. {xtquant_share-1.2.41 → xtquant_share-1.3.1/xtquant_share.egg-info}/PKG-INFO +50 -1
  23. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xtquant_share.egg-info/SOURCES.txt +11 -0
  24. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xtquant_share.egg-info/requires.txt +5 -0
  25. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/LICENSE +0 -0
  26. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/setup.cfg +0 -0
  27. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/tests/test_auth.py +0 -0
  28. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/tests/test_client.py +0 -0
  29. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/tests/test_client_permission.py +0 -0
  30. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/tests/test_integration.py +0 -0
  31. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/tests/test_remote_data.py +0 -0
  32. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/tests/test_server_download_history_data2_compat.py +0 -0
  33. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/tests/test_server_permission.py +0 -0
  34. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/tests/test_tools_common.py +0 -0
  35. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/tests/test_trader_events.py +0 -0
  36. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/.env.example +0 -0
  37. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/auth.py +0 -0
  38. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/check_env.py +0 -0
  39. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/clients.yaml.example +0 -0
  40. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/qmt_datadir/divid.py +0 -0
  41. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/qmt_datadir/kline.py +0 -0
  42. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/qmt_datadir/misc.py +0 -0
  43. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/qmt_datadir/sector.py +0 -0
  44. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/qmt_watchdog.py +0 -0
  45. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/tools/__init__.py +0 -0
  46. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/tools/common.py +0 -0
  47. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/tools/xtdata.py +0 -0
  48. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/tools/xttrader.py +0 -0
  49. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xtquant_share.egg-info/dependency_links.txt +0 -0
  50. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xtquant_share.egg-info/entry_points.txt +0 -0
  51. {xtquant_share-1.2.41 → xtquant_share-1.3.1}/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.2.41
3
+ Version: 1.3.1
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
@@ -28,12 +28,16 @@ License-File: LICENSE
28
28
  Requires-Dist: rpyc>=6.0
29
29
  Requires-Dist: python-dotenv>=1.0
30
30
  Requires-Dist: pyyaml>=6.0
31
+ Requires-Dist: psutil>=5.9
31
32
  Provides-Extra: dev
32
33
  Requires-Dist: pytest>=7.0; extra == "dev"
33
34
  Requires-Dist: black>=23.0; extra == "dev"
34
35
  Requires-Dist: flake8>=6.0; extra == "dev"
35
36
  Provides-Extra: datadir
36
37
  Requires-Dist: pandas>=1.0; extra == "datadir"
38
+ Requires-Dist: pyarrow>=6.0; extra == "datadir"
39
+ Provides-Extra: bigqmt
40
+ Requires-Dist: xtquant-big-convert[redis]; extra == "bigqmt"
37
41
  Dynamic: license-file
38
42
 
39
43
  # 极限量化 (xqshare)
@@ -47,6 +51,7 @@ Dynamic: license-file
47
51
  - ✅ **断线重连** - 自动检测断线并重连,指数退避策略
48
52
  - ✅ **心跳保活** - 定期心跳检测,保持连接活跃
49
53
  - ✅ **异步回调** - 支持交易事件(on_stock_*)与行情 tick 订阅回调(`xtdata.register_quote_callback` + `subscribe_whole_quote`)
54
+ - ✅ **无感切换** - 大QMT ↔ miniQMT 无感切换(行情侧),自动探测 miniQMT 可用性,不可用自动切 bigqmt 通道(复用 xtquant_big_convert)
50
55
  - ✅ **完整日志** - API调用日志,记录函数名、参数、耗时
51
56
  - ✅ **零学习成本** - 无需记忆新 API
52
57
 
@@ -90,6 +95,8 @@ pip install -e .
90
95
  pip install rpyc
91
96
  ```
92
97
 
98
+ > **datadir 文件解析功能**需要额外依赖:`pip install "xqshare[datadir]"`(含 `pandas` + `pyarrow`,其中 `pyarrow` 用于因子库 feather 文件解析)。
99
+
93
100
  ## 快速启动
94
101
 
95
102
  ### 启动前准备
@@ -316,6 +323,17 @@ with XtQuantRemote("21.214.136.216", client_secret="my-secret") as xt:
316
323
  # 获取单个板块成分股
317
324
  bank_stocks = xt.datadir.sector("申万行业", "SW1银行")
318
325
  print(f"银行板块:{len(bank_stocks)} 只")
326
+
327
+ # 列出可用因子表
328
+ classes = xt.datadir.factor_classes() # ['factor_growth', 'factor_metrics', ...]
329
+
330
+ # 读取因子库数据(feather 文件,需服务端装 pyarrow)
331
+ factor_df = xt.datadir.factors(
332
+ "factor_growth",
333
+ symbols=["600000.SH"],
334
+ start_date="20240101",
335
+ end_date="20241231",
336
+ )
319
337
  ```
320
338
 
321
339
  ### 全局便捷方式
@@ -338,6 +356,7 @@ df = xqshare.datadir.kline("600000.SH", "1d")
338
356
  | 支持实时行情 | ✅ 支持 | ❌ 不支持 |
339
357
  | 支持历史 K 线 | ✅ 支持 | ✅ 支持 |
340
358
  | 支持板块成分股 | ✅ 支持 | ✅ 支持 |
359
+ | 支持因子库 | 在线接口 | ✅ 支持(feather 解析,需 `pyarrow`) |
341
360
  | 适用场景 | 实时/在线场景 | 离线/备用/批量场景 |
342
361
 
343
362
  ### datadir 不可用时的处理
@@ -503,6 +522,36 @@ xt = XtQuantRemote("21.214.136.216", log_level="DEBUG")
503
522
  | --key | SSL 私钥文件 | - |
504
523
  | --log-level | 日志级别 | INFO |
505
524
 
525
+ ### 下载超时
526
+
527
+ 盘后券商(如平安 QMT)挂起时,`download_*` 类调用会在服务端永久阻塞 worker 线程,
528
+ 堵死后续所有下载请求。服务端对下载类调用统一加了超时隔离(独立 daemon 线程 + 超时等待),
529
+ 超过阈值后放弃等待并抛 `TimeoutError`,避免挂起堵死后续请求。
530
+
531
+ | 环境变量 | 默认值 | 说明 |
532
+ |---------|--------|------|
533
+ | `XQSHARE_DOWNLOAD_TIMEOUT` | `600` | `download_*` 调用超时阈值(秒) |
534
+
535
+ > 注意:超时后底层 xtquant C 库调用无法被 Python 强制中断,挂起的 daemon 线程会残留,
536
+ > 直到 QMT/xqshare 进程重启才回收。详见 [doc/download-timeout-gap.md](doc/download-timeout-gap.md)。
537
+
538
+ ### 通道切换(大QMT ↔ miniQMT 无感切换)
539
+
540
+ | 环境变量 | 默认值 | 说明 |
541
+ |---------|--------|------|
542
+ | `XQSHARE_BIGQMT_ENABLED` | `false` | 是否启用 bigqmt 通道(mini 不可用时的路由候选) |
543
+ | `XQSHARE_BIGQMT_ALLOW_ORDER` | `false` | 是否允许 bigqmt 通道下单(双重门禁之一) |
544
+ | `XQSHARE_FORCE_CHANNEL` | 空 | 强制通道:空=自动探测,可选 `mini` / `bigqmt` |
545
+ | `XQSHARE_PROBE_INTERVAL` | `30` | 健康探测间隔(秒) |
546
+ | `BIGQMT_ACCOUNT_ID` | — | 大QMT 资金账号(透传给 bigqmt 桥接) |
547
+ | `BIGQMT_REDIS_HOST` | — | Redis 主机 |
548
+ | `BIGQMT_REDIS_PORT` | — | Redis 端口 |
549
+ | `BIGQMT_REDIS_DB` | — | Redis DB 编号 |
550
+ | `BIGQMT_REDIS_PASSWORD` | — | Redis 密码(可选) |
551
+ | `BIGQMT_REDIS_USERNAME` | — | Redis 用户名(可选) |
552
+
553
+ > 通道抽象层设计见 [doc/channel-abstraction.md](doc/channel-abstraction.md),大QMT 端部署见 [doc/bigqmt-bridge-deploy.md](doc/bigqmt-bridge-deploy.md)。
554
+
506
555
  ---
507
556
 
508
557
  ## 认证机制
@@ -9,6 +9,7 @@
9
9
  - ✅ **断线重连** - 自动检测断线并重连,指数退避策略
10
10
  - ✅ **心跳保活** - 定期心跳检测,保持连接活跃
11
11
  - ✅ **异步回调** - 支持交易事件(on_stock_*)与行情 tick 订阅回调(`xtdata.register_quote_callback` + `subscribe_whole_quote`)
12
+ - ✅ **无感切换** - 大QMT ↔ miniQMT 无感切换(行情侧),自动探测 miniQMT 可用性,不可用自动切 bigqmt 通道(复用 xtquant_big_convert)
12
13
  - ✅ **完整日志** - API调用日志,记录函数名、参数、耗时
13
14
  - ✅ **零学习成本** - 无需记忆新 API
14
15
 
@@ -52,6 +53,8 @@ pip install -e .
52
53
  pip install rpyc
53
54
  ```
54
55
 
56
+ > **datadir 文件解析功能**需要额外依赖:`pip install "xqshare[datadir]"`(含 `pandas` + `pyarrow`,其中 `pyarrow` 用于因子库 feather 文件解析)。
57
+
55
58
  ## 快速启动
56
59
 
57
60
  ### 启动前准备
@@ -278,6 +281,17 @@ with XtQuantRemote("21.214.136.216", client_secret="my-secret") as xt:
278
281
  # 获取单个板块成分股
279
282
  bank_stocks = xt.datadir.sector("申万行业", "SW1银行")
280
283
  print(f"银行板块:{len(bank_stocks)} 只")
284
+
285
+ # 列出可用因子表
286
+ classes = xt.datadir.factor_classes() # ['factor_growth', 'factor_metrics', ...]
287
+
288
+ # 读取因子库数据(feather 文件,需服务端装 pyarrow)
289
+ factor_df = xt.datadir.factors(
290
+ "factor_growth",
291
+ symbols=["600000.SH"],
292
+ start_date="20240101",
293
+ end_date="20241231",
294
+ )
281
295
  ```
282
296
 
283
297
  ### 全局便捷方式
@@ -300,6 +314,7 @@ df = xqshare.datadir.kline("600000.SH", "1d")
300
314
  | 支持实时行情 | ✅ 支持 | ❌ 不支持 |
301
315
  | 支持历史 K 线 | ✅ 支持 | ✅ 支持 |
302
316
  | 支持板块成分股 | ✅ 支持 | ✅ 支持 |
317
+ | 支持因子库 | 在线接口 | ✅ 支持(feather 解析,需 `pyarrow`) |
303
318
  | 适用场景 | 实时/在线场景 | 离线/备用/批量场景 |
304
319
 
305
320
  ### datadir 不可用时的处理
@@ -465,6 +480,36 @@ xt = XtQuantRemote("21.214.136.216", log_level="DEBUG")
465
480
  | --key | SSL 私钥文件 | - |
466
481
  | --log-level | 日志级别 | INFO |
467
482
 
483
+ ### 下载超时
484
+
485
+ 盘后券商(如平安 QMT)挂起时,`download_*` 类调用会在服务端永久阻塞 worker 线程,
486
+ 堵死后续所有下载请求。服务端对下载类调用统一加了超时隔离(独立 daemon 线程 + 超时等待),
487
+ 超过阈值后放弃等待并抛 `TimeoutError`,避免挂起堵死后续请求。
488
+
489
+ | 环境变量 | 默认值 | 说明 |
490
+ |---------|--------|------|
491
+ | `XQSHARE_DOWNLOAD_TIMEOUT` | `600` | `download_*` 调用超时阈值(秒) |
492
+
493
+ > 注意:超时后底层 xtquant C 库调用无法被 Python 强制中断,挂起的 daemon 线程会残留,
494
+ > 直到 QMT/xqshare 进程重启才回收。详见 [doc/download-timeout-gap.md](doc/download-timeout-gap.md)。
495
+
496
+ ### 通道切换(大QMT ↔ miniQMT 无感切换)
497
+
498
+ | 环境变量 | 默认值 | 说明 |
499
+ |---------|--------|------|
500
+ | `XQSHARE_BIGQMT_ENABLED` | `false` | 是否启用 bigqmt 通道(mini 不可用时的路由候选) |
501
+ | `XQSHARE_BIGQMT_ALLOW_ORDER` | `false` | 是否允许 bigqmt 通道下单(双重门禁之一) |
502
+ | `XQSHARE_FORCE_CHANNEL` | 空 | 强制通道:空=自动探测,可选 `mini` / `bigqmt` |
503
+ | `XQSHARE_PROBE_INTERVAL` | `30` | 健康探测间隔(秒) |
504
+ | `BIGQMT_ACCOUNT_ID` | — | 大QMT 资金账号(透传给 bigqmt 桥接) |
505
+ | `BIGQMT_REDIS_HOST` | — | Redis 主机 |
506
+ | `BIGQMT_REDIS_PORT` | — | Redis 端口 |
507
+ | `BIGQMT_REDIS_DB` | — | Redis DB 编号 |
508
+ | `BIGQMT_REDIS_PASSWORD` | — | Redis 密码(可选) |
509
+ | `BIGQMT_REDIS_USERNAME` | — | Redis 用户名(可选) |
510
+
511
+ > 通道抽象层设计见 [doc/channel-abstraction.md](doc/channel-abstraction.md),大QMT 端部署见 [doc/bigqmt-bridge-deploy.md](doc/bigqmt-bridge-deploy.md)。
512
+
468
513
  ---
469
514
 
470
515
  ## 认证机制
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "xtquant-share"
7
- version = "1.2.41"
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"
@@ -42,6 +42,7 @@ dependencies = [
42
42
  "rpyc>=6.0",
43
43
  "python-dotenv>=1.0",
44
44
  "pyyaml>=6.0",
45
+ "psutil>=5.9",
45
46
  ]
46
47
 
47
48
  [project.optional-dependencies]
@@ -52,6 +53,10 @@ dev = [
52
53
  ]
53
54
  datadir = [
54
55
  "pandas>=1.0",
56
+ "pyarrow>=6.0",
57
+ ]
58
+ bigqmt = [
59
+ "xtquant-big-convert[redis]",
55
60
  ]
56
61
 
57
62
  [project.scripts]
@@ -0,0 +1,127 @@
1
+ # -*- coding: utf-8 -*-
2
+ """大QMT 通道封装(bigqmt.py)单元测试:回调适配 + 下单闸门。"""
3
+ import pytest
4
+
5
+ from xqshare.channels.bigqmt import (
6
+ BIGQMT_AVAILABLE,
7
+ BigQmtCallbackAdapter,
8
+ BigQmtChannel,
9
+ _GuardedTrader,
10
+ _SyntheticEvent,
11
+ order_allowed,
12
+ )
13
+
14
+
15
+ class TestOrderAllowed:
16
+ def test_default_false(self, monkeypatch):
17
+ monkeypatch.delenv("XQSHARE_BIGQMT_ALLOW_ORDER", raising=False)
18
+ assert order_allowed() is False
19
+
20
+ def test_true(self, monkeypatch):
21
+ monkeypatch.setenv("XQSHARE_BIGQMT_ALLOW_ORDER", "true")
22
+ assert order_allowed() is True
23
+
24
+
25
+ class TestGuardedTrader:
26
+ def test_read_passthrough(self):
27
+ trader = _SyntheticEvent(query_stock_asset=lambda acc: "asset")
28
+ guarded = _GuardedTrader(trader)
29
+ assert guarded.query_stock_asset("acc") == "asset"
30
+
31
+ def test_write_blocked_when_disallowed(self, monkeypatch):
32
+ monkeypatch.setenv("XQSHARE_BIGQMT_ALLOW_ORDER", "false")
33
+ trader = _SyntheticEvent()
34
+ guarded = _GuardedTrader(trader)
35
+ with pytest.raises(RuntimeError):
36
+ guarded.order_stock("acc", "600000.SH", 23, 100, 11, 10.0, "s", "r")
37
+
38
+ def test_cancel_blocked_when_disallowed(self, monkeypatch):
39
+ monkeypatch.setenv("XQSHARE_BIGQMT_ALLOW_ORDER", "false")
40
+ trader = _SyntheticEvent()
41
+ guarded = _GuardedTrader(trader)
42
+ with pytest.raises(RuntimeError):
43
+ guarded.cancel_order_stock("acc", "SYS1")
44
+
45
+ def test_write_allowed_when_enabled(self, monkeypatch):
46
+ monkeypatch.setenv("XQSHARE_BIGQMT_ALLOW_ORDER", "true")
47
+ trader = _SyntheticEvent(order_stock=lambda *a, **k: "orderid")
48
+ guarded = _GuardedTrader(trader)
49
+ assert guarded.order_stock("acc", "600000.SH", 23, 100, 11, 10.0, "s", "r") == "orderid"
50
+
51
+
52
+ class TestCallbackAdapter:
53
+ def _make(self):
54
+ from unittest.mock import MagicMock
55
+ server_cb = MagicMock()
56
+ adapter = BigQmtCallbackAdapter(server_cb, account_id="ACC123")
57
+ return adapter, server_cb
58
+
59
+ def test_on_stock_order_passthrough(self):
60
+ adapter, server_cb = self._make()
61
+ order = _SyntheticEvent(account_id="ACC123", stock_code="600000.SH")
62
+ adapter.on_stock_order(order)
63
+ server_cb.on_stock_order.assert_called_once_with(order)
64
+
65
+ def test_on_stock_trade_passthrough(self):
66
+ adapter, server_cb = self._make()
67
+ trade = _SyntheticEvent(account_id="ACC123")
68
+ adapter.on_stock_trade(trade)
69
+ server_cb.on_stock_trade.assert_called_once_with(trade)
70
+
71
+ def test_on_order_error_synthesizes_junk_order(self):
72
+ adapter, server_cb = self._make()
73
+ err = _SyntheticEvent(
74
+ stock_code="600000.SH", order_sysid="SYS1", order_id="SYS1",
75
+ strategy_name="s", order_remark="r", error_msg="拒单",
76
+ )
77
+ adapter.on_order_error(err)
78
+ server_cb.on_stock_order.assert_called_once()
79
+ order = server_cb.on_stock_order.call_args[0][0]
80
+ assert order.account_id == "ACC123"
81
+ assert order.stock_code == "600000.SH"
82
+ assert order.order_status == 57
83
+ assert order.status_msg == "拒单"
84
+
85
+ def test_on_cancel_error_synthesizes_junk_order(self):
86
+ adapter, server_cb = self._make()
87
+ err = _SyntheticEvent(order_sysid="SYS2", error_msg="撤单被拒")
88
+ adapter.on_cancel_error(err)
89
+ server_cb.on_stock_order.assert_called_once()
90
+ order = server_cb.on_stock_order.call_args[0][0]
91
+ assert order.order_status == 57
92
+ assert "撤单失败" in order.status_msg
93
+
94
+ def test_on_account_status_not_forwarded(self):
95
+ adapter, server_cb = self._make()
96
+ adapter.on_account_status(_SyntheticEvent(account_id="ACC123", status=1))
97
+ server_cb.on_stock_order.assert_not_called()
98
+ server_cb.on_stock_trade.assert_not_called()
99
+
100
+ def test_on_async_response_not_forwarded(self):
101
+ adapter, server_cb = self._make()
102
+ adapter.on_order_stock_async_response(_SyntheticEvent(seq=1))
103
+ server_cb.on_stock_order.assert_not_called()
104
+
105
+
106
+ class TestBigQmtChannel:
107
+ def test_enabled_false_when_lib_missing(self, monkeypatch):
108
+ monkeypatch.setenv("XQSHARE_BIGQMT_ENABLED", "true")
109
+ if BIGQMT_AVAILABLE:
110
+ pytest.skip("xtquant_big_convert 已安装,跳过未安装场景")
111
+ assert BigQmtChannel.enabled() is False
112
+
113
+ def test_enabled_requires_flag(self, monkeypatch):
114
+ monkeypatch.delenv("XQSHARE_BIGQMT_ENABLED", raising=False)
115
+ assert BigQmtChannel.enabled() is False
116
+
117
+ def test_data_source_none_when_disabled(self, monkeypatch):
118
+ monkeypatch.delenv("XQSHARE_BIGQMT_ENABLED", raising=False)
119
+ assert BigQmtChannel.data_source() is None
120
+
121
+ def test_ping_false_when_disabled(self, monkeypatch):
122
+ monkeypatch.delenv("XQSHARE_BIGQMT_ENABLED", raising=False)
123
+ assert BigQmtChannel.ping() is False
124
+
125
+
126
+ if __name__ == "__main__":
127
+ pytest.main([__file__, "-v"])
@@ -0,0 +1,64 @@
1
+ # -*- coding: utf-8 -*-
2
+ """通道健康探测(health.py)单元测试。"""
3
+ import pytest
4
+
5
+ from xqshare.channels.health import ChannelHealth, HealthProbe
6
+
7
+
8
+ class TestChannelHealth:
9
+ def test_mode_mini_priority(self):
10
+ h = ChannelHealth(mini_available=True, bigqmt_available=True)
11
+ assert h.mode == "mini"
12
+
13
+ def test_mode_bigqmt(self):
14
+ h = ChannelHealth(mini_available=False, bigqmt_available=True)
15
+ assert h.mode == "bigqmt"
16
+
17
+ def test_mode_none(self):
18
+ h = ChannelHealth()
19
+ assert h.mode == "none"
20
+
21
+ def test_to_dict(self):
22
+ h = ChannelHealth(mini_available=True, checked_at=123.0, detail={"a": 1})
23
+ d = h.to_dict()
24
+ assert d["mode"] == "mini"
25
+ assert d["mini_available"] is True
26
+ assert d["bigqmt_available"] is False
27
+ assert d["checked_at"] == 123.0
28
+ assert d["detail"] == {"a": 1}
29
+
30
+
31
+ class TestHealthProbe:
32
+ def test_probe_delegates(self):
33
+ probe = HealthProbe(
34
+ mini_probe_func=lambda: True,
35
+ bigqmt_probe_func=lambda: False,
36
+ )
37
+ h = probe.probe()
38
+ assert h.mini_available is True
39
+ assert h.bigqmt_available is False
40
+ assert h.mode == "mini"
41
+ assert h.checked_at > 0
42
+
43
+ def test_probe_exception_swallowed(self):
44
+ def boom():
45
+ raise RuntimeError("probe failed")
46
+
47
+ probe = HealthProbe(mini_probe_func=boom, bigqmt_probe_func=lambda: False)
48
+ h = probe.probe()
49
+ assert h.mini_available is False
50
+ assert h.mode == "none"
51
+
52
+ def test_probe_both_true_mode_mini(self):
53
+ probe = HealthProbe(
54
+ mini_probe_func=lambda: True,
55
+ bigqmt_probe_func=lambda: True,
56
+ )
57
+ h = probe.probe()
58
+ assert h.mini_available is True
59
+ assert h.bigqmt_available is True
60
+ assert h.mode == "mini" # mini 优先
61
+
62
+
63
+ if __name__ == "__main__":
64
+ pytest.main([__file__, "-v"])
@@ -0,0 +1,102 @@
1
+ # -*- coding: utf-8 -*-
2
+ """通道路由(router.py)单元测试:抖动保护 + 路由决策 + 强制通道。"""
3
+ import pytest
4
+
5
+ from xqshare.channels.health import ChannelHealth, HealthProbe
6
+ from xqshare.channels.router import ChannelRouter
7
+
8
+
9
+ class FakeProbe:
10
+ """按队列顺序返回预设探测结果。"""
11
+
12
+ def __init__(self, results):
13
+ self._results = list(results)
14
+
15
+ def probe(self):
16
+ return self._results.pop(0)
17
+
18
+
19
+ def _health(mini, bigqmt):
20
+ return ChannelHealth(mini_available=mini, bigqmt_available=bigqmt, checked_at=1.0)
21
+
22
+
23
+ class TestJitterProtection:
24
+ def test_switch_after_threshold(self):
25
+ router = ChannelRouter(
26
+ probe=FakeProbe([_health(False, True), _health(False, True)]),
27
+ switch_threshold=2,
28
+ )
29
+ router._probe_once()
30
+ assert router.snapshot()["mode"] == "mini" # 第一次,未达阈值
31
+ router._probe_once()
32
+ assert router.snapshot()["mode"] == "bigqmt" # 第二次,切换
33
+
34
+ def test_no_switch_on_single_flap(self):
35
+ router = ChannelRouter(
36
+ probe=FakeProbe([_health(False, True), _health(True, False)]),
37
+ switch_threshold=2,
38
+ )
39
+ router._probe_once()
40
+ router._probe_once()
41
+ assert router.snapshot()["mode"] == "mini" # 抖动,未切换
42
+
43
+ def test_switch_back(self):
44
+ router = ChannelRouter(
45
+ probe=FakeProbe([
46
+ _health(False, True), _health(False, True), # mini -> bigqmt
47
+ _health(True, False), _health(True, False), # bigqmt -> mini
48
+ ]),
49
+ switch_threshold=2,
50
+ )
51
+ router._probe_once()
52
+ router._probe_once()
53
+ assert router.snapshot()["mode"] == "bigqmt"
54
+ router._probe_once()
55
+ router._probe_once()
56
+ assert router.snapshot()["mode"] == "mini"
57
+
58
+
59
+ class TestSnapshot:
60
+ def test_snapshot_structure(self):
61
+ router = ChannelRouter(probe=FakeProbe([_health(True, False)]), switch_threshold=1)
62
+ router._probe_once()
63
+ snap = router.snapshot()
64
+ assert set(snap.keys()) == {
65
+ "mode", "mini_available", "bigqmt_available", "checked_at", "detail",
66
+ }
67
+ assert snap["mode"] == "mini"
68
+ assert snap["mini_available"] is True
69
+
70
+
71
+ class TestTraderMode:
72
+ def test_trader_mode_follows_current_mode(self):
73
+ router = ChannelRouter(
74
+ probe=FakeProbe([_health(False, True), _health(False, True)]),
75
+ switch_threshold=2,
76
+ )
77
+ router._probe_once()
78
+ router._probe_once()
79
+ assert router.trader_mode() == "bigqmt"
80
+
81
+ def test_trader_mode_default_mini(self):
82
+ router = ChannelRouter(probe=FakeProbe([_health(False, False)]), switch_threshold=1)
83
+ router._probe_once()
84
+ assert router.trader_mode() == "mini" # 双通道不可用回落 mini
85
+
86
+
87
+ class TestForceChannel:
88
+ def test_force_bigqmt_trader_mode(self, monkeypatch):
89
+ monkeypatch.setenv("XQSHARE_FORCE_CHANNEL", "bigqmt")
90
+ router = ChannelRouter(probe=FakeProbe([_health(True, False)]), switch_threshold=1)
91
+ router._probe_once()
92
+ assert router.trader_mode() == "bigqmt"
93
+
94
+ def test_force_mini_trader_mode(self, monkeypatch):
95
+ monkeypatch.setenv("XQSHARE_FORCE_CHANNEL", "mini")
96
+ router = ChannelRouter(probe=FakeProbe([_health(False, True)]), switch_threshold=1)
97
+ router._probe_once()
98
+ assert router.trader_mode() == "mini"
99
+
100
+
101
+ if __name__ == "__main__":
102
+ pytest.main([__file__, "-v"])
@@ -0,0 +1,112 @@
1
+ # -*- coding: utf-8 -*-
2
+ """qmt_datadir 因子库(factor.py)单元测试。"""
3
+ import os
4
+
5
+ import pandas as pd
6
+ import pytest
7
+
8
+ pyarrow = pytest.importorskip("pyarrow")
9
+ import pyarrow as pa
10
+ import pyarrow.feather as feather
11
+
12
+ from xqshare.qmt_datadir import (
13
+ QmtDataReader,
14
+ read_factor,
15
+ list_factor_classes,
16
+ )
17
+
18
+ # 2024-01-01 / 2024-01-02 / 2024-01-03 的毫秒时间戳(UTC)
19
+ _TS = {
20
+ '20240101': 1704067200000,
21
+ '20240102': 1704153600000,
22
+ '20240103': 1704240000000,
23
+ }
24
+
25
+
26
+ def _write_feather(path, times_ms, stocks, values):
27
+ table = pa.table({
28
+ '_time': pa.array(times_ms, type=pa.int64()),
29
+ '_stock': pa.array(stocks, type=pa.string()),
30
+ 'value': pa.array(values, type=pa.float64()),
31
+ })
32
+ feather.write_feather(table, path)
33
+
34
+
35
+ def _make_datadir(tmp_path, factor_class='factor_growth',
36
+ times=None, stocks=None, values=None):
37
+ """构造最小 datadir:EP/{factor_class}_Xdat2/data.fe"""
38
+ times = times or [_TS['20240101'], _TS['20240102'], _TS['20240103']]
39
+ stocks = stocks or ['600000.SH', '000001.SZ', '600000.SH']
40
+ values = values or [1.0, 2.0, 3.0]
41
+ xdat2 = tmp_path / 'EP' / f'{factor_class}_Xdat2'
42
+ xdat2.mkdir(parents=True)
43
+ _write_feather(str(xdat2 / 'data.fe'), times, stocks, values)
44
+ return str(tmp_path)
45
+
46
+
47
+ class TestReadFactor:
48
+ def test_basic_schema_normalize(self, tmp_path):
49
+ """_time→date、_stock→symbol 标准化,值保留"""
50
+ data_dir = _make_datadir(tmp_path)
51
+ df = read_factor(data_dir, 'factor_growth')
52
+ assert list(df.columns)[:2] == ['symbol', 'date']
53
+ assert df['symbol'].tolist() == ['600000.SH', '000001.SZ', '600000.SH']
54
+ assert df['value'].tolist() == [1.0, 2.0, 3.0]
55
+ # date 应为 datetime
56
+ assert pd.api.types.is_datetime64_any_dtype(df['date'])
57
+
58
+ def test_symbols_filter_full_code(self, tmp_path):
59
+ data_dir = _make_datadir(tmp_path)
60
+ df = read_factor(data_dir, 'factor_growth', symbols=['000001.SZ'])
61
+ assert df['symbol'].unique().tolist() == ['000001.SZ']
62
+
63
+ def test_symbols_filter_pure_code(self, tmp_path):
64
+ """纯 6 位代码也能匹配(兼容 600000 → 600000.SH/SZ)"""
65
+ data_dir = _make_datadir(tmp_path)
66
+ df = read_factor(data_dir, 'factor_growth', symbols=['600000'])
67
+ assert df['symbol'].unique().tolist() == ['600000.SH']
68
+
69
+ def test_date_filter(self, tmp_path):
70
+ data_dir = _make_datadir(tmp_path)
71
+ df = read_factor(data_dir, 'factor_growth',
72
+ start_date='20240102', end_date='20240103')
73
+ dates = pd.to_datetime(df['date']).dt.strftime('%Y%m%d').unique().tolist()
74
+ assert dates == ['20240102', '20240103']
75
+
76
+ def test_missing_feather_raises(self, tmp_path):
77
+ """data.fe 不存在时抛 FileNotFoundError"""
78
+ ep = tmp_path / 'EP'
79
+ ep.mkdir()
80
+ with pytest.raises(FileNotFoundError):
81
+ read_factor(str(tmp_path), 'factor_growth')
82
+
83
+
84
+ class TestListFactorClasses:
85
+ def test_lists_both_kinds(self, tmp_path):
86
+ """同时识别 _Xdat2(已下载)和 _Xdat(仅定义)"""
87
+ ep = tmp_path / 'EP'
88
+ (ep / 'factor_growth_Xdat2').mkdir(parents=True)
89
+ (ep / 'factor_metrics_Xdat').mkdir(parents=True)
90
+ classes = list_factor_classes(str(tmp_path))
91
+ assert classes == ['factor_growth', 'factor_metrics']
92
+
93
+ def test_empty_when_no_ep(self, tmp_path):
94
+ assert list_factor_classes(str(tmp_path)) == []
95
+
96
+
97
+ class TestReaderMethods:
98
+ def test_reader_factors(self, tmp_path):
99
+ data_dir = _make_datadir(tmp_path)
100
+ reader = QmtDataReader(data_dir)
101
+ df = reader.factors('factor_growth', symbols=['000001.SZ'])
102
+ assert df['symbol'].unique().tolist() == ['000001.SZ']
103
+
104
+ def test_reader_factor_classes(self, tmp_path):
105
+ ep = tmp_path / 'EP'
106
+ (ep / 'beta_Xdat2').mkdir(parents=True)
107
+ reader = QmtDataReader(str(tmp_path))
108
+ assert reader.factor_classes() == ['beta']
109
+
110
+
111
+ if __name__ == "__main__":
112
+ pytest.main([__file__, "-v"])