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.
- {xtquant_share-1.2.41/xtquant_share.egg-info → xtquant_share-1.3.1}/PKG-INFO +50 -1
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/README.md +45 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/pyproject.toml +6 -1
- xtquant_share-1.3.1/tests/test_channels_bigqmt.py +127 -0
- xtquant_share-1.3.1/tests/test_channels_health.py +64 -0
- xtquant_share-1.3.1/tests/test_channels_router.py +102 -0
- xtquant_share-1.3.1/tests/test_qmt_datadir_factor.py +112 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/tests/test_quote_events.py +82 -3
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/tests/test_server.py +43 -0
- xtquant_share-1.3.1/tests/test_server_channel_routing.py +90 -0
- xtquant_share-1.3.1/tests/test_server_download_timeout.py +128 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/__init__.py +1 -1
- xtquant_share-1.3.1/xqshare/channels/__init__.py +29 -0
- xtquant_share-1.3.1/xqshare/channels/bigqmt.py +239 -0
- xtquant_share-1.3.1/xqshare/channels/health.py +140 -0
- xtquant_share-1.3.1/xqshare/channels/router.py +179 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/client.py +21 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/qmt_datadir/__init__.py +7 -0
- xtquant_share-1.3.1/xqshare/qmt_datadir/factor.py +148 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/qmt_datadir/reader.py +34 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/server.py +381 -62
- {xtquant_share-1.2.41 → xtquant_share-1.3.1/xtquant_share.egg-info}/PKG-INFO +50 -1
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xtquant_share.egg-info/SOURCES.txt +11 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xtquant_share.egg-info/requires.txt +5 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/LICENSE +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/setup.cfg +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/tests/test_auth.py +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/tests/test_client.py +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/tests/test_client_permission.py +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/tests/test_integration.py +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/tests/test_remote_data.py +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/tests/test_server_download_history_data2_compat.py +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/tests/test_server_permission.py +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/tests/test_tools_common.py +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/tests/test_trader_events.py +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/.env.example +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/auth.py +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/check_env.py +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/clients.yaml.example +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/qmt_datadir/divid.py +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/qmt_datadir/kline.py +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/qmt_datadir/misc.py +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/qmt_datadir/sector.py +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/qmt_watchdog.py +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/tools/__init__.py +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/tools/common.py +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/tools/xtdata.py +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xqshare/tools/xttrader.py +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xtquant_share.egg-info/dependency_links.txt +0 -0
- {xtquant_share-1.2.41 → xtquant_share-1.3.1}/xtquant_share.egg-info/entry_points.txt +0 -0
- {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.
|
|
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.
|
|
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"])
|