quantdb-sdk 0.4.3__tar.gz → 0.4.5__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.
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/CHANGELOG.md +25 -0
- {quantdb_sdk-0.4.3/quantdb_sdk.egg-info → quantdb_sdk-0.4.5}/PKG-INFO +1 -1
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/pyproject.toml +1 -1
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/quantdb_sdk/__init__.py +1 -1
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/quantdb_sdk/__main__.py +30 -3
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/quantdb_sdk/_utils.py +3 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/quantdb_sdk/async_client.py +38 -3
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/quantdb_sdk/client.py +54 -4
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/quantdb_sdk/examples/demo_01_connect_basics.py +6 -1
- quantdb_sdk-0.4.5/quantdb_sdk/mcp_download.py +111 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5/quantdb_sdk.egg-info}/PKG-INFO +1 -1
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/quantdb_sdk.egg-info/SOURCES.txt +2 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/tests/test_async_client.py +2 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/tests/test_client.py +2 -0
- quantdb_sdk-0.4.5/tests/test_mcp_download.py +134 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/LICENSE +0 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/MANIFEST.in +0 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/README.md +0 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/quantdb_sdk/errors.py +0 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/quantdb_sdk/examples/__init__.py +0 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/quantdb_sdk/examples/demo_02_kline_v2_daily.py +0 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/quantdb_sdk/examples/demo_03_sync_full_market.py +0 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/quantdb_sdk/examples/demo_04_duckdb_warehouse.py +0 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/quantdb_sdk/examples/demo_05_read_local_data.py +0 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/quantdb_sdk/examples/demo_06_tick_and_factors.py +0 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/quantdb_sdk/examples/demo_07_data_hub.py +0 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/quantdb_sdk/hub.py +0 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/quantdb_sdk/layout.py +0 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/quantdb_sdk/py.typed +0 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/quantdb_sdk.egg-info/dependency_links.txt +0 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/quantdb_sdk.egg-info/entry_points.txt +0 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/quantdb_sdk.egg-info/requires.txt +0 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/quantdb_sdk.egg-info/top_level.txt +0 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/setup.cfg +0 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/tests/test_data_hub.py +0 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/tests/test_hub_models.py +0 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/tests/test_layout.py +0 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/tests/test_technical_indicators.py +0 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/tests/test_update_pipeline.py +0 -0
- {quantdb_sdk-0.4.3 → quantdb_sdk-0.4.5}/tests/test_utils.py +0 -0
|
@@ -3,6 +3,31 @@
|
|
|
3
3
|
所有 notable 变更都会记录在此文件。格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
|
|
4
4
|
版本号遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
|
|
5
5
|
|
|
6
|
+
## \[0.4.5] - 2026-10-18
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- 本地 MCP 可调用 `quantdb-sdk` 执行单文件/全市场日分区下载与数据集增量同步,并查询、取消后台任务。远程 MCP 提供用户电脑可执行的 SDK 下载方案。
|
|
11
|
+
- `quantdb download <dataset> --trade-date YYYYMMDD --save-dir <root> --json`:下载 Parquet 并输出保存路径、大小;V1 逐股票文件使用 `--symbol`。V2 下载包含全市场,不做单股票裁切。
|
|
12
|
+
- `quantdb sync <dataset> --json`:复用 SDK release cursor/Manifest 同步并返回精简摘要。
|
|
13
|
+
- `python -m quantdb_sdk.mcp_download`:供本地 MCP 子进程使用的 JSON stdin/stdout 执行入口,凭据不进入进程参数或返回值。
|
|
14
|
+
|
|
15
|
+
## \[0.4.4] - 2026-10-11
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **`get_usage()` / `a_get_usage()` 漏算流量包额度**:旧实现用 `remaining = traffic_limit + credit_limit + purchased_traffic - used_traffic`,**完全没算流量包**,导致持有流量包的用户剩余流量被低估(少报的量正好等于流量包余额),`is_active` 也会在仍有流量包可用时被误判为 `False`。
|
|
20
|
+
|
|
21
|
+
修正后的口径与后端下载预扣一致:可用总额度 = 套餐额度(`traffic_limit + credit_limit + purchased_traffic`)+ 流量包总额;可用剩余 = (套餐额度 − 已用) + 流量包剩余。因用量接口返回的 `package_traffic` 是流量包**剩余量**,总额从 `GET /api/v1/subscription/traffic-packages` 台账累加;台账接口不可用时退化为「总额 = 剩余」,保证 `get_usage()` 在旧版服务端上依然可用。
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **`get_usage()` / `a_get_usage()` 新增额度明细字段**(原有字段全部保留,取值已修正):
|
|
26
|
+
- 合计:`total_gb` / `used_gb` / `remaining_gb`
|
|
27
|
+
- 套餐额度:`plan_total_gb` / `plan_used_gb` / `plan_remaining_gb`
|
|
28
|
+
- 流量包额度:`package_total_gb` / `package_used_gb` / `package_remaining_gb`
|
|
29
|
+
- **`quantdb usage` CLI** 改为分行展示套餐 / 流量包 / 合计的已用、总额与剩余,不再只显示一个总额。
|
|
30
|
+
|
|
6
31
|
## \[0.4.3] - 2026-10-05
|
|
7
32
|
|
|
8
33
|
### Changed
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
15
|
import argparse
|
|
16
|
+
import json
|
|
16
17
|
import os
|
|
17
18
|
import sys
|
|
18
19
|
from typing import Optional
|
|
@@ -105,6 +106,14 @@ def main() -> int:
|
|
|
105
106
|
sync_parser.add_argument("--save-dir", default=None,
|
|
106
107
|
help="本地同步根目录(默认读 QUANTDB_DATA_DIR,未设置时用默认下载目录)")
|
|
107
108
|
sync_parser.add_argument("--after-release", default=None, help="覆盖本地 release cursor(一般无需传入)")
|
|
109
|
+
sync_parser.add_argument("--json", action="store_true", help="输出精简 JSON 摘要(适合 MCP 下载方案)")
|
|
110
|
+
|
|
111
|
+
download_parser = subparsers.add_parser("download", help="下载单个 Parquet 文件或全市场日分区")
|
|
112
|
+
download_parser.add_argument("dataset", help="数据集,如 daily_forward、balance")
|
|
113
|
+
download_parser.add_argument("--symbol", default=None, help="仅逐股票 V1 文件填写;V2 分区包含全市场")
|
|
114
|
+
download_parser.add_argument("--trade-date", default=None, help="V2 必填:YYYY-MM-DD 或 YYYYMMDD")
|
|
115
|
+
download_parser.add_argument("--save-dir", default=None, help="本地数据根目录")
|
|
116
|
+
download_parser.add_argument("--json", action="store_true", help="输出 JSON 保存路径和文件大小")
|
|
108
117
|
|
|
109
118
|
args = parser.parse_args()
|
|
110
119
|
|
|
@@ -123,6 +132,21 @@ def main() -> int:
|
|
|
123
132
|
parser.error(f"请先设置 API Key 环境变量 {args.api_key_env}")
|
|
124
133
|
|
|
125
134
|
try:
|
|
135
|
+
if args.command == "download" or (args.command == "sync" and args.json):
|
|
136
|
+
from .mcp_download import execute_download
|
|
137
|
+
|
|
138
|
+
result = execute_download({
|
|
139
|
+
"operation": args.command, "dataset": args.dataset,
|
|
140
|
+
"symbol": getattr(args, "symbol", None),
|
|
141
|
+
"trade_date": getattr(args, "trade_date", None), "save_dir": args.save_dir,
|
|
142
|
+
"after_release": getattr(args, "after_release", None),
|
|
143
|
+
}, args.api_host, api_key)
|
|
144
|
+
if args.json:
|
|
145
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
146
|
+
else:
|
|
147
|
+
print(f"下载完成: {result['path']} ({result['size_bytes']} bytes)")
|
|
148
|
+
return 0
|
|
149
|
+
|
|
126
150
|
client = QuantDBClient(api_host=args.api_host, api_key=api_key)
|
|
127
151
|
|
|
128
152
|
if args.command == "check":
|
|
@@ -135,9 +159,12 @@ def main() -> int:
|
|
|
135
159
|
if args.command == "usage":
|
|
136
160
|
usage = client.get_usage()
|
|
137
161
|
print(f"账户用量:")
|
|
138
|
-
print(f"
|
|
139
|
-
|
|
140
|
-
print(f"
|
|
162
|
+
print(f" 套餐: 已用 {usage['plan_used_gb']:.2f} GB / {usage['plan_total_gb']:.2f} GB"
|
|
163
|
+
f"(剩余 {usage['plan_remaining_gb']:.2f} GB)")
|
|
164
|
+
print(f" 流量包: 已用 {usage['package_used_gb']:.2f} GB / {usage['package_total_gb']:.2f} GB"
|
|
165
|
+
f"(剩余 {usage['package_remaining_gb']:.2f} GB)")
|
|
166
|
+
print(f" 合计: 已用 {usage['used_gb']:.2f} GB / {usage['total_gb']:.2f} GB"
|
|
167
|
+
f"(剩余 {usage['remaining_gb']:.2f} GB)")
|
|
141
168
|
print(f" 余额: ¥{usage['balance_yuan']:.2f}")
|
|
142
169
|
sub = usage.get("subscription", {})
|
|
143
170
|
print(f" 订阅: {sub.get('status', 'none')}")
|
|
@@ -96,6 +96,9 @@ def safe_join(root: str, relative_path: str) -> str:
|
|
|
96
96
|
target = os.path.abspath(os.path.join(root_abs, *parts))
|
|
97
97
|
if os.path.commonpath([root_abs, target]) != root_abs:
|
|
98
98
|
raise ValueError("发布清单路径超出同步目录")
|
|
99
|
+
root_real = os.path.realpath(root_abs)
|
|
100
|
+
if os.path.commonpath([root_real, os.path.realpath(target)]) != root_real:
|
|
101
|
+
raise ValueError("发布清单路径通过符号链接超出同步目录")
|
|
99
102
|
return target
|
|
100
103
|
|
|
101
104
|
|
|
@@ -28,7 +28,7 @@ from .errors import (
|
|
|
28
28
|
)
|
|
29
29
|
|
|
30
30
|
# 维护提示:每次发版必须与 pyproject.toml 版本号同步
|
|
31
|
-
_USER_AGENT = "QuantDB-Python-SDK/0.4.
|
|
31
|
+
_USER_AGENT = "QuantDB-Python-SDK/0.4.5"
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
def _etag_hex(value: Optional[str]) -> Optional[str]:
|
|
@@ -287,21 +287,56 @@ class AsyncQuantDBClient:
|
|
|
287
287
|
return await self._get("/api/v1/auth/me")
|
|
288
288
|
|
|
289
289
|
async def a_get_usage(self) -> Dict[str, Any]:
|
|
290
|
+
"""获取当前账户流量与订阅使用情况(异步版,字段含义见 ``QuantDBClient.get_usage``)。"""
|
|
290
291
|
data = await self._get("/api/v1/auth/usage")
|
|
291
292
|
used = data.get("used_traffic", 0)
|
|
292
293
|
limit = data.get("traffic_limit", 0)
|
|
293
294
|
credit = data.get("credit_limit", 0)
|
|
294
295
|
purchased = data.get("purchased_traffic", 0)
|
|
296
|
+
package_remaining = data.get("package_traffic", 0)
|
|
295
297
|
subscription = data.get("subscription", {}) or {}
|
|
298
|
+
|
|
299
|
+
plan_total = limit + credit + purchased
|
|
300
|
+
plan_remaining = max(0, plan_total - used)
|
|
301
|
+
|
|
302
|
+
# 流量包总额只能从台账取(用量接口只给剩余量)。台账接口不可用时退化为
|
|
303
|
+
# 「总额 = 剩余」,保证 a_get_usage 在旧版服务端上依然可用。
|
|
304
|
+
package_total = package_remaining
|
|
305
|
+
try:
|
|
306
|
+
ledger = await self._get("/api/v1/subscription/traffic-packages")
|
|
307
|
+
active = [
|
|
308
|
+
p for p in (ledger.get("packages") or [])
|
|
309
|
+
if str(p.get("status", "")).lower() == "active"
|
|
310
|
+
]
|
|
311
|
+
if active:
|
|
312
|
+
package_total = sum(int(p.get("quota_bytes") or 0) for p in active)
|
|
313
|
+
except QuantDBError:
|
|
314
|
+
pass
|
|
315
|
+
|
|
316
|
+
package_used = max(0, package_total - package_remaining)
|
|
317
|
+
total = plan_total + package_total
|
|
318
|
+
remaining = plan_remaining + package_remaining
|
|
319
|
+
|
|
296
320
|
return {
|
|
321
|
+
# 合计
|
|
322
|
+
"total_gb": bytes_to_gb(total),
|
|
297
323
|
"used_gb": bytes_to_gb(used),
|
|
324
|
+
"remaining_gb": bytes_to_gb(remaining),
|
|
325
|
+
# 套餐额度(月度重置)
|
|
326
|
+
"plan_total_gb": bytes_to_gb(plan_total),
|
|
327
|
+
"plan_used_gb": bytes_to_gb(used),
|
|
328
|
+
"plan_remaining_gb": bytes_to_gb(plan_remaining),
|
|
329
|
+
# 流量包额度(一年有效、可叠加)
|
|
330
|
+
"package_total_gb": bytes_to_gb(package_total),
|
|
331
|
+
"package_used_gb": bytes_to_gb(package_used),
|
|
332
|
+
"package_remaining_gb": bytes_to_gb(package_remaining),
|
|
333
|
+
# 兼容旧字段
|
|
298
334
|
"limit_gb": bytes_to_gb(limit),
|
|
299
335
|
"credit_gb": bytes_to_gb(credit),
|
|
300
336
|
"purchased_gb": bytes_to_gb(purchased),
|
|
301
|
-
"remaining_gb": max(0.0, bytes_to_gb(limit + credit + purchased - used)),
|
|
302
337
|
"balance_yuan": data.get("balance_yuan", 0.0),
|
|
303
338
|
"subscription": subscription,
|
|
304
|
-
"is_active": subscription.get("status") == "active" and
|
|
339
|
+
"is_active": subscription.get("status") == "active" and remaining > 0,
|
|
305
340
|
}
|
|
306
341
|
|
|
307
342
|
async def a_register(self, username: str, email: str, password: str) -> Dict[str, Any]:
|
|
@@ -32,7 +32,7 @@ from .errors import (
|
|
|
32
32
|
)
|
|
33
33
|
|
|
34
34
|
# 维护提示:每次发版必须与 pyproject.toml 版本号同步
|
|
35
|
-
_USER_AGENT = "QuantDB-Python-SDK/0.4.
|
|
35
|
+
_USER_AGENT = "QuantDB-Python-SDK/0.4.5"
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
def _etag_hex(value: Optional[str]) -> Optional[str]:
|
|
@@ -325,22 +325,72 @@ class QuantDBClient:
|
|
|
325
325
|
return self._get("/api/v1/auth/me")
|
|
326
326
|
|
|
327
327
|
def get_usage(self) -> Dict[str, Any]:
|
|
328
|
-
"""获取当前账户流量与订阅使用情况。
|
|
328
|
+
"""获取当前账户流量与订阅使用情况。
|
|
329
|
+
|
|
330
|
+
额度模型(与后端下载预扣逻辑一致):
|
|
331
|
+
|
|
332
|
+
- **套餐额度**(月度重置):``traffic_limit + credit_limit + purchased_traffic``,
|
|
333
|
+
下载时优先从该额度池扣减,已用量即 ``used_traffic``;
|
|
334
|
+
- **流量包额度**(一年有效、可叠加):台账 ``traffic_packages``。
|
|
335
|
+
接口返回的 ``package_traffic`` 是流量包**剩余量**,总额需从台账累加。
|
|
336
|
+
|
|
337
|
+
因此:可用总额度 = 套餐额度 + 流量包总额;
|
|
338
|
+
可用剩余 = (套餐额度 - 已用) + 流量包剩余。
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
用量字典,包含合计(``total_gb``/``used_gb``/``remaining_gb``)、
|
|
342
|
+
套餐额度(``plan_*``)、流量包额度(``package_*``)三组字段,
|
|
343
|
+
以及兼容旧版的 ``limit_gb``/``credit_gb``/``purchased_gb``。
|
|
344
|
+
"""
|
|
329
345
|
data = self._get("/api/v1/auth/usage")
|
|
330
346
|
used = data.get("used_traffic", 0)
|
|
331
347
|
limit = data.get("traffic_limit", 0)
|
|
332
348
|
credit = data.get("credit_limit", 0)
|
|
333
349
|
purchased = data.get("purchased_traffic", 0)
|
|
350
|
+
package_remaining = data.get("package_traffic", 0)
|
|
334
351
|
subscription = data.get("subscription", {}) or {}
|
|
352
|
+
|
|
353
|
+
plan_total = limit + credit + purchased
|
|
354
|
+
plan_remaining = max(0, plan_total - used)
|
|
355
|
+
|
|
356
|
+
# 流量包总额只能从台账取(用量接口只给剩余量)。台账接口不可用时退化为
|
|
357
|
+
# 「总额 = 剩余」,保证 get_usage 在旧版服务端上依然可用。
|
|
358
|
+
package_total = package_remaining
|
|
359
|
+
try:
|
|
360
|
+
ledger = self._get("/api/v1/subscription/traffic-packages")
|
|
361
|
+
active = [
|
|
362
|
+
p for p in (ledger.get("packages") or [])
|
|
363
|
+
if str(p.get("status", "")).lower() == "active"
|
|
364
|
+
]
|
|
365
|
+
if active:
|
|
366
|
+
package_total = sum(int(p.get("quota_bytes") or 0) for p in active)
|
|
367
|
+
except QuantDBError:
|
|
368
|
+
pass
|
|
369
|
+
|
|
370
|
+
package_used = max(0, package_total - package_remaining)
|
|
371
|
+
total = plan_total + package_total
|
|
372
|
+
remaining = plan_remaining + package_remaining
|
|
373
|
+
|
|
335
374
|
return {
|
|
375
|
+
# 合计
|
|
376
|
+
"total_gb": bytes_to_gb(total),
|
|
336
377
|
"used_gb": bytes_to_gb(used),
|
|
378
|
+
"remaining_gb": bytes_to_gb(remaining),
|
|
379
|
+
# 套餐额度(月度重置)
|
|
380
|
+
"plan_total_gb": bytes_to_gb(plan_total),
|
|
381
|
+
"plan_used_gb": bytes_to_gb(used),
|
|
382
|
+
"plan_remaining_gb": bytes_to_gb(plan_remaining),
|
|
383
|
+
# 流量包额度(一年有效、可叠加)
|
|
384
|
+
"package_total_gb": bytes_to_gb(package_total),
|
|
385
|
+
"package_used_gb": bytes_to_gb(package_used),
|
|
386
|
+
"package_remaining_gb": bytes_to_gb(package_remaining),
|
|
387
|
+
# 兼容旧字段
|
|
337
388
|
"limit_gb": bytes_to_gb(limit),
|
|
338
389
|
"credit_gb": bytes_to_gb(credit),
|
|
339
390
|
"purchased_gb": bytes_to_gb(purchased),
|
|
340
|
-
"remaining_gb": max(0.0, bytes_to_gb(limit + credit + purchased - used)),
|
|
341
391
|
"balance_yuan": data.get("balance_yuan", 0.0),
|
|
342
392
|
"subscription": subscription,
|
|
343
|
-
"is_active": subscription.get("status") == "active" and
|
|
393
|
+
"is_active": subscription.get("status") == "active" and remaining > 0,
|
|
344
394
|
}
|
|
345
395
|
|
|
346
396
|
def register(self, username: str, email: str, password: str) -> Dict[str, Any]:
|
|
@@ -30,7 +30,12 @@ def main() -> None:
|
|
|
30
30
|
print("账户:", me.get("username"), "| ID:", me.get("id"))
|
|
31
31
|
|
|
32
32
|
usage = client.get_usage()
|
|
33
|
-
print(f"
|
|
33
|
+
print(f"套餐: 已用 {usage['plan_used_gb']:.2f} GB / {usage['plan_total_gb']:.2f} GB"
|
|
34
|
+
f" / 剩余 {usage['plan_remaining_gb']:.2f} GB")
|
|
35
|
+
print(f"流量包: 已用 {usage['package_used_gb']:.2f} GB / {usage['package_total_gb']:.2f} GB"
|
|
36
|
+
f" / 剩余 {usage['package_remaining_gb']:.2f} GB")
|
|
37
|
+
print(f"合计: 已用 {usage['used_gb']:.2f} GB / {usage['total_gb']:.2f} GB"
|
|
38
|
+
f" / 剩余 {usage['remaining_gb']:.2f} GB")
|
|
34
39
|
print("订阅:", usage.get("subscription", {}).get("status", "none"))
|
|
35
40
|
|
|
36
41
|
stocks = client.query_stock_list(keyword=args.keyword, limit=5)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""SDK download runner used by the local MCP process (JSON over stdin/stdout).
|
|
2
|
+
|
|
3
|
+
Credentials travel over stdin, never in process arguments or returned results.
|
|
4
|
+
All network transfers, traffic accounting and release cursors use QuantDBClient.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import contextlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Any, Dict
|
|
14
|
+
|
|
15
|
+
from .client import QuantDBClient
|
|
16
|
+
from .errors import ValidationError
|
|
17
|
+
from .layout import CATEGORY_DIRS, V2_DAILY_PARTITION, data_root
|
|
18
|
+
from ._utils import SYNC_DATASET_CATEGORIES
|
|
19
|
+
|
|
20
|
+
PARTITIONED = V2_DAILY_PARTITION | {"min1_kline", "min5_kline", "tick_data", "etf_kline"}
|
|
21
|
+
STATIC = {"sector_concept", "instrument_detail", "trading_calendar", "convertible_bond", "etf_pcf"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_request(request: Dict[str, Any]) -> Dict[str, Any]:
|
|
25
|
+
operation = request.get("operation", "download")
|
|
26
|
+
dataset = request.get("dataset", "")
|
|
27
|
+
if operation not in {"download", "sync"} or dataset not in SYNC_DATASET_CATEGORIES:
|
|
28
|
+
raise ValidationError("Unknown operation or dataset")
|
|
29
|
+
result = {"operation": operation, "dataset": dataset}
|
|
30
|
+
for key in ("symbol", "trade_date", "save_dir"):
|
|
31
|
+
value = request.get(key) or ""
|
|
32
|
+
if not isinstance(value, str) or len(value) > 4096:
|
|
33
|
+
raise ValidationError("Invalid download parameter")
|
|
34
|
+
result[key] = value.strip()
|
|
35
|
+
if operation == "sync":
|
|
36
|
+
if result["symbol"] or result["trade_date"]:
|
|
37
|
+
raise ValidationError("Dataset sync does not accept symbol or trade_date")
|
|
38
|
+
return result
|
|
39
|
+
if dataset in PARTITIONED:
|
|
40
|
+
if result["symbol"]:
|
|
41
|
+
raise ValidationError("V2 downloads contain the entire market partition; omit symbol")
|
|
42
|
+
date = result["trade_date"].replace("-", "")
|
|
43
|
+
if not re.fullmatch(r"\d{8}", date):
|
|
44
|
+
raise ValidationError("V2 downloads require trade_date (YYYY-MM-DD or YYYYMMDD)")
|
|
45
|
+
try:
|
|
46
|
+
datetime.strptime(date, "%Y%m%d")
|
|
47
|
+
except ValueError as exc:
|
|
48
|
+
raise ValidationError("Invalid trade_date") from exc
|
|
49
|
+
result["trade_date"] = date
|
|
50
|
+
else:
|
|
51
|
+
if result["trade_date"]:
|
|
52
|
+
raise ValidationError("This dataset does not accept trade_date")
|
|
53
|
+
if dataset in STATIC:
|
|
54
|
+
if result["symbol"]:
|
|
55
|
+
raise ValidationError("This dataset is a whole-market static file; omit symbol")
|
|
56
|
+
elif not re.fullmatch(r"[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*", result["symbol"]):
|
|
57
|
+
raise ValidationError("A valid symbol is required for this dataset")
|
|
58
|
+
result["symbol"] = result["symbol"].upper()
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def execute_download(request: Dict[str, Any], api_host: str, api_key: str) -> Dict[str, Any]:
|
|
63
|
+
spec = validate_request(request)
|
|
64
|
+
if not api_key:
|
|
65
|
+
raise ValidationError("QUANTDB_API_KEY is required")
|
|
66
|
+
root = os.path.realpath(os.path.abspath(spec["save_dir"] or data_root()))
|
|
67
|
+
client = QuantDBClient(api_host=api_host, api_key=api_key)
|
|
68
|
+
try:
|
|
69
|
+
if spec["operation"] == "sync":
|
|
70
|
+
result = client.data.sync(spec["dataset"], save_dir=root, after_release=request.get("after_release"))
|
|
71
|
+
return {
|
|
72
|
+
"operation": "sync", "dataset": spec["dataset"], "save_dir": root,
|
|
73
|
+
"downloaded_count": len(result["downloaded"]),
|
|
74
|
+
"matched_count": len(result.get("matched", [])),
|
|
75
|
+
"release_id": result.get("release_id"), "layout": result.get("layout"),
|
|
76
|
+
}
|
|
77
|
+
dataset = spec["dataset"]
|
|
78
|
+
target = os.path.join(root, CATEGORY_DIRS[SYNC_DATASET_CATEGORIES[dataset]], dataset)
|
|
79
|
+
if spec["trade_date"]:
|
|
80
|
+
target = os.path.join(target, "dt=" + spec["trade_date"])
|
|
81
|
+
# Resolve symlinks before creating files, including existing subdirectories.
|
|
82
|
+
if os.path.commonpath([root, os.path.realpath(target)]) != root:
|
|
83
|
+
raise ValidationError("Download destination escapes save_dir")
|
|
84
|
+
path = client.data.download(
|
|
85
|
+
dataset, symbol=spec["symbol"] or None, trade_date=spec["trade_date"] or None,
|
|
86
|
+
save_dir=target, layout="v2" if dataset in PARTITIONED else "v1",
|
|
87
|
+
)
|
|
88
|
+
return {"operation": "download", "dataset": dataset, "save_dir": root,
|
|
89
|
+
"path": path, "size_bytes": os.path.getsize(path), "downloaded_count": 1}
|
|
90
|
+
finally:
|
|
91
|
+
client.session.close()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main() -> int:
|
|
95
|
+
try:
|
|
96
|
+
raw = sys.stdin.read(65537)
|
|
97
|
+
if len(raw) > 65536:
|
|
98
|
+
raise ValidationError("Request too large")
|
|
99
|
+
request = json.loads(raw)
|
|
100
|
+
# SDK/HTTP diagnostics must not leak credentials or signed URLs into MCP.
|
|
101
|
+
with open(os.devnull, "w") as sink, contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink):
|
|
102
|
+
result = execute_download(request, request["api_host"], request["api_key"])
|
|
103
|
+
print(json.dumps({"result": result}, ensure_ascii=True))
|
|
104
|
+
return 0
|
|
105
|
+
except Exception as exc:
|
|
106
|
+
print(json.dumps({"error": {"type": type(exc).__name__, "message": "SDK download failed; check credentials, access, quota, dataset and local disk."}}))
|
|
107
|
+
return 1
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
sys.exit(main())
|
|
@@ -11,6 +11,7 @@ quantdb_sdk/client.py
|
|
|
11
11
|
quantdb_sdk/errors.py
|
|
12
12
|
quantdb_sdk/hub.py
|
|
13
13
|
quantdb_sdk/layout.py
|
|
14
|
+
quantdb_sdk/mcp_download.py
|
|
14
15
|
quantdb_sdk/py.typed
|
|
15
16
|
quantdb_sdk.egg-info/PKG-INFO
|
|
16
17
|
quantdb_sdk.egg-info/SOURCES.txt
|
|
@@ -31,6 +32,7 @@ tests/test_client.py
|
|
|
31
32
|
tests/test_data_hub.py
|
|
32
33
|
tests/test_hub_models.py
|
|
33
34
|
tests/test_layout.py
|
|
35
|
+
tests/test_mcp_download.py
|
|
34
36
|
tests/test_technical_indicators.py
|
|
35
37
|
tests/test_update_pipeline.py
|
|
36
38
|
tests/test_utils.py
|
|
@@ -144,6 +144,8 @@ async def test_a_insufficient_traffic_error():
|
|
|
144
144
|
@pytest.mark.asyncio
|
|
145
145
|
@respx.mock
|
|
146
146
|
async def test_a_get_usage_with_subscription():
|
|
147
|
+
# a_get_usage also queries the traffic-package ledger since SDK 0.4.4.
|
|
148
|
+
respx.get(f"{API_HOST}/api/v1/subscription/traffic-packages").mock(return_value=httpx.Response(200, json={"packages": []}))
|
|
147
149
|
respx.get(f"{API_HOST}/api/v1/auth/usage").mock(
|
|
148
150
|
return_value=httpx.Response(
|
|
149
151
|
200,
|
|
@@ -213,6 +213,8 @@ def test_insufficient_traffic_error():
|
|
|
213
213
|
|
|
214
214
|
@responses.activate
|
|
215
215
|
def test_get_usage_with_subscription():
|
|
216
|
+
# get_usage also queries the traffic-package ledger since SDK 0.4.4.
|
|
217
|
+
responses.get(f"{API_HOST}/api/v1/subscription/traffic-packages", json={"packages": []}, status=200)
|
|
216
218
|
responses.get(
|
|
217
219
|
f"{API_HOST}/api/v1/auth/usage",
|
|
218
220
|
json={
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""MCP download runner reuses real SDK transfer and release-sync behavior."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from urllib.parse import parse_qs, urlparse
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
import responses
|
|
10
|
+
|
|
11
|
+
from quantdb_sdk.errors import ValidationError
|
|
12
|
+
from quantdb_sdk.mcp_download import execute_download, main, validate_request
|
|
13
|
+
|
|
14
|
+
HOST = "http://localhost:5000"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@responses.activate
|
|
18
|
+
def test_download_partition_preserves_dates_and_forwards_key(tmp_path):
|
|
19
|
+
def download(request):
|
|
20
|
+
params = parse_qs(urlparse(request.url).query)
|
|
21
|
+
assert request.headers["X-API-Key"] == "local-secret"
|
|
22
|
+
assert params["layout"] == ["v2"]
|
|
23
|
+
assert "symbol" not in params
|
|
24
|
+
return 200, {"Content-Disposition": 'attachment; filename="data.parquet"'}, params["trade_date"][0].encode()
|
|
25
|
+
|
|
26
|
+
responses.add_callback(responses.GET, HOST + "/api/v1/data/download", callback=download)
|
|
27
|
+
paths = []
|
|
28
|
+
for date in ("2026-10-15", "20261016"):
|
|
29
|
+
result = execute_download({"dataset": "min1_kline", "trade_date": date, "save_dir": str(tmp_path)}, HOST, "local-secret")
|
|
30
|
+
paths.append(result["path"])
|
|
31
|
+
assert result["downloaded_count"] == 1
|
|
32
|
+
assert result["size_bytes"] == 8
|
|
33
|
+
assert paths[0] != paths[1]
|
|
34
|
+
assert (tmp_path / "1_kline_data/min1_kline/dt=20261016/data.parquet").read_bytes() == b"20261016"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@responses.activate
|
|
38
|
+
def test_download_v1_and_static_files(tmp_path):
|
|
39
|
+
def download(request):
|
|
40
|
+
params = parse_qs(urlparse(request.url).query)
|
|
41
|
+
assert params["layout"] == ["v1"]
|
|
42
|
+
if params["sub_category"] == ["balance"]:
|
|
43
|
+
assert params["symbol"] == ["600519.SH"]
|
|
44
|
+
else:
|
|
45
|
+
assert "symbol" not in params
|
|
46
|
+
return 200, {}, b"file"
|
|
47
|
+
responses.add_callback(responses.GET, HOST + "/api/v1/data/download", callback=download)
|
|
48
|
+
result = execute_download({"dataset": "balance", "symbol": "600519.sh", "save_dir": str(tmp_path)}, HOST, "key")
|
|
49
|
+
assert result["path"].endswith("balance_600519.SH.parquet")
|
|
50
|
+
result = execute_download({"dataset": "sector_concept", "save_dir": str(tmp_path)}, HOST, "key")
|
|
51
|
+
assert result["path"].endswith("sector_concept.parquet")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@responses.activate
|
|
55
|
+
def test_sync_uses_sdk_cursor_and_skips_existing_content(tmp_path):
|
|
56
|
+
payload = b"partition"
|
|
57
|
+
key = "1_kline_data/daily_forward/dt=20261016/data.parquet"
|
|
58
|
+
calls = []
|
|
59
|
+
def releases(request):
|
|
60
|
+
calls.append(parse_qs(urlparse(request.url).query, keep_blank_values=True).get("after_release"))
|
|
61
|
+
return 200, {}, json.dumps({"releases": [{"release_id": "20261016.1", "objects": [
|
|
62
|
+
{"key": key, "size": len(payload), "sha256": hashlib.sha256(payload).hexdigest()}
|
|
63
|
+
]}]})
|
|
64
|
+
responses.add_callback(responses.GET, HOST + "/api/v1/data/releases", callback=releases)
|
|
65
|
+
responses.get(HOST + "/api/v1/data/download", body=payload, status=200)
|
|
66
|
+
spec = {"operation": "sync", "dataset": "daily_forward", "save_dir": str(tmp_path)}
|
|
67
|
+
first = execute_download(spec, HOST, "key")
|
|
68
|
+
second = execute_download(spec, HOST, "key")
|
|
69
|
+
assert first["downloaded_count"] == 1
|
|
70
|
+
assert second["downloaded_count"] == 0
|
|
71
|
+
assert calls == [[""], ["20261016.1"]]
|
|
72
|
+
assert (tmp_path / key).read_bytes() == payload
|
|
73
|
+
assert "downloaded" not in first # bounded MCP summary, even for huge syncs
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.mark.parametrize("spec", [
|
|
77
|
+
{"dataset": "unknown"},
|
|
78
|
+
{"dataset": "daily_forward"},
|
|
79
|
+
{"dataset": "daily_forward", "trade_date": "20260230"},
|
|
80
|
+
{"dataset": "daily_forward", "trade_date": "20261016", "symbol": "600519.SH"},
|
|
81
|
+
{"dataset": "balance", "symbol": "../../outside"},
|
|
82
|
+
{"dataset": "balance", "symbol": "600519.SH", "trade_date": "20261016"},
|
|
83
|
+
{"dataset": "sector_concept", "symbol": "600519.SH"},
|
|
84
|
+
{"operation": "sync", "dataset": "balance", "symbol": "600519.SH"},
|
|
85
|
+
])
|
|
86
|
+
def test_validation_prevents_ambiguous_or_unsafe_requests(spec):
|
|
87
|
+
with pytest.raises(ValidationError):
|
|
88
|
+
validate_request(spec)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@responses.activate
|
|
92
|
+
def test_worker_never_returns_upstream_secrets(tmp_path, monkeypatch, capsys):
|
|
93
|
+
responses.get(HOST + "/api/v1/data/download", json={"detail": "local-secret https://signed.example?sign=secret"}, status=403)
|
|
94
|
+
request = {"dataset": "balance", "symbol": "600519.SH", "save_dir": str(tmp_path), "api_host": HOST, "api_key": "local-secret"}
|
|
95
|
+
import io
|
|
96
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(json.dumps(request)))
|
|
97
|
+
assert main() == 1
|
|
98
|
+
output = capsys.readouterr()
|
|
99
|
+
assert "local-secret" not in output.out + output.err
|
|
100
|
+
assert "signed.example" not in output.out + output.err
|
|
101
|
+
assert json.loads(output.out)["error"]["type"]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_cli_download_and_sync_json_delegate_sdk(monkeypatch, capsys, tmp_path):
|
|
105
|
+
from quantdb_sdk.__main__ import main as cli
|
|
106
|
+
from unittest.mock import patch
|
|
107
|
+
monkeypatch.setenv("QUANTDB_API_KEY", "cli-secret")
|
|
108
|
+
for operation in ("download", "sync"):
|
|
109
|
+
args = ["quantdb", operation, "balance", "--save-dir", str(tmp_path), "--json"]
|
|
110
|
+
if operation == "download":
|
|
111
|
+
args += ["--symbol", "600519.SH"]
|
|
112
|
+
else:
|
|
113
|
+
args += ["--after-release", "cursor"]
|
|
114
|
+
monkeypatch.setattr(sys, "argv", args)
|
|
115
|
+
with patch("quantdb_sdk.mcp_download.execute_download", return_value={"downloaded_count": 1}) as execute:
|
|
116
|
+
assert cli() == 0
|
|
117
|
+
assert execute.call_args.args[0]["operation"] == operation
|
|
118
|
+
if operation == "sync":
|
|
119
|
+
assert execute.call_args.args[0]["after_release"] == "cursor"
|
|
120
|
+
assert json.loads(capsys.readouterr().out)["downloaded_count"] == 1
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_sync_destination_rejects_symlink_escape(tmp_path):
|
|
124
|
+
from quantdb_sdk._utils import safe_join
|
|
125
|
+
outside = tmp_path / "outside"
|
|
126
|
+
root = tmp_path / "root"
|
|
127
|
+
outside.mkdir()
|
|
128
|
+
root.mkdir()
|
|
129
|
+
try:
|
|
130
|
+
(root / "linked").symlink_to(outside, target_is_directory=True)
|
|
131
|
+
except OSError:
|
|
132
|
+
pytest.skip("symlinks are unavailable on this platform")
|
|
133
|
+
with pytest.raises(ValueError, match="符号链接"):
|
|
134
|
+
safe_join(str(root), "linked/data.parquet")
|
|
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
|