quantdb-sdk 0.1.3__tar.gz → 0.2.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.
- {quantdb_sdk-0.1.3 → quantdb_sdk-0.2.0}/CHANGELOG.md +17 -0
- {quantdb_sdk-0.1.3/quantdb_sdk.egg-info → quantdb_sdk-0.2.0}/PKG-INFO +20 -2
- {quantdb_sdk-0.1.3 → quantdb_sdk-0.2.0}/README.md +21 -3
- {quantdb_sdk-0.1.3 → quantdb_sdk-0.2.0}/pyproject.toml +1 -1
- {quantdb_sdk-0.1.3 → quantdb_sdk-0.2.0}/quantdb_sdk/__init__.py +1 -1
- {quantdb_sdk-0.1.3 → quantdb_sdk-0.2.0}/quantdb_sdk/async_client.py +138 -32
- {quantdb_sdk-0.1.3 → quantdb_sdk-0.2.0}/quantdb_sdk/client.py +223 -44
- {quantdb_sdk-0.1.3 → quantdb_sdk-0.2.0/quantdb_sdk.egg-info}/PKG-INFO +20 -2
- {quantdb_sdk-0.1.3 → quantdb_sdk-0.2.0}/tests/test_async_client.py +29 -2
- {quantdb_sdk-0.1.3 → quantdb_sdk-0.2.0}/tests/test_client.py +54 -4
- {quantdb_sdk-0.1.3 → quantdb_sdk-0.2.0}/LICENSE +0 -0
- {quantdb_sdk-0.1.3 → quantdb_sdk-0.2.0}/MANIFEST.in +0 -0
- {quantdb_sdk-0.1.3 → quantdb_sdk-0.2.0}/quantdb_sdk/__main__.py +0 -0
- {quantdb_sdk-0.1.3 → quantdb_sdk-0.2.0}/quantdb_sdk/_utils.py +0 -0
- {quantdb_sdk-0.1.3 → quantdb_sdk-0.2.0}/quantdb_sdk/errors.py +0 -0
- {quantdb_sdk-0.1.3 → quantdb_sdk-0.2.0}/quantdb_sdk/py.typed +0 -0
- {quantdb_sdk-0.1.3 → quantdb_sdk-0.2.0}/quantdb_sdk.egg-info/SOURCES.txt +0 -0
- {quantdb_sdk-0.1.3 → quantdb_sdk-0.2.0}/quantdb_sdk.egg-info/dependency_links.txt +0 -0
- {quantdb_sdk-0.1.3 → quantdb_sdk-0.2.0}/quantdb_sdk.egg-info/entry_points.txt +0 -0
- {quantdb_sdk-0.1.3 → quantdb_sdk-0.2.0}/quantdb_sdk.egg-info/requires.txt +0 -0
- {quantdb_sdk-0.1.3 → quantdb_sdk-0.2.0}/quantdb_sdk.egg-info/top_level.txt +0 -0
- {quantdb_sdk-0.1.3 → quantdb_sdk-0.2.0}/setup.cfg +0 -0
- {quantdb_sdk-0.1.3 → quantdb_sdk-0.2.0}/tests/test_technical_indicators.py +0 -0
|
@@ -3,6 +3,23 @@
|
|
|
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.2.0] - 2026-07-26
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- **V1/V2 布局兼容**:同步与异步客户端的下载、Manifest、DataFrame、K 线和 Tick 接口均支持 `layout="auto" | "v1" | "v2"`。
|
|
10
|
+
- **V2 release 增量同步**:`sync_dataset()` / `a_sync_dataset()` 使用发布 cursor,同步 daily 与 patch 对象;文件 SHA-256 校验和原子落盘成功后才推进 cursor。
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- `query_kline()` 在给出日期范围时优先 V2 日切片;V2 覆盖缺日时自动模式整体回退 V1,显式 V2 则返回覆盖错误。未给日期范围时保持 V1 全历史行为。
|
|
14
|
+
- 下载接口支持 ETag 条件请求;服务端命中 `304 Not Modified` 时不传输对象正文、不扣下载流量。
|
|
15
|
+
|
|
16
|
+
## [0.1.4] - 2026-07-25
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- **用户名密码登录**:先初始化 HTTP 会话再登录,修复 `QuantDBClient(username=..., password=...)` 初始化时访问未创建会话的错误。
|
|
20
|
+
- **API Key 删除**:`delete_api_key()` / `a_delete_api_key()` 改为调用服务端实际的 `DELETE /api/v1/auth/api-keys/{id}` 接口,并明确接受 API Key ID。
|
|
21
|
+
- **流量用量**:`get_usage()` / `a_get_usage()` 将余额购买的额外流量纳入剩余流量和活跃状态计算,并新增 `purchased_gb` 返回字段。
|
|
22
|
+
|
|
6
23
|
## [0.1.3] - 2026-07-25
|
|
7
24
|
|
|
8
25
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: quantdb-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: QuantDB 量化数据平台官方 Python SDK
|
|
5
5
|
Author: QuantDB Team
|
|
6
6
|
License: MIT
|
|
@@ -88,9 +88,27 @@ client = QuantDBClient(username="admin", password="admin123")
|
|
|
88
88
|
- **账户管理**:查询用户信息、用量、API Key、订阅与订单。
|
|
89
89
|
- **异步客户端**:基于 httpx,适用于 asyncio 量化框架。
|
|
90
90
|
|
|
91
|
+
## V1 / V2 数据布局
|
|
92
|
+
|
|
93
|
+
COS 同时保留 V1(按股票历史文件)和 V2(按交易日全市场分区)。所有下载相关接口均可传入
|
|
94
|
+
`layout="auto" | "v1" | "v2"`。默认 `auto` 的规则是:给出 K 线日期范围时优先 V2;若任一
|
|
95
|
+
交易日没有 V2 分区,则整次请求回退 V1,绝不混合两种口径;未给日期范围时读取 V1 全历史文件。
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
# 按日期范围优先 V2;覆盖不完整时自动回退 V1
|
|
99
|
+
df = client.query_kline("600519.SH", start_date="2026-07-01", end_date="2026-07-24")
|
|
100
|
+
|
|
101
|
+
# 强制指定物理布局;layout="v2" 缺日时会明确报错
|
|
102
|
+
latest = client.download_file("1", "daily_forward", trade_date="2026-07-24", layout="v2")
|
|
103
|
+
history = client.download_file("1", "daily_forward", symbol="600519.SH", layout="v1")
|
|
104
|
+
|
|
105
|
+
# 以发布清单为 cursor 做原子化增量同步(含 V2 patch)
|
|
106
|
+
result = client.sync_dataset("daily_forward", save_dir="D:/quantdb-data")
|
|
107
|
+
```
|
|
108
|
+
|
|
91
109
|
## 流量说明
|
|
92
110
|
|
|
93
|
-
|
|
111
|
+
免费注册用户获赠 100 MB 一次性体验流量;订阅用户每月含 30 GB 下载流量,超出部分按 ¥1/GB 从账户余额扣减。余额不足时下载会被拦截。
|
|
94
112
|
|
|
95
113
|
## 文档
|
|
96
114
|
|
|
@@ -43,17 +43,35 @@ print(df.head())
|
|
|
43
43
|
client = QuantDBClient(username="admin", password="admin123")
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
## 核心功能
|
|
46
|
+
## 核心功能
|
|
47
47
|
|
|
48
48
|
- **数据查询**:K 线、Tick 通过下载 Parquet 切片后客户端解析(消耗流量);股票列表、交易日历、元数据走网关 JSON(不计流量)。
|
|
49
49
|
- **数据下载**:Parquet 文件下载或直读 DataFrame,计入订阅流量。
|
|
50
50
|
- **本地分析**:基于 DuckDB 对本地 Parquet 执行 SQL。
|
|
51
51
|
- **账户管理**:查询用户信息、用量、API Key、订阅与订单。
|
|
52
|
-
- **异步客户端**:基于 httpx,适用于 asyncio 量化框架。
|
|
52
|
+
- **异步客户端**:基于 httpx,适用于 asyncio 量化框架。
|
|
53
|
+
|
|
54
|
+
## V1 / V2 数据布局
|
|
55
|
+
|
|
56
|
+
COS 同时保留 V1(按股票历史文件)和 V2(按交易日全市场分区)。所有下载相关接口均可传入
|
|
57
|
+
`layout="auto" | "v1" | "v2"`。默认 `auto` 的规则是:给出 K 线日期范围时优先 V2;若任一
|
|
58
|
+
交易日没有 V2 分区,则整次请求回退 V1,绝不混合两种口径;未给日期范围时读取 V1 全历史文件。
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
# 按日期范围优先 V2;覆盖不完整时自动回退 V1
|
|
62
|
+
df = client.query_kline("600519.SH", start_date="2026-07-01", end_date="2026-07-24")
|
|
63
|
+
|
|
64
|
+
# 强制指定物理布局;layout="v2" 缺日时会明确报错
|
|
65
|
+
latest = client.download_file("1", "daily_forward", trade_date="2026-07-24", layout="v2")
|
|
66
|
+
history = client.download_file("1", "daily_forward", symbol="600519.SH", layout="v1")
|
|
67
|
+
|
|
68
|
+
# 以发布清单为 cursor 做原子化增量同步(含 V2 patch)
|
|
69
|
+
result = client.sync_dataset("daily_forward", save_dir="D:/quantdb-data")
|
|
70
|
+
```
|
|
53
71
|
|
|
54
72
|
## 流量说明
|
|
55
73
|
|
|
56
|
-
|
|
74
|
+
免费注册用户获赠 100 MB 一次性体验流量;订阅用户每月含 30 GB 下载流量,超出部分按 ¥1/GB 从账户余额扣减。余额不足时下载会被拦截。
|
|
57
75
|
|
|
58
76
|
## 文档
|
|
59
77
|
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"""QuantDB 异步 Python SDK(基于 httpx)。"""
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
import io
|
|
4
5
|
import os
|
|
5
6
|
import re
|
|
6
|
-
|
|
7
|
+
import sqlite3
|
|
8
|
+
from typing import Any, Dict, List, Optional, Literal
|
|
7
9
|
|
|
8
10
|
import httpx
|
|
9
11
|
import pandas as pd
|
|
@@ -35,7 +37,7 @@ class AsyncQuantDBClient:
|
|
|
35
37
|
):
|
|
36
38
|
self.api_host = api_host.rstrip("/")
|
|
37
39
|
self.timeout = timeout
|
|
38
|
-
headers = {"User-Agent": "QuantDB-Python-SDK/0.
|
|
40
|
+
headers = {"User-Agent": "QuantDB-Python-SDK/0.2.0"}
|
|
39
41
|
if api_key:
|
|
40
42
|
headers["X-API-Key"] = api_key
|
|
41
43
|
elif token:
|
|
@@ -58,6 +60,30 @@ class AsyncQuantDBClient:
|
|
|
58
60
|
"""清空进程内 Parquet 缓存(强制下次重新下载最新数据)。"""
|
|
59
61
|
self._cache.clear()
|
|
60
62
|
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _validate_layout(layout: str) -> Literal["auto", "v1", "v2"]:
|
|
65
|
+
if layout not in {"auto", "v1", "v2"}:
|
|
66
|
+
raise ValidationError("layout 仅支持 auto、v1 或 v2")
|
|
67
|
+
return layout # type: ignore[return-value]
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _normalise_kline(df: pd.DataFrame, start_date: Optional[str], end_date: Optional[str], fields: str, limit: Optional[int]) -> pd.DataFrame:
|
|
71
|
+
if "time" in df.columns:
|
|
72
|
+
dt = pd.to_datetime(df["time"], errors="coerce")
|
|
73
|
+
elif "trade_date" in df.columns:
|
|
74
|
+
dt = pd.to_datetime(df["trade_date"], errors="coerce")
|
|
75
|
+
else:
|
|
76
|
+
dt = pd.Series(pd.NaT, index=df.index)
|
|
77
|
+
mask = pd.Series(True, index=df.index)
|
|
78
|
+
if start_date: mask &= dt >= pd.to_datetime(start_date)
|
|
79
|
+
if end_date: mask &= dt <= pd.to_datetime(end_date)
|
|
80
|
+
result = df.loc[mask].copy()
|
|
81
|
+
result["trade_date"] = dt.loc[mask].dt.strftime("%Y-%m-%d")
|
|
82
|
+
result = result.drop(columns=["time"], errors="ignore").sort_values("trade_date", kind="stable").drop_duplicates("trade_date", keep="last")
|
|
83
|
+
wanted = [x.strip() for x in fields.split(",") if x.strip()]
|
|
84
|
+
result = result[["trade_date"] + [x for x in wanted if x in result.columns]]
|
|
85
|
+
return (result.tail(limit) if limit is not None else result).reset_index(drop=True)
|
|
86
|
+
|
|
61
87
|
async def __aenter__(self) -> "AsyncQuantDBClient":
|
|
62
88
|
return self
|
|
63
89
|
|
|
@@ -82,6 +108,10 @@ class AsyncQuantDBClient:
|
|
|
82
108
|
resp = await self._request("POST", path, json=json)
|
|
83
109
|
return self._check_response(resp)
|
|
84
110
|
|
|
111
|
+
async def _delete(self, path: str) -> dict:
|
|
112
|
+
resp = await self._request("DELETE", path)
|
|
113
|
+
return self._check_response(resp)
|
|
114
|
+
|
|
85
115
|
def _check_response(self, response: httpx.Response) -> dict:
|
|
86
116
|
if response.status_code == 200:
|
|
87
117
|
try:
|
|
@@ -123,15 +153,17 @@ class AsyncQuantDBClient:
|
|
|
123
153
|
used = data.get("used_traffic", 0)
|
|
124
154
|
limit = data.get("traffic_limit", 0)
|
|
125
155
|
credit = data.get("credit_limit", 0)
|
|
156
|
+
purchased = data.get("purchased_traffic", 0)
|
|
126
157
|
subscription = data.get("subscription", {}) or {}
|
|
127
158
|
return {
|
|
128
159
|
"used_gb": bytes_to_gb(used),
|
|
129
160
|
"limit_gb": bytes_to_gb(limit),
|
|
130
161
|
"credit_gb": bytes_to_gb(credit),
|
|
131
|
-
"
|
|
162
|
+
"purchased_gb": bytes_to_gb(purchased),
|
|
163
|
+
"remaining_gb": max(0.0, bytes_to_gb(limit + credit + purchased - used)),
|
|
132
164
|
"balance_yuan": data.get("balance_yuan", 0.0),
|
|
133
165
|
"subscription": subscription,
|
|
134
|
-
"is_active": subscription.get("status") == "active" and used < limit + credit,
|
|
166
|
+
"is_active": subscription.get("status") == "active" and used < limit + credit + purchased,
|
|
135
167
|
}
|
|
136
168
|
|
|
137
169
|
async def a_register(self, username: str, email: str, password: str) -> Dict[str, Any]:
|
|
@@ -159,16 +191,16 @@ class AsyncQuantDBClient:
|
|
|
159
191
|
"""创建新的 API Key(每个账号限制 1 个有效 Key)。"""
|
|
160
192
|
return await self._post("/api/v1/auth/api-keys", json={"description": description})
|
|
161
193
|
|
|
162
|
-
async def a_delete_api_key(self,
|
|
194
|
+
async def a_delete_api_key(self, key_id: int) -> Dict[str, Any]:
|
|
163
195
|
"""删除指定的 API Key。
|
|
164
196
|
|
|
165
197
|
Args:
|
|
166
|
-
|
|
198
|
+
key_id: ``a_list_api_keys()`` 返回的 API Key ID。
|
|
167
199
|
|
|
168
200
|
Returns:
|
|
169
201
|
删除结果信息。
|
|
170
202
|
"""
|
|
171
|
-
return await self.
|
|
203
|
+
return await self._delete(f"/api/v1/auth/api-keys/{key_id}")
|
|
172
204
|
|
|
173
205
|
# ========== 订阅与支付 ==========
|
|
174
206
|
|
|
@@ -200,31 +232,31 @@ class AsyncQuantDBClient:
|
|
|
200
232
|
end_date: Optional[str] = None,
|
|
201
233
|
fields: str = "open,high,low,close,volume,amount",
|
|
202
234
|
limit: Optional[int] = None,
|
|
235
|
+
layout: Literal["auto", "v1", "v2"] = "auto",
|
|
203
236
|
) -> pd.DataFrame:
|
|
204
237
|
"""查询 K 线数据(下载 COS parquet 切片后客户端解析,消耗下载流量,异步)。"""
|
|
238
|
+
layout = self._validate_layout(layout)
|
|
205
239
|
sub_category = f"daily_{adj_type}"
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if
|
|
214
|
-
|
|
215
|
-
if
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
df = df[
|
|
225
|
-
|
|
226
|
-
df = df.tail(limit)
|
|
227
|
-
return df.reset_index(drop=True)
|
|
240
|
+
if layout == "v1" or (layout == "auto" and not (start_date or end_date)):
|
|
241
|
+
return self._normalise_kline(await self.a_load_as_df("1", sub_category, symbol, layout="v1"), start_date, end_date, fields, limit)
|
|
242
|
+
files = await self.a_query_manifest("1", sub_category, layout="v2")
|
|
243
|
+
selected = [f for f in files if (not start_date or f.get("trade_date", "") >= start_date) and (not end_date or f.get("trade_date", "") <= end_date)]
|
|
244
|
+
calendar = await self.a_query_calendar(start_date, end_date)
|
|
245
|
+
expected = set()
|
|
246
|
+
if not calendar.empty:
|
|
247
|
+
date_col = next((c for c in ("trade_date", "date", "cal_date") if c in calendar.columns), None)
|
|
248
|
+
open_col = next((c for c in ("is_open", "is_trading_day", "open") if c in calendar.columns), None)
|
|
249
|
+
if date_col:
|
|
250
|
+
rows = calendar if not open_col else calendar[calendar[open_col].astype(str).isin(["1", "True", "true"])]
|
|
251
|
+
expected = set(pd.to_datetime(rows[date_col], errors="coerce").dropna().dt.strftime("%Y-%m-%d"))
|
|
252
|
+
if not selected or (expected and not expected.issubset({f.get("trade_date") for f in selected})):
|
|
253
|
+
if layout == "auto":
|
|
254
|
+
return self._normalise_kline(await self.a_load_as_df("1", sub_category, symbol, layout="v1"), start_date, end_date, fields, limit)
|
|
255
|
+
raise NotFoundError("V2 日切片在请求日期范围内覆盖不完整;请改用 layout='auto' 或 'v1'")
|
|
256
|
+
frames = [await self.a_load_as_df("1", sub_category, symbol, trade_date=f["trade_date"], layout="v2") for f in selected]
|
|
257
|
+
df = pd.concat(frames, ignore_index=True, sort=False)
|
|
258
|
+
if "symbol" in df.columns: df = df[df["symbol"].astype(str).str.upper() == symbol.upper()].copy()
|
|
259
|
+
return self._normalise_kline(df, start_date, end_date, fields, limit)
|
|
228
260
|
|
|
229
261
|
async def a_query_tick(
|
|
230
262
|
self,
|
|
@@ -234,9 +266,10 @@ class AsyncQuantDBClient:
|
|
|
234
266
|
end_ts: Optional[str] = None,
|
|
235
267
|
fields: str = "last_price,open,high,low,last_close,volume,amount",
|
|
236
268
|
limit: Optional[int] = None,
|
|
269
|
+
layout: Literal["auto", "v1", "v2"] = "auto",
|
|
237
270
|
) -> pd.DataFrame:
|
|
238
271
|
"""查询 Tick 分笔数据(下载 COS parquet 切片后客户端解析,消耗下载流量,异步)。"""
|
|
239
|
-
df = await self.a_load_as_df("1", "tick_data", symbol, trade_date=trade_date)
|
|
272
|
+
df = await self.a_load_as_df("1", "tick_data", symbol, trade_date=trade_date, layout=layout)
|
|
240
273
|
ts_col = "ts" if "ts" in df.columns else ("time" if "time" in df.columns else None)
|
|
241
274
|
if ts_col and (start_ts or end_ts):
|
|
242
275
|
ts = pd.to_datetime(df[ts_col], errors="coerce")
|
|
@@ -286,11 +319,13 @@ class AsyncQuantDBClient:
|
|
|
286
319
|
category_id: str,
|
|
287
320
|
sub_category: str,
|
|
288
321
|
trade_date: Optional[str] = None,
|
|
322
|
+
layout: Literal["auto", "v1", "v2"] = "auto",
|
|
289
323
|
) -> List[Dict[str, Any]]:
|
|
290
324
|
params: Dict[str, Any] = {
|
|
291
325
|
"category_id": category_id,
|
|
292
326
|
"sub_category": sub_category,
|
|
293
327
|
}
|
|
328
|
+
params["layout"] = self._validate_layout(layout)
|
|
294
329
|
if trade_date:
|
|
295
330
|
params["trade_date"] = trade_date
|
|
296
331
|
data = await self._get("/api/v1/data/download/manifest", params)
|
|
@@ -336,6 +371,8 @@ class AsyncQuantDBClient:
|
|
|
336
371
|
symbol: Optional[str] = None,
|
|
337
372
|
save_dir: Optional[str] = None,
|
|
338
373
|
trade_date: Optional[str] = None,
|
|
374
|
+
layout: Literal["auto", "v1", "v2"] = "auto",
|
|
375
|
+
object_key: Optional[str] = None,
|
|
339
376
|
) -> str:
|
|
340
377
|
if save_dir is None:
|
|
341
378
|
save_dir = default_download_dir()
|
|
@@ -347,8 +384,11 @@ class AsyncQuantDBClient:
|
|
|
347
384
|
}
|
|
348
385
|
if symbol:
|
|
349
386
|
params["symbol"] = symbol
|
|
387
|
+
params["layout"] = self._validate_layout(layout)
|
|
350
388
|
if trade_date:
|
|
351
389
|
params["trade_date"] = trade_date
|
|
390
|
+
if object_key:
|
|
391
|
+
params["object_key"] = object_key
|
|
352
392
|
|
|
353
393
|
async with self.client.stream(
|
|
354
394
|
"GET", f"{self.api_host}/api/v1/data/download", params=params
|
|
@@ -370,10 +410,12 @@ class AsyncQuantDBClient:
|
|
|
370
410
|
filename = parse_filename_from_content_disposition(cd, fallback)
|
|
371
411
|
|
|
372
412
|
save_path = os.path.join(save_dir, filename)
|
|
373
|
-
|
|
413
|
+
tmp_path = save_path + ".part"
|
|
414
|
+
with open(tmp_path, "wb") as f:
|
|
374
415
|
async for chunk in resp.aiter_bytes(chunk_size=8192):
|
|
375
416
|
if chunk:
|
|
376
417
|
f.write(chunk)
|
|
418
|
+
os.replace(tmp_path, save_path)
|
|
377
419
|
return os.path.abspath(save_path)
|
|
378
420
|
|
|
379
421
|
async def a_load_as_df(
|
|
@@ -382,20 +424,26 @@ class AsyncQuantDBClient:
|
|
|
382
424
|
sub_category: str,
|
|
383
425
|
symbol: Optional[str] = None,
|
|
384
426
|
trade_date: Optional[str] = None,
|
|
427
|
+
layout: Literal["auto", "v1", "v2"] = "auto",
|
|
428
|
+
object_key: Optional[str] = None,
|
|
385
429
|
) -> pd.DataFrame:
|
|
386
430
|
"""远端 Parquet 切片加载到内存 DataFrame(消耗下载流量,异步)。
|
|
387
431
|
|
|
388
432
|
带进程内 ETag 缓存:同对象(ETag 未变)不重复下载,避免重复计费。
|
|
389
433
|
"""
|
|
390
|
-
|
|
434
|
+
layout = self._validate_layout(layout)
|
|
435
|
+
cache_key = f"{category_id}/{sub_category}/{symbol}/{trade_date}/{layout}/{object_key or ''}"
|
|
391
436
|
params: Dict[str, Any] = {
|
|
392
437
|
"category_id": category_id,
|
|
393
438
|
"sub_category": sub_category,
|
|
394
439
|
}
|
|
395
440
|
if symbol:
|
|
396
441
|
params["symbol"] = symbol
|
|
442
|
+
params["layout"] = layout
|
|
397
443
|
if trade_date:
|
|
398
444
|
params["trade_date"] = trade_date
|
|
445
|
+
if object_key:
|
|
446
|
+
params["object_key"] = object_key
|
|
399
447
|
cached = self._cache.get(cache_key)
|
|
400
448
|
req_headers = {}
|
|
401
449
|
if cached and cached.get("etag"):
|
|
@@ -473,3 +521,61 @@ class AsyncQuantDBClient:
|
|
|
473
521
|
else:
|
|
474
522
|
sql = f"SELECT * FROM '{clean_path}' WHERE {sql}"
|
|
475
523
|
return duckdb.query(sql).df()
|
|
524
|
+
|
|
525
|
+
async def a_sync_dataset(self, dataset: str, save_dir: Optional[str] = None, after_release: Optional[str] = None) -> Dict[str, Any]:
|
|
526
|
+
"""异步版 release 增量同步;状态格式与 ``sync_dataset`` 兼容。"""
|
|
527
|
+
category_map = {
|
|
528
|
+
"daily_unadjusted": "1", "daily_forward": "1", "daily_backward": "1", "index_daily": "1",
|
|
529
|
+
"min1_kline": "1", "min5_kline": "1", "margin_trading": "2", "valuation": "5",
|
|
530
|
+
"technical_indicators": "5", "market_sentiment": "5", "features_daily": "6",
|
|
531
|
+
}
|
|
532
|
+
if dataset not in category_map: raise ValidationError(f"不支持同步的数据集: {dataset}")
|
|
533
|
+
root = os.path.abspath(save_dir or default_download_dir()); os.makedirs(root, exist_ok=True)
|
|
534
|
+
state = sqlite3.connect(os.path.join(root, "quantdb_sync.sqlite"))
|
|
535
|
+
state.execute("CREATE TABLE IF NOT EXISTS objects (key TEXT PRIMARY KEY, etag TEXT, sha256 TEXT, path TEXT, layout TEXT, dataset TEXT)")
|
|
536
|
+
cols = {row[1] for row in state.execute("PRAGMA table_info(objects)")}
|
|
537
|
+
if "dataset" not in cols: state.execute("ALTER TABLE objects ADD COLUMN dataset TEXT")
|
|
538
|
+
state.execute("CREATE TABLE IF NOT EXISTS releases (dataset TEXT PRIMARY KEY, release_id TEXT NOT NULL)")
|
|
539
|
+
downloaded: List[str] = []
|
|
540
|
+
try:
|
|
541
|
+
persisted = state.execute("SELECT release_id FROM releases WHERE dataset=?", (dataset,)).fetchone()
|
|
542
|
+
cursor = after_release if after_release is not None else (persisted[0] if persisted else "")
|
|
543
|
+
releases = (await self._get("/api/v1/data/releases", {"datasets": dataset, "after_release": cursor})).get("releases", [])
|
|
544
|
+
if releases:
|
|
545
|
+
for release in releases:
|
|
546
|
+
for obj in release.get("objects", []):
|
|
547
|
+
key, target = obj["key"], os.path.join(root, *obj["key"].split("/"))
|
|
548
|
+
old = state.execute("SELECT etag,sha256,path FROM objects WHERE key=?", (key,)).fetchone()
|
|
549
|
+
if old and old[0] == obj.get("etag") and old[1] == obj.get("sha256") and os.path.exists(old[2]): continue
|
|
550
|
+
os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
551
|
+
tmp, digest = target + ".part", hashlib.sha256()
|
|
552
|
+
async with self.client.stream("GET", f"{self.api_host}/api/v1/data/download", params={"category_id": category_map[dataset], "sub_category": dataset, "layout": "v2", "object_key": key}) as resp:
|
|
553
|
+
if resp.status_code != 200:
|
|
554
|
+
body = await resp.aread(); self._check_response(httpx.Response(resp.status_code, content=body)); raise QuantDBError("下载失败")
|
|
555
|
+
try:
|
|
556
|
+
with open(tmp, "wb") as fh:
|
|
557
|
+
async for chunk in resp.aiter_bytes(1024 * 1024):
|
|
558
|
+
if chunk: fh.write(chunk); digest.update(chunk)
|
|
559
|
+
actual = digest.hexdigest()
|
|
560
|
+
if obj.get("sha256") and actual.lower() != obj["sha256"].lower(): raise ServerError("对象 SHA-256 校验失败")
|
|
561
|
+
os.replace(tmp, target)
|
|
562
|
+
except Exception:
|
|
563
|
+
if os.path.exists(tmp): os.remove(tmp)
|
|
564
|
+
raise
|
|
565
|
+
state.execute("INSERT OR REPLACE INTO objects(key,etag,sha256,path,layout,dataset) VALUES(?,?,?,?,?,?)", (key, obj.get("etag"), actual, target, "v2_daily_partition", dataset)); downloaded.append(key)
|
|
566
|
+
state.execute("INSERT OR REPLACE INTO releases(dataset,release_id) VALUES(?,?)", (dataset, release["release_id"])); state.commit()
|
|
567
|
+
return {"dataset": dataset, "layout": "v2_daily_partition", "downloaded": downloaded, "after_release": cursor, "release_id": releases[-1]["release_id"]}
|
|
568
|
+
if persisted: return {"dataset": dataset, "layout": "v2_daily_partition", "downloaded": [], "after_release": cursor, "release_id": persisted[0]}
|
|
569
|
+
files = (await self._get("/api/v1/data/download/manifest", {"category_id": category_map[dataset], "sub_category": dataset, "layout": "v1"})).get("files", [])
|
|
570
|
+
for obj in files:
|
|
571
|
+
key, target = obj["key"], os.path.join(root, *(obj.get("relative_path") or obj["key"]).split("/"))
|
|
572
|
+
old = state.execute("SELECT etag,path FROM objects WHERE key=?", (key,)).fetchone()
|
|
573
|
+
if old and old[0] == obj.get("etag") and os.path.exists(old[1]): continue
|
|
574
|
+
await self.a_download_file(category_map[dataset], dataset, symbol=obj.get("symbol"), save_dir=os.path.dirname(target), layout="v1")
|
|
575
|
+
# 下载文件名来自服务端,按清单目标路径归位以保证本地根目录同构。
|
|
576
|
+
source = os.path.join(os.path.dirname(target), os.path.basename(target))
|
|
577
|
+
if os.path.abspath(source) != os.path.abspath(target): os.replace(source, target)
|
|
578
|
+
state.execute("INSERT OR REPLACE INTO objects(key,etag,sha256,path,layout,dataset) VALUES(?,?,?,?,?,?)", (key, obj.get("etag"), "", target, "v1_symbol", dataset)); downloaded.append(key)
|
|
579
|
+
state.commit(); return {"dataset": dataset, "layout": "v1_symbol", "downloaded": downloaded, "after_release": cursor}
|
|
580
|
+
finally:
|
|
581
|
+
state.close()
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
"""QuantDB 同步 Python SDK。"""
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
import io
|
|
4
5
|
import os
|
|
5
6
|
import re
|
|
6
|
-
|
|
7
|
+
import sqlite3
|
|
8
|
+
import glob
|
|
9
|
+
from typing import Any, Dict, List, Optional, Literal
|
|
7
10
|
|
|
8
11
|
import pandas as pd
|
|
9
12
|
import requests
|
|
@@ -42,6 +45,10 @@ class QuantDBClient:
|
|
|
42
45
|
self.timeout = timeout
|
|
43
46
|
self.token: Optional[str] = None
|
|
44
47
|
self.headers: Dict[str, str] = {}
|
|
48
|
+
self.session = requests.Session()
|
|
49
|
+
# User-Agent 中的版本与 pyproject.toml 同步,用于服务端日志归因
|
|
50
|
+
# 维护提示:每次版本号变化必须同步改这里(init 里的 __version__ 走 metadata 自动同步)
|
|
51
|
+
self.session.headers.update({"User-Agent": "QuantDB-Python-SDK/0.2.0"})
|
|
45
52
|
|
|
46
53
|
if api_key:
|
|
47
54
|
self.headers = {"X-API-Key": api_key}
|
|
@@ -50,10 +57,6 @@ class QuantDBClient:
|
|
|
50
57
|
else:
|
|
51
58
|
raise ValueError("必须提供 api_key 或 username+password")
|
|
52
59
|
|
|
53
|
-
self.session = requests.Session()
|
|
54
|
-
# User-Agent 中的版本与 pyproject.toml 同步,用于服务端日志归因
|
|
55
|
-
# 维护提示:每次版本号变化必须同步改这里(init 里的 __version__ 走 metadata 自动同步)
|
|
56
|
-
self.session.headers.update({"User-Agent": "QuantDB-Python-SDK/0.1.3"})
|
|
57
60
|
if self.headers:
|
|
58
61
|
self.session.headers.update(self.headers)
|
|
59
62
|
|
|
@@ -112,6 +115,10 @@ class QuantDBClient:
|
|
|
112
115
|
resp = self._request("POST", path, json=json)
|
|
113
116
|
return self._check_response(resp)
|
|
114
117
|
|
|
118
|
+
def _delete(self, path: str) -> dict:
|
|
119
|
+
resp = self._request("DELETE", path)
|
|
120
|
+
return self._check_response(resp)
|
|
121
|
+
|
|
115
122
|
def _check_response(self, response: requests.Response) -> dict:
|
|
116
123
|
if response.status_code == 200:
|
|
117
124
|
try:
|
|
@@ -151,6 +158,39 @@ class QuantDBClient:
|
|
|
151
158
|
"""
|
|
152
159
|
self._cache.clear()
|
|
153
160
|
|
|
161
|
+
@staticmethod
|
|
162
|
+
def _validate_layout(layout: str) -> Literal["auto", "v1", "v2"]:
|
|
163
|
+
if layout not in {"auto", "v1", "v2"}:
|
|
164
|
+
raise ValidationError("layout 仅支持 auto、v1 或 v2")
|
|
165
|
+
return layout # type: ignore[return-value]
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def _normalise_kline(df: pd.DataFrame, start_date: Optional[str], end_date: Optional[str], fields: str, limit: Optional[int]) -> pd.DataFrame:
|
|
169
|
+
"""统一 V1/V2 日线结果并在客户端按标的、日期与字段过滤。"""
|
|
170
|
+
if "time" in df.columns:
|
|
171
|
+
dt = pd.to_datetime(df["time"], errors="coerce")
|
|
172
|
+
if getattr(dt.dt, "tz", None) is not None:
|
|
173
|
+
dt = dt.dt.tz_convert(None)
|
|
174
|
+
elif "trade_date" in df.columns:
|
|
175
|
+
dt = pd.to_datetime(df["trade_date"], errors="coerce")
|
|
176
|
+
else:
|
|
177
|
+
dt = pd.Series(pd.NaT, index=df.index)
|
|
178
|
+
mask = pd.Series(True, index=df.index)
|
|
179
|
+
if start_date:
|
|
180
|
+
mask &= dt >= pd.to_datetime(start_date)
|
|
181
|
+
if end_date:
|
|
182
|
+
mask &= dt <= pd.to_datetime(end_date)
|
|
183
|
+
result = df.loc[mask].copy()
|
|
184
|
+
result["trade_date"] = dt.loc[mask].dt.strftime("%Y-%m-%d")
|
|
185
|
+
result = result.drop(columns=["time"], errors="ignore")
|
|
186
|
+
result = result.sort_values("trade_date", kind="stable").drop_duplicates("trade_date", keep="last")
|
|
187
|
+
wanted = [x.strip() for x in fields.split(",") if x.strip()]
|
|
188
|
+
columns = ["trade_date"] + [x for x in wanted if x in result.columns]
|
|
189
|
+
result = result.loc[:, list(dict.fromkeys(columns))]
|
|
190
|
+
if limit is not None:
|
|
191
|
+
result = result.tail(limit)
|
|
192
|
+
return result.reset_index(drop=True)
|
|
193
|
+
|
|
154
194
|
# ========== 账户信息 ==========
|
|
155
195
|
|
|
156
196
|
def get_me(self) -> Dict[str, Any]:
|
|
@@ -163,15 +203,17 @@ class QuantDBClient:
|
|
|
163
203
|
used = data.get("used_traffic", 0)
|
|
164
204
|
limit = data.get("traffic_limit", 0)
|
|
165
205
|
credit = data.get("credit_limit", 0)
|
|
206
|
+
purchased = data.get("purchased_traffic", 0)
|
|
166
207
|
subscription = data.get("subscription", {}) or {}
|
|
167
208
|
return {
|
|
168
209
|
"used_gb": bytes_to_gb(used),
|
|
169
210
|
"limit_gb": bytes_to_gb(limit),
|
|
170
211
|
"credit_gb": bytes_to_gb(credit),
|
|
171
|
-
"
|
|
212
|
+
"purchased_gb": bytes_to_gb(purchased),
|
|
213
|
+
"remaining_gb": max(0.0, bytes_to_gb(limit + credit + purchased - used)),
|
|
172
214
|
"balance_yuan": data.get("balance_yuan", 0.0),
|
|
173
215
|
"subscription": subscription,
|
|
174
|
-
"is_active": subscription.get("status") == "active" and used < limit + credit,
|
|
216
|
+
"is_active": subscription.get("status") == "active" and used < limit + credit + purchased,
|
|
175
217
|
}
|
|
176
218
|
|
|
177
219
|
def register(self, username: str, email: str, password: str) -> Dict[str, Any]:
|
|
@@ -203,16 +245,16 @@ class QuantDBClient:
|
|
|
203
245
|
"""创建新的 API Key(每个账号限制 1 个有效 Key)。"""
|
|
204
246
|
return self._post("/api/v1/auth/api-keys", json={"description": description})
|
|
205
247
|
|
|
206
|
-
def delete_api_key(self,
|
|
248
|
+
def delete_api_key(self, key_id: int) -> Dict[str, Any]:
|
|
207
249
|
"""删除指定的 API Key。
|
|
208
250
|
|
|
209
251
|
Args:
|
|
210
|
-
|
|
252
|
+
key_id: ``list_api_keys()`` 返回的 API Key ID。
|
|
211
253
|
|
|
212
254
|
Returns:
|
|
213
255
|
删除结果信息。
|
|
214
256
|
"""
|
|
215
|
-
return self.
|
|
257
|
+
return self._delete(f"/api/v1/auth/api-keys/{key_id}")
|
|
216
258
|
|
|
217
259
|
# ========== 订阅与支付 ==========
|
|
218
260
|
|
|
@@ -247,37 +289,42 @@ class QuantDBClient:
|
|
|
247
289
|
end_date: Optional[str] = None,
|
|
248
290
|
fields: str = "open,high,low,close,volume,amount",
|
|
249
291
|
limit: Optional[int] = None,
|
|
292
|
+
layout: Literal["auto", "v1", "v2"] = "auto",
|
|
250
293
|
) -> pd.DataFrame:
|
|
251
294
|
"""查询 K 线数据(下载 COS parquet 切片后客户端解析,消耗下载流量)。
|
|
252
295
|
|
|
253
|
-
|
|
254
|
-
|
|
296
|
+
``auto`` 在提供日期范围时优先 V2 全市场日分区;如 V2 覆盖不完整,
|
|
297
|
+
整次回退 V1 股票历史文件。未提供日期范围时直接使用 V1,以保持旧版
|
|
298
|
+
``query_kline`` 的全历史语义。显式 ``v2`` 不会静默回退。
|
|
255
299
|
"""
|
|
300
|
+
layout = self._validate_layout(layout)
|
|
256
301
|
sub_category = f"daily_{adj_type}"
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
if
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
302
|
+
has_range = bool(start_date or end_date)
|
|
303
|
+
if layout == "v1" or (layout == "auto" and not has_range):
|
|
304
|
+
return self._normalise_kline(self.load_as_df("1", sub_category, symbol, layout="v1"), start_date, end_date, fields, limit)
|
|
305
|
+
|
|
306
|
+
files = self.query_manifest("1", sub_category, layout="v2")
|
|
307
|
+
selected = [f for f in files if (not start_date or f.get("trade_date", "") >= start_date) and (not end_date or f.get("trade_date", "") <= end_date)]
|
|
308
|
+
# 日历是 V2 完整性的权威。无法得到日历或任一开市日缺分区时,auto 回退 V1;v2 明确报错。
|
|
309
|
+
calendar = self.query_calendar(start_date, end_date)
|
|
310
|
+
expected = set()
|
|
311
|
+
if not calendar.empty:
|
|
312
|
+
date_col = next((c for c in ("trade_date", "date", "cal_date") if c in calendar.columns), None)
|
|
313
|
+
open_col = next((c for c in ("is_open", "is_trading_day", "open") if c in calendar.columns), None)
|
|
314
|
+
if date_col:
|
|
315
|
+
rows = calendar if not open_col else calendar[calendar[open_col].astype(str).isin(["1", "True", "true"])]
|
|
316
|
+
expected = set(pd.to_datetime(rows[date_col], errors="coerce").dropna().dt.strftime("%Y-%m-%d"))
|
|
317
|
+
found = {f.get("trade_date") for f in selected}
|
|
318
|
+
complete = bool(selected) and (not expected or expected.issubset(found))
|
|
319
|
+
if not complete:
|
|
320
|
+
if layout == "auto":
|
|
321
|
+
return self._normalise_kline(self.load_as_df("1", sub_category, symbol, layout="v1"), start_date, end_date, fields, limit)
|
|
322
|
+
raise NotFoundError("V2 日切片在请求日期范围内覆盖不完整;请改用 layout='auto' 或 'v1'")
|
|
323
|
+
frames = [self.load_as_df("1", sub_category, symbol, trade_date=f["trade_date"], layout="v2") for f in selected]
|
|
324
|
+
df = pd.concat(frames, ignore_index=True, sort=False)
|
|
325
|
+
if "symbol" in df.columns:
|
|
326
|
+
df = df[df["symbol"].astype(str).str.upper() == symbol.upper()].copy()
|
|
327
|
+
return self._normalise_kline(df, start_date, end_date, fields, limit)
|
|
281
328
|
|
|
282
329
|
def query_tick(
|
|
283
330
|
self,
|
|
@@ -287,13 +334,14 @@ class QuantDBClient:
|
|
|
287
334
|
end_ts: Optional[str] = None,
|
|
288
335
|
fields: str = "last_price,open,high,low,last_close,volume,amount",
|
|
289
336
|
limit: Optional[int] = None,
|
|
337
|
+
layout: Literal["auto", "v1", "v2"] = "auto",
|
|
290
338
|
) -> pd.DataFrame:
|
|
291
339
|
"""查询 Tick 分笔数据(下载 COS parquet 切片后客户端解析,消耗下载流量)。
|
|
292
340
|
|
|
293
341
|
下载 trade_date 当日该 symbol 的 tick parquet,按 start_ts/end_ts 过滤时间、按 fields 选列。
|
|
294
342
|
start_ts/end_ts 可传完整时间戳或 "HH:MM:SS"(自动补 trade_date 日期)。
|
|
295
343
|
"""
|
|
296
|
-
df = self.load_as_df("1", "tick_data", symbol, trade_date=trade_date)
|
|
344
|
+
df = self.load_as_df("1", "tick_data", symbol, trade_date=trade_date, layout=layout)
|
|
297
345
|
# 时间过滤
|
|
298
346
|
ts_col = "ts" if "ts" in df.columns else ("time" if "time" in df.columns else None)
|
|
299
347
|
if ts_col and (start_ts or end_ts):
|
|
@@ -347,12 +395,14 @@ class QuantDBClient:
|
|
|
347
395
|
category_id: str,
|
|
348
396
|
sub_category: str,
|
|
349
397
|
trade_date: Optional[str] = None,
|
|
398
|
+
layout: Literal["auto", "v1", "v2"] = "auto",
|
|
350
399
|
) -> List[Dict[str, Any]]:
|
|
351
400
|
"""查询 COS 可下载文件清单。"""
|
|
352
401
|
params: Dict[str, Any] = {
|
|
353
402
|
"category_id": category_id,
|
|
354
403
|
"sub_category": sub_category,
|
|
355
404
|
}
|
|
405
|
+
params["layout"] = self._validate_layout(layout)
|
|
356
406
|
if trade_date:
|
|
357
407
|
params["trade_date"] = trade_date
|
|
358
408
|
data = self._get("/api/v1/data/download/manifest", params)
|
|
@@ -404,10 +454,12 @@ class QuantDBClient:
|
|
|
404
454
|
symbol: Optional[str] = None,
|
|
405
455
|
save_dir: Optional[str] = None,
|
|
406
456
|
trade_date: Optional[str] = None,
|
|
457
|
+
layout: Literal["auto", "v1", "v2"] = "auto",
|
|
458
|
+
object_key: Optional[str] = None,
|
|
407
459
|
) -> str:
|
|
408
460
|
"""流式下载原始 Parquet 切片到本地,返回保存路径(消耗下载流量)。
|
|
409
461
|
|
|
410
|
-
|
|
462
|
+
``object_key`` 仅供 release 增量同步使用;服务端会校验它属于请求的数据集前缀。
|
|
411
463
|
"""
|
|
412
464
|
if save_dir is None:
|
|
413
465
|
save_dir = default_download_dir()
|
|
@@ -419,8 +471,11 @@ class QuantDBClient:
|
|
|
419
471
|
}
|
|
420
472
|
if symbol:
|
|
421
473
|
params["symbol"] = symbol
|
|
474
|
+
params["layout"] = self._validate_layout(layout)
|
|
422
475
|
if trade_date:
|
|
423
476
|
params["trade_date"] = trade_date
|
|
477
|
+
if object_key:
|
|
478
|
+
params["object_key"] = object_key
|
|
424
479
|
|
|
425
480
|
resp = self._request(
|
|
426
481
|
"GET", "/api/v1/data/download", params=params, stream=True
|
|
@@ -436,10 +491,12 @@ class QuantDBClient:
|
|
|
436
491
|
)
|
|
437
492
|
|
|
438
493
|
save_path = os.path.join(save_dir, filename)
|
|
439
|
-
|
|
494
|
+
tmp_path = save_path + ".part"
|
|
495
|
+
with open(tmp_path, "wb") as f:
|
|
440
496
|
for chunk in resp.iter_content(chunk_size=8192):
|
|
441
497
|
if chunk:
|
|
442
498
|
f.write(chunk)
|
|
499
|
+
os.replace(tmp_path, save_path)
|
|
443
500
|
return os.path.abspath(save_path)
|
|
444
501
|
|
|
445
502
|
def load_as_df(
|
|
@@ -448,6 +505,8 @@ class QuantDBClient:
|
|
|
448
505
|
sub_category: str,
|
|
449
506
|
symbol: Optional[str] = None,
|
|
450
507
|
trade_date: Optional[str] = None,
|
|
508
|
+
layout: Literal["auto", "v1", "v2"] = "auto",
|
|
509
|
+
object_key: Optional[str] = None,
|
|
451
510
|
) -> pd.DataFrame:
|
|
452
511
|
"""将远端 Parquet 切片直接加载到内存 DataFrame(不落盘,消耗下载流量)。
|
|
453
512
|
|
|
@@ -456,18 +515,20 @@ class QuantDBClient:
|
|
|
456
515
|
带进程内 ETag 缓存:同一对象(ETag 未变)不重复下载,避免对同一 symbol
|
|
457
516
|
多次查询时重复消耗流量。
|
|
458
517
|
"""
|
|
459
|
-
|
|
518
|
+
layout = self._validate_layout(layout)
|
|
519
|
+
cache_key = f"{category_id}/{sub_category}/{symbol}/{trade_date}/{layout}/{object_key or ''}"
|
|
460
520
|
params: Dict[str, Any] = {
|
|
461
521
|
"category_id": category_id,
|
|
462
522
|
"sub_category": sub_category,
|
|
463
523
|
}
|
|
464
524
|
if symbol:
|
|
465
525
|
params["symbol"] = symbol
|
|
526
|
+
params["layout"] = layout
|
|
466
527
|
if trade_date:
|
|
467
528
|
params["trade_date"] = trade_date
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
#
|
|
529
|
+
if object_key:
|
|
530
|
+
params["object_key"] = object_key
|
|
531
|
+
# 若已有缓存,带 If-None-Match;服务端 ETag 命中时返回 304,不扣下载流量。
|
|
471
532
|
cached = self._cache.get(cache_key)
|
|
472
533
|
headers = {}
|
|
473
534
|
if cached and cached.get("etag"):
|
|
@@ -551,6 +612,125 @@ class QuantDBClient:
|
|
|
551
612
|
"""获取 DuckDB 本地数据仓库实例,用于离线多表 SQL JOIN 查询。"""
|
|
552
613
|
return DuckDBWarehouse(client=self, save_dir=save_dir)
|
|
553
614
|
|
|
615
|
+
def sync_dataset(self, dataset: str, save_dir: Optional[str] = None, after_release: Optional[str] = None) -> Dict[str, Any]:
|
|
616
|
+
"""按 release 增量同步 V2;无可用 V2 release 时回退 V1 Manifest。"""
|
|
617
|
+
root = os.path.abspath(save_dir or default_download_dir())
|
|
618
|
+
os.makedirs(root, exist_ok=True)
|
|
619
|
+
state = sqlite3.connect(os.path.join(root, "quantdb_sync.sqlite"))
|
|
620
|
+
state.execute("CREATE TABLE IF NOT EXISTS objects (key TEXT PRIMARY KEY, etag TEXT, sha256 TEXT, path TEXT, layout TEXT, dataset TEXT)")
|
|
621
|
+
cols = {row[1] for row in state.execute("PRAGMA table_info(objects)")}
|
|
622
|
+
if "dataset" not in cols:
|
|
623
|
+
state.execute("ALTER TABLE objects ADD COLUMN dataset TEXT")
|
|
624
|
+
state.execute("CREATE TABLE IF NOT EXISTS releases (dataset TEXT PRIMARY KEY, release_id TEXT NOT NULL)")
|
|
625
|
+
category_map = {
|
|
626
|
+
"daily_unadjusted": "1", "daily_forward": "1", "daily_backward": "1",
|
|
627
|
+
"index_daily": "1", "min1_kline": "1", "min5_kline": "1",
|
|
628
|
+
"margin_trading": "2", "valuation": "5", "technical_indicators": "5",
|
|
629
|
+
"market_sentiment": "5", "features_daily": "6",
|
|
630
|
+
}
|
|
631
|
+
if dataset not in category_map:
|
|
632
|
+
raise ValidationError(f"不支持同步的数据集: {dataset}")
|
|
633
|
+
|
|
634
|
+
downloaded: List[str] = []
|
|
635
|
+
try:
|
|
636
|
+
persisted = state.execute("SELECT release_id FROM releases WHERE dataset=?", (dataset,)).fetchone()
|
|
637
|
+
cursor = after_release if after_release is not None else (persisted[0] if persisted else "")
|
|
638
|
+
release_data = self._get("/api/v1/data/releases", {"datasets": dataset, "after_release": cursor})
|
|
639
|
+
releases = release_data.get("releases", [])
|
|
640
|
+
if releases:
|
|
641
|
+
for release in releases:
|
|
642
|
+
release_id = release["release_id"]
|
|
643
|
+
for obj in release.get("objects", []):
|
|
644
|
+
key = obj["key"]
|
|
645
|
+
relative_path = obj.get("relative_path") or key
|
|
646
|
+
target = os.path.join(root, *relative_path.split("/"))
|
|
647
|
+
old = state.execute("SELECT etag, sha256, path FROM objects WHERE key=?", (key,)).fetchone()
|
|
648
|
+
if old and old[0] == obj.get("etag") and old[1] == obj.get("sha256") and os.path.exists(old[2]):
|
|
649
|
+
continue
|
|
650
|
+
resp = self._request("GET", "/api/v1/data/download", params={"category_id": category_map[dataset], "sub_category": dataset, "layout": "v2", "object_key": key}, stream=True)
|
|
651
|
+
if resp.status_code != 200:
|
|
652
|
+
self._check_response(resp)
|
|
653
|
+
os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
654
|
+
tmp, digest = target + ".part", hashlib.sha256()
|
|
655
|
+
try:
|
|
656
|
+
with open(tmp, "wb") as fh:
|
|
657
|
+
for chunk in resp.iter_content(1024 * 1024):
|
|
658
|
+
if chunk:
|
|
659
|
+
fh.write(chunk); digest.update(chunk)
|
|
660
|
+
actual = digest.hexdigest()
|
|
661
|
+
if obj.get("sha256") and actual.lower() != obj["sha256"].lower():
|
|
662
|
+
raise ServerError("对象 SHA-256 校验失败")
|
|
663
|
+
os.replace(tmp, target)
|
|
664
|
+
except Exception:
|
|
665
|
+
if os.path.exists(tmp): os.remove(tmp)
|
|
666
|
+
raise
|
|
667
|
+
state.execute("INSERT OR REPLACE INTO objects(key,etag,sha256,path,layout,dataset) VALUES(?,?,?,?,?,?)", (key, obj.get("etag"), actual, target, "v2_daily_partition", dataset))
|
|
668
|
+
downloaded.append(key)
|
|
669
|
+
# 仅在该 release 的所有对象成功落盘和校验后提交 cursor。
|
|
670
|
+
state.execute("INSERT OR REPLACE INTO releases(dataset,release_id) VALUES(?,?)", (dataset, release_id))
|
|
671
|
+
state.commit()
|
|
672
|
+
return {"dataset": dataset, "layout": "v2_daily_partition", "downloaded": downloaded, "after_release": cursor, "release_id": releases[-1]["release_id"]}
|
|
673
|
+
|
|
674
|
+
if persisted:
|
|
675
|
+
return {"dataset": dataset, "layout": "v2_daily_partition", "downloaded": [], "after_release": cursor, "release_id": persisted[0]}
|
|
676
|
+
|
|
677
|
+
manifest = self._get("/api/v1/data/download/manifest", {"category_id": category_map[dataset], "sub_category": dataset, "layout": "v1"})
|
|
678
|
+
for obj in manifest.get("files", []):
|
|
679
|
+
key, relative_path = obj["key"], obj.get("relative_path") or obj["key"]
|
|
680
|
+
target = os.path.join(root, *relative_path.split("/"))
|
|
681
|
+
old = state.execute("SELECT etag, path FROM objects WHERE key=?", (key,)).fetchone()
|
|
682
|
+
if old and old[0] == obj.get("etag") and os.path.exists(old[1]):
|
|
683
|
+
continue
|
|
684
|
+
resp = self._request("GET", "/api/v1/data/download", params={"category_id": category_map[dataset], "sub_category": dataset, "layout": "v1", "symbol": obj.get("symbol", "")}, stream=True)
|
|
685
|
+
if resp.status_code != 200: self._check_response(resp)
|
|
686
|
+
os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
687
|
+
tmp = target + ".part"
|
|
688
|
+
try:
|
|
689
|
+
with open(tmp, "wb") as fh:
|
|
690
|
+
for chunk in resp.iter_content(1024 * 1024):
|
|
691
|
+
if chunk: fh.write(chunk)
|
|
692
|
+
os.replace(tmp, target)
|
|
693
|
+
except Exception:
|
|
694
|
+
if os.path.exists(tmp): os.remove(tmp)
|
|
695
|
+
raise
|
|
696
|
+
state.execute("INSERT OR REPLACE INTO objects(key,etag,sha256,path,layout,dataset) VALUES(?,?,?,?,?,?)", (key, obj.get("etag"), "", target, "v1_symbol", dataset))
|
|
697
|
+
downloaded.append(key)
|
|
698
|
+
state.commit()
|
|
699
|
+
return {"dataset": dataset, "layout": "v1_symbol", "downloaded": downloaded, "after_release": cursor}
|
|
700
|
+
finally:
|
|
701
|
+
state.close()
|
|
702
|
+
|
|
703
|
+
def mount_local_dataset(self, dataset: str, save_dir: Optional[str] = None, view_name: Optional[str] = None) -> "DuckDBWarehouse":
|
|
704
|
+
"""挂载本地 V1/V2 同构数据集;V2 patch 与 daily 文件均参与去重。"""
|
|
705
|
+
warehouse = self.get_local_warehouse(save_dir)
|
|
706
|
+
root = os.path.abspath(save_dir or default_download_dir()).replace("\\", "/")
|
|
707
|
+
views = {"daily_forward":"1_kline_data/daily_forward", "valuation":"5_technical_derived/valuation", "technical_indicators":"5_technical_derived/technical_indicators", "market_sentiment":"5_technical_derived/market_sentiment", "features_daily":"6_ml_datasets/features_daily"}
|
|
708
|
+
if dataset not in views: raise ValidationError(f"不支持的 V2 数据集: {dataset}")
|
|
709
|
+
name = view_name or re.sub(r"[^a-zA-Z0-9_]", "_", dataset)
|
|
710
|
+
state_path = os.path.join(root.replace("/", os.sep), "quantdb_sync.sqlite")
|
|
711
|
+
files: List[str] = []
|
|
712
|
+
if os.path.exists(state_path):
|
|
713
|
+
conn = sqlite3.connect(state_path)
|
|
714
|
+
try:
|
|
715
|
+
files = [row[0] for row in conn.execute("SELECT path FROM objects WHERE dataset=? AND path IS NOT NULL", (dataset,)) if os.path.exists(row[0])]
|
|
716
|
+
finally:
|
|
717
|
+
conn.close()
|
|
718
|
+
if not files:
|
|
719
|
+
daily = f"{root}/{views[dataset]}/dt=*/data.parquet"
|
|
720
|
+
v1 = f"{root}/{views[dataset]}/*.parquet"
|
|
721
|
+
files = glob.glob(daily) or glob.glob(v1)
|
|
722
|
+
if not files: raise NotFoundError(f"本地尚未同步数据集: {dataset}")
|
|
723
|
+
quoted = ", ".join("'" + p.replace("'", "''") + "'" for p in files)
|
|
724
|
+
relation = f"read_parquet([{quoted}], union_by_name=true)"
|
|
725
|
+
columns = {row[0] for row in warehouse.conn.execute(f"DESCRIBE SELECT * FROM {relation}").fetchall()}
|
|
726
|
+
if {"symbol", "time"}.issubset(columns):
|
|
727
|
+
order = "coalesce(release_id, '') DESC" if "release_id" in columns else "0"
|
|
728
|
+
warehouse.conn.execute(f"CREATE OR REPLACE VIEW {name} AS SELECT * EXCLUDE (rn) FROM (SELECT *, row_number() OVER (PARTITION BY symbol, time ORDER BY {order}) rn FROM {relation}) WHERE rn=1")
|
|
729
|
+
else:
|
|
730
|
+
warehouse.conn.execute(f"CREATE OR REPLACE VIEW {name} AS SELECT * FROM {relation}")
|
|
731
|
+
warehouse._views[name] = f"{dataset}"
|
|
732
|
+
return warehouse
|
|
733
|
+
|
|
554
734
|
|
|
555
735
|
class DuckDBWarehouse:
|
|
556
736
|
"""DuckDB 本地仓库管理,支持自动下载数据并注册为 DuckDB 视图,方便多表复杂 SQL 查询。"""
|
|
@@ -609,4 +789,3 @@ class DuckDBWarehouse:
|
|
|
609
789
|
def list_views(self) -> Dict[str, str]:
|
|
610
790
|
"""列出当前已注册的视图及其对应本地路径。"""
|
|
611
791
|
return dict(self._views)
|
|
612
|
-
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: quantdb-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: QuantDB 量化数据平台官方 Python SDK
|
|
5
5
|
Author: QuantDB Team
|
|
6
6
|
License: MIT
|
|
@@ -88,9 +88,27 @@ client = QuantDBClient(username="admin", password="admin123")
|
|
|
88
88
|
- **账户管理**:查询用户信息、用量、API Key、订阅与订单。
|
|
89
89
|
- **异步客户端**:基于 httpx,适用于 asyncio 量化框架。
|
|
90
90
|
|
|
91
|
+
## V1 / V2 数据布局
|
|
92
|
+
|
|
93
|
+
COS 同时保留 V1(按股票历史文件)和 V2(按交易日全市场分区)。所有下载相关接口均可传入
|
|
94
|
+
`layout="auto" | "v1" | "v2"`。默认 `auto` 的规则是:给出 K 线日期范围时优先 V2;若任一
|
|
95
|
+
交易日没有 V2 分区,则整次请求回退 V1,绝不混合两种口径;未给日期范围时读取 V1 全历史文件。
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
# 按日期范围优先 V2;覆盖不完整时自动回退 V1
|
|
99
|
+
df = client.query_kline("600519.SH", start_date="2026-07-01", end_date="2026-07-24")
|
|
100
|
+
|
|
101
|
+
# 强制指定物理布局;layout="v2" 缺日时会明确报错
|
|
102
|
+
latest = client.download_file("1", "daily_forward", trade_date="2026-07-24", layout="v2")
|
|
103
|
+
history = client.download_file("1", "daily_forward", symbol="600519.SH", layout="v1")
|
|
104
|
+
|
|
105
|
+
# 以发布清单为 cursor 做原子化增量同步(含 V2 patch)
|
|
106
|
+
result = client.sync_dataset("daily_forward", save_dir="D:/quantdb-data")
|
|
107
|
+
```
|
|
108
|
+
|
|
91
109
|
## 流量说明
|
|
92
110
|
|
|
93
|
-
|
|
111
|
+
免费注册用户获赠 100 MB 一次性体验流量;订阅用户每月含 30 GB 下载流量,超出部分按 ¥1/GB 从账户余额扣减。余额不足时下载会被拦截。
|
|
94
112
|
|
|
95
113
|
## 文档
|
|
96
114
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""QuantDB 异步 SDK 单元测试(使用 respx mock HTTP)。"""
|
|
2
2
|
|
|
3
3
|
import io
|
|
4
|
+
from urllib.parse import parse_qs, urlparse
|
|
4
5
|
|
|
5
6
|
import httpx
|
|
6
7
|
import pandas as pd
|
|
@@ -40,6 +41,29 @@ async def test_a_query_kline():
|
|
|
40
41
|
assert route.called
|
|
41
42
|
|
|
42
43
|
|
|
44
|
+
@pytest.mark.asyncio
|
|
45
|
+
@respx.mock
|
|
46
|
+
async def test_a_query_kline_without_range_forces_v1_layout():
|
|
47
|
+
route = respx.get(f"{API_HOST}/api/v1/data/download").mock(return_value=httpx.Response(200, content=_kline_parquet()))
|
|
48
|
+
async with AsyncQuantDBClient(api_host=API_HOST, api_key="test-key") as client:
|
|
49
|
+
await client.a_query_kline("600519.SH")
|
|
50
|
+
assert parse_qs(urlparse(str(route.calls[0].request.url)).query)["layout"] == ["v1"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.mark.asyncio
|
|
54
|
+
@respx.mock
|
|
55
|
+
async def test_a_sync_dataset_uses_release_cursor(tmp_path):
|
|
56
|
+
payload = b"release-parquet"
|
|
57
|
+
digest = __import__("hashlib").sha256(payload).hexdigest()
|
|
58
|
+
respx.get(f"{API_HOST}/api/v1/data/releases").mock(return_value=httpx.Response(200, json={"releases": [{"release_id": "20260726_180000", "objects": [{"key": "1_kline_data/daily_forward/dt=20260726/data.parquet", "etag": "etag-1", "sha256": digest}]}]}))
|
|
59
|
+
route = respx.get(f"{API_HOST}/api/v1/data/download").mock(return_value=httpx.Response(200, content=payload))
|
|
60
|
+
async with AsyncQuantDBClient(api_host=API_HOST, api_key="test-key") as client:
|
|
61
|
+
result = await client.a_sync_dataset("daily_forward", str(tmp_path))
|
|
62
|
+
assert result["layout"] == "v2_daily_partition"
|
|
63
|
+
assert (tmp_path / "1_kline_data" / "daily_forward" / "dt=20260726" / "data.parquet").read_bytes() == payload
|
|
64
|
+
assert parse_qs(urlparse(str(route.calls[0].request.url)).query)["object_key"] == ["1_kline_data/daily_forward/dt=20260726/data.parquet"]
|
|
65
|
+
|
|
66
|
+
|
|
43
67
|
@pytest.mark.asyncio
|
|
44
68
|
@respx.mock
|
|
45
69
|
async def test_a_auth_error_raises_auth_error():
|
|
@@ -72,6 +96,7 @@ async def test_a_get_usage_with_subscription():
|
|
|
72
96
|
"used_traffic": 1073741824,
|
|
73
97
|
"traffic_limit": 53687091200,
|
|
74
98
|
"credit_limit": 0,
|
|
99
|
+
"purchased_traffic": 1073741824,
|
|
75
100
|
"balance_yuan": 0.0,
|
|
76
101
|
"subscription": {"plan_id": "pro-annual", "status": "active"},
|
|
77
102
|
},
|
|
@@ -81,6 +106,8 @@ async def test_a_get_usage_with_subscription():
|
|
|
81
106
|
usage = await client.a_get_usage()
|
|
82
107
|
assert usage["used_gb"] == 1.0
|
|
83
108
|
assert usage["limit_gb"] == 50.0
|
|
109
|
+
assert usage["purchased_gb"] == 1.0
|
|
110
|
+
assert usage["remaining_gb"] == 50.0
|
|
84
111
|
assert usage["subscription"]["status"] == "active"
|
|
85
112
|
assert usage["is_active"] is True
|
|
86
113
|
|
|
@@ -115,11 +142,11 @@ async def test_a_create_order():
|
|
|
115
142
|
@pytest.mark.asyncio
|
|
116
143
|
@respx.mock
|
|
117
144
|
async def test_a_delete_api_key():
|
|
118
|
-
respx.
|
|
145
|
+
respx.delete(f"{API_HOST}/api/v1/auth/api-keys/42").mock(
|
|
119
146
|
return_value=httpx.Response(200, json={"message": "API Key 撤销成功"})
|
|
120
147
|
)
|
|
121
148
|
async with AsyncQuantDBClient(api_host=API_HOST, api_key="test-key") as client:
|
|
122
|
-
result = await client.a_delete_api_key(
|
|
149
|
+
result = await client.a_delete_api_key(42)
|
|
123
150
|
assert result["message"] == "API Key 撤销成功"
|
|
124
151
|
|
|
125
152
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""QuantDB 同步 SDK 单元测试(使用 responses mock HTTP)。"""
|
|
2
2
|
|
|
3
3
|
import io
|
|
4
|
+
from urllib.parse import parse_qs, urlparse
|
|
4
5
|
|
|
5
6
|
import pandas as pd
|
|
6
7
|
import pytest
|
|
@@ -42,6 +43,53 @@ def test_query_kline():
|
|
|
42
43
|
assert len(df) == 2
|
|
43
44
|
|
|
44
45
|
|
|
46
|
+
@responses.activate
|
|
47
|
+
def test_query_kline_without_range_forces_v1_layout():
|
|
48
|
+
responses.get(f"{API_HOST}/api/v1/data/download", body=_kline_parquet(), status=200)
|
|
49
|
+
client = QuantDBClient(api_host=API_HOST, api_key="test-key")
|
|
50
|
+
client.query_kline("600519.SH")
|
|
51
|
+
params = parse_qs(urlparse(responses.calls[0].request.url).query)
|
|
52
|
+
assert params["layout"] == ["v1"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@responses.activate
|
|
56
|
+
def test_query_kline_range_reads_all_v2_days():
|
|
57
|
+
responses.get(
|
|
58
|
+
f"{API_HOST}/api/v1/data/download/manifest",
|
|
59
|
+
json={"files": [{"trade_date": "2025-01-02"}, {"trade_date": "2025-01-03"}]}, status=200,
|
|
60
|
+
)
|
|
61
|
+
responses.get(
|
|
62
|
+
f"{API_HOST}/api/v1/data/calendar",
|
|
63
|
+
json={"data": [{"trade_date": "2025-01-02", "is_open": 1}, {"trade_date": "2025-01-03", "is_open": 1}]}, status=200,
|
|
64
|
+
)
|
|
65
|
+
def download_cb(request):
|
|
66
|
+
date = parse_qs(urlparse(request.url).query)["trade_date"][0]
|
|
67
|
+
buf = io.BytesIO()
|
|
68
|
+
pd.DataFrame({"symbol": ["600519.SH"], "time": [date], "open": [1.0], "close": [2.0]}).to_parquet(buf)
|
|
69
|
+
return 200, {"ETag": date}, buf.getvalue()
|
|
70
|
+
responses.add_callback(responses.GET, f"{API_HOST}/api/v1/data/download", callback=download_cb)
|
|
71
|
+
client = QuantDBClient(api_host=API_HOST, api_key="test-key")
|
|
72
|
+
df = client.query_kline("600519.SH", start_date="2025-01-02", end_date="2025-01-03")
|
|
73
|
+
assert df["trade_date"].tolist() == ["2025-01-02", "2025-01-03"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@responses.activate
|
|
77
|
+
def test_sync_dataset_uses_release_cursor_and_object_key(tmp_path):
|
|
78
|
+
payload = b"release-parquet"
|
|
79
|
+
digest = __import__("hashlib").sha256(payload).hexdigest()
|
|
80
|
+
responses.get(
|
|
81
|
+
f"{API_HOST}/api/v1/data/releases",
|
|
82
|
+
json={"releases": [{"release_id": "20260726_180000", "objects": [{"key": "1_kline_data/daily_forward/dt=20260726/data.parquet", "etag": "etag-1", "sha256": digest}]}]}, status=200,
|
|
83
|
+
)
|
|
84
|
+
responses.get(f"{API_HOST}/api/v1/data/download", body=payload, status=200)
|
|
85
|
+
client = QuantDBClient(api_host=API_HOST, api_key="test-key")
|
|
86
|
+
result = client.sync_dataset("daily_forward", str(tmp_path))
|
|
87
|
+
assert result["layout"] == "v2_daily_partition"
|
|
88
|
+
assert (tmp_path / "1_kline_data" / "daily_forward" / "dt=20260726" / "data.parquet").read_bytes() == payload
|
|
89
|
+
params = parse_qs(urlparse(responses.calls[-1].request.url).query)
|
|
90
|
+
assert params["object_key"] == ["1_kline_data/daily_forward/dt=20260726/data.parquet"]
|
|
91
|
+
|
|
92
|
+
|
|
45
93
|
@responses.activate
|
|
46
94
|
def test_auth_error_raises_auth_error():
|
|
47
95
|
responses.get(
|
|
@@ -74,6 +122,7 @@ def test_get_usage_with_subscription():
|
|
|
74
122
|
"used_traffic": 1073741824,
|
|
75
123
|
"traffic_limit": 32212254720,
|
|
76
124
|
"credit_limit": 0,
|
|
125
|
+
"purchased_traffic": 1073741824,
|
|
77
126
|
"balance_yuan": 0.0,
|
|
78
127
|
"subscription": {"plan_id": "pro-monthly", "status": "active"},
|
|
79
128
|
},
|
|
@@ -83,6 +132,8 @@ def test_get_usage_with_subscription():
|
|
|
83
132
|
usage = client.get_usage()
|
|
84
133
|
assert usage["used_gb"] == 1.0
|
|
85
134
|
assert usage["limit_gb"] == 30.0
|
|
135
|
+
assert usage["purchased_gb"] == 1.0
|
|
136
|
+
assert usage["remaining_gb"] == 30.0
|
|
86
137
|
assert usage["subscription"]["status"] == "active"
|
|
87
138
|
assert usage["is_active"] is True
|
|
88
139
|
|
|
@@ -114,13 +165,13 @@ def test_create_order():
|
|
|
114
165
|
|
|
115
166
|
@responses.activate
|
|
116
167
|
def test_delete_api_key():
|
|
117
|
-
responses.
|
|
118
|
-
f"{API_HOST}/api/v1/auth/api-keys/
|
|
168
|
+
responses.delete(
|
|
169
|
+
f"{API_HOST}/api/v1/auth/api-keys/42",
|
|
119
170
|
json={"message": "API Key 撤销成功"},
|
|
120
171
|
status=200,
|
|
121
172
|
)
|
|
122
173
|
client = QuantDBClient(api_host=API_HOST, api_key="test-key")
|
|
123
|
-
result = client.delete_api_key(
|
|
174
|
+
result = client.delete_api_key(42)
|
|
124
175
|
assert result["message"] == "API Key 撤销成功"
|
|
125
176
|
|
|
126
177
|
|
|
@@ -149,4 +200,3 @@ def test_duckdb_warehouse(tmp_path):
|
|
|
149
200
|
df = wh.query("SELECT * FROM stock_val WHERE val > 15")
|
|
150
201
|
assert len(df) == 1
|
|
151
202
|
assert df.iloc[0]["code"] == "600519.SH"
|
|
152
|
-
|
|
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
|