quantdb-sdk 0.1.4__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {quantdb_sdk-0.1.4 → quantdb_sdk-0.2.1}/CHANGELOG.md +16 -0
  2. {quantdb_sdk-0.1.4/quantdb_sdk.egg-info → quantdb_sdk-0.2.1}/PKG-INFO +19 -1
  3. {quantdb_sdk-0.1.4 → quantdb_sdk-0.2.1}/README.md +20 -2
  4. {quantdb_sdk-0.1.4 → quantdb_sdk-0.2.1}/pyproject.toml +1 -1
  5. {quantdb_sdk-0.1.4 → quantdb_sdk-0.2.1}/quantdb_sdk/__init__.py +1 -1
  6. {quantdb_sdk-0.1.4 → quantdb_sdk-0.2.1}/quantdb_sdk/async_client.py +132 -27
  7. {quantdb_sdk-0.1.4 → quantdb_sdk-0.2.1}/quantdb_sdk/client.py +214 -35
  8. {quantdb_sdk-0.1.4 → quantdb_sdk-0.2.1/quantdb_sdk.egg-info}/PKG-INFO +19 -1
  9. {quantdb_sdk-0.1.4 → quantdb_sdk-0.2.1}/tests/test_async_client.py +24 -0
  10. {quantdb_sdk-0.1.4 → quantdb_sdk-0.2.1}/tests/test_client.py +48 -0
  11. {quantdb_sdk-0.1.4 → quantdb_sdk-0.2.1}/LICENSE +0 -0
  12. {quantdb_sdk-0.1.4 → quantdb_sdk-0.2.1}/MANIFEST.in +0 -0
  13. {quantdb_sdk-0.1.4 → quantdb_sdk-0.2.1}/quantdb_sdk/__main__.py +0 -0
  14. {quantdb_sdk-0.1.4 → quantdb_sdk-0.2.1}/quantdb_sdk/_utils.py +0 -0
  15. {quantdb_sdk-0.1.4 → quantdb_sdk-0.2.1}/quantdb_sdk/errors.py +0 -0
  16. {quantdb_sdk-0.1.4 → quantdb_sdk-0.2.1}/quantdb_sdk/py.typed +0 -0
  17. {quantdb_sdk-0.1.4 → quantdb_sdk-0.2.1}/quantdb_sdk.egg-info/SOURCES.txt +0 -0
  18. {quantdb_sdk-0.1.4 → quantdb_sdk-0.2.1}/quantdb_sdk.egg-info/dependency_links.txt +0 -0
  19. {quantdb_sdk-0.1.4 → quantdb_sdk-0.2.1}/quantdb_sdk.egg-info/entry_points.txt +0 -0
  20. {quantdb_sdk-0.1.4 → quantdb_sdk-0.2.1}/quantdb_sdk.egg-info/requires.txt +0 -0
  21. {quantdb_sdk-0.1.4 → quantdb_sdk-0.2.1}/quantdb_sdk.egg-info/top_level.txt +0 -0
  22. {quantdb_sdk-0.1.4 → quantdb_sdk-0.2.1}/setup.cfg +0 -0
  23. {quantdb_sdk-0.1.4 → quantdb_sdk-0.2.1}/tests/test_technical_indicators.py +0 -0
@@ -3,6 +3,22 @@
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.1] - 2026-07-26
7
+
8
+ ### Fixed
9
+ - **历史发布清单兼容**:同步客户端自动归一化旧 release manifest 遗留的 `v2/` 前缀,并可下载 release patch 对象。
10
+ - **布局回退**:服务端以 COS Manifest 为可用性权威,V2 元数据缺失时可正确回退 V1,而非将数据误判为不可下载。
11
+
12
+ ## [0.2.0] - 2026-07-26
13
+
14
+ ### Added
15
+ - **V1/V2 布局兼容**:同步与异步客户端的下载、Manifest、DataFrame、K 线和 Tick 接口均支持 `layout="auto" | "v1" | "v2"`。
16
+ - **V2 release 增量同步**:`sync_dataset()` / `a_sync_dataset()` 使用发布 cursor,同步 daily 与 patch 对象;文件 SHA-256 校验和原子落盘成功后才推进 cursor。
17
+
18
+ ### Changed
19
+ - `query_kline()` 在给出日期范围时优先 V2 日切片;V2 覆盖缺日时自动模式整体回退 V1,显式 V2 则返回覆盖错误。未给日期范围时保持 V1 全历史行为。
20
+ - 下载接口支持 ETag 条件请求;服务端命中 `304 Not Modified` 时不传输对象正文、不扣下载流量。
21
+
6
22
  ## [0.1.4] - 2026-07-25
7
23
 
8
24
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quantdb-sdk
3
- Version: 0.1.4
3
+ Version: 0.2.1
4
4
  Summary: QuantDB 量化数据平台官方 Python SDK
5
5
  Author: QuantDB Team
6
6
  License: MIT
@@ -88,6 +88,24 @@ 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 从账户余额扣减。余额不足时下载会被拦截。
@@ -43,13 +43,31 @@ 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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "quantdb-sdk"
7
- version = "0.1.4"
7
+ version = "0.2.1"
8
8
  description = "QuantDB 量化数据平台官方 Python SDK"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -5,7 +5,7 @@ try:
5
5
  # 注意:此处用 PyPI 包名(连字符)查找版本号,不是模块名(下划线)
6
6
  __version__ = version("quantdb-sdk")
7
7
  except ImportError:
8
- __version__ = "0.1.0"
8
+ __version__ = "0.2.1"
9
9
 
10
10
  from .async_client import AsyncQuantDBClient
11
11
  from .client import DuckDBWarehouse, QuantDBClient
@@ -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
- from typing import Any, Dict, List, Optional
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.1.4"}
40
+ headers = {"User-Agent": "QuantDB-Python-SDK/0.2.1"}
39
41
  if api_key:
40
42
  headers["X-API-Key"] = api_key
41
43
  elif token:
@@ -58,6 +60,34 @@ 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_release_key(key: str) -> str:
71
+ return key.strip().lstrip("/").removeprefix("v2/")
72
+
73
+ @staticmethod
74
+ def _normalise_kline(df: pd.DataFrame, start_date: Optional[str], end_date: Optional[str], fields: str, limit: Optional[int]) -> pd.DataFrame:
75
+ if "time" in df.columns:
76
+ dt = pd.to_datetime(df["time"], errors="coerce")
77
+ elif "trade_date" in df.columns:
78
+ dt = pd.to_datetime(df["trade_date"], errors="coerce")
79
+ else:
80
+ dt = pd.Series(pd.NaT, index=df.index)
81
+ mask = pd.Series(True, index=df.index)
82
+ if start_date: mask &= dt >= pd.to_datetime(start_date)
83
+ if end_date: mask &= dt <= pd.to_datetime(end_date)
84
+ result = df.loc[mask].copy()
85
+ result["trade_date"] = dt.loc[mask].dt.strftime("%Y-%m-%d")
86
+ result = result.drop(columns=["time"], errors="ignore").sort_values("trade_date", kind="stable").drop_duplicates("trade_date", keep="last")
87
+ wanted = [x.strip() for x in fields.split(",") if x.strip()]
88
+ result = result[["trade_date"] + [x for x in wanted if x in result.columns]]
89
+ return (result.tail(limit) if limit is not None else result).reset_index(drop=True)
90
+
61
91
  async def __aenter__(self) -> "AsyncQuantDBClient":
62
92
  return self
63
93
 
@@ -206,31 +236,31 @@ class AsyncQuantDBClient:
206
236
  end_date: Optional[str] = None,
207
237
  fields: str = "open,high,low,close,volume,amount",
208
238
  limit: Optional[int] = None,
239
+ layout: Literal["auto", "v1", "v2"] = "auto",
209
240
  ) -> pd.DataFrame:
210
241
  """查询 K 线数据(下载 COS parquet 切片后客户端解析,消耗下载流量,异步)。"""
242
+ layout = self._validate_layout(layout)
211
243
  sub_category = f"daily_{adj_type}"
212
- df = await self.a_load_as_df("1", sub_category, symbol)
213
- # 日期过滤:K线 parquet 的日期列为 'time',对外以 'trade_date' 暴露。
214
- if "time" in df.columns:
215
- dt = pd.to_datetime(df["time"], errors="coerce")
216
- if dt.dt.tz is not None:
217
- dt = dt.dt.tz_convert(None)
218
- mask = pd.Series(True, index=df.index)
219
- if start_date:
220
- mask &= dt >= pd.to_datetime(start_date)
221
- if end_date:
222
- mask &= dt <= pd.to_datetime(end_date)
223
- df = df[mask].copy()
224
- df["trade_date"] = dt[mask].dt.strftime("%Y-%m-%d")
225
- df = df.drop(columns=["time"])
226
- field_list = [f.strip() for f in fields.split(",") if f.strip()]
227
- cols = [c for c in field_list if c in df.columns]
228
- keep = (["trade_date"] if "trade_date" in df.columns else []) + cols
229
- keep = list(dict.fromkeys(keep))
230
- df = df[keep]
231
- if limit is not None:
232
- df = df.tail(limit)
233
- return df.reset_index(drop=True)
244
+ if layout == "v1" or (layout == "auto" and not (start_date or end_date)):
245
+ return self._normalise_kline(await self.a_load_as_df("1", sub_category, symbol, layout="v1"), start_date, end_date, fields, limit)
246
+ files = await self.a_query_manifest("1", sub_category, layout="v2")
247
+ 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)]
248
+ calendar = await self.a_query_calendar(start_date, end_date)
249
+ expected = set()
250
+ if not calendar.empty:
251
+ date_col = next((c for c in ("trade_date", "date", "cal_date") if c in calendar.columns), None)
252
+ open_col = next((c for c in ("is_open", "is_trading_day", "open") if c in calendar.columns), None)
253
+ if date_col:
254
+ rows = calendar if not open_col else calendar[calendar[open_col].astype(str).isin(["1", "True", "true"])]
255
+ expected = set(pd.to_datetime(rows[date_col], errors="coerce").dropna().dt.strftime("%Y-%m-%d"))
256
+ if not selected or (expected and not expected.issubset({f.get("trade_date") for f in selected})):
257
+ if layout == "auto":
258
+ return self._normalise_kline(await self.a_load_as_df("1", sub_category, symbol, layout="v1"), start_date, end_date, fields, limit)
259
+ raise NotFoundError("V2 日切片在请求日期范围内覆盖不完整;请改用 layout='auto' 或 'v1'")
260
+ frames = [await self.a_load_as_df("1", sub_category, symbol, trade_date=f["trade_date"], layout="v2") for f in selected]
261
+ df = pd.concat(frames, ignore_index=True, sort=False)
262
+ if "symbol" in df.columns: df = df[df["symbol"].astype(str).str.upper() == symbol.upper()].copy()
263
+ return self._normalise_kline(df, start_date, end_date, fields, limit)
234
264
 
235
265
  async def a_query_tick(
236
266
  self,
@@ -240,9 +270,10 @@ class AsyncQuantDBClient:
240
270
  end_ts: Optional[str] = None,
241
271
  fields: str = "last_price,open,high,low,last_close,volume,amount",
242
272
  limit: Optional[int] = None,
273
+ layout: Literal["auto", "v1", "v2"] = "auto",
243
274
  ) -> pd.DataFrame:
244
275
  """查询 Tick 分笔数据(下载 COS parquet 切片后客户端解析,消耗下载流量,异步)。"""
245
- df = await self.a_load_as_df("1", "tick_data", symbol, trade_date=trade_date)
276
+ df = await self.a_load_as_df("1", "tick_data", symbol, trade_date=trade_date, layout=layout)
246
277
  ts_col = "ts" if "ts" in df.columns else ("time" if "time" in df.columns else None)
247
278
  if ts_col and (start_ts or end_ts):
248
279
  ts = pd.to_datetime(df[ts_col], errors="coerce")
@@ -292,11 +323,13 @@ class AsyncQuantDBClient:
292
323
  category_id: str,
293
324
  sub_category: str,
294
325
  trade_date: Optional[str] = None,
326
+ layout: Literal["auto", "v1", "v2"] = "auto",
295
327
  ) -> List[Dict[str, Any]]:
296
328
  params: Dict[str, Any] = {
297
329
  "category_id": category_id,
298
330
  "sub_category": sub_category,
299
331
  }
332
+ params["layout"] = self._validate_layout(layout)
300
333
  if trade_date:
301
334
  params["trade_date"] = trade_date
302
335
  data = await self._get("/api/v1/data/download/manifest", params)
@@ -342,6 +375,8 @@ class AsyncQuantDBClient:
342
375
  symbol: Optional[str] = None,
343
376
  save_dir: Optional[str] = None,
344
377
  trade_date: Optional[str] = None,
378
+ layout: Literal["auto", "v1", "v2"] = "auto",
379
+ object_key: Optional[str] = None,
345
380
  ) -> str:
346
381
  if save_dir is None:
347
382
  save_dir = default_download_dir()
@@ -353,8 +388,11 @@ class AsyncQuantDBClient:
353
388
  }
354
389
  if symbol:
355
390
  params["symbol"] = symbol
391
+ params["layout"] = self._validate_layout(layout)
356
392
  if trade_date:
357
393
  params["trade_date"] = trade_date
394
+ if object_key:
395
+ params["object_key"] = object_key
358
396
 
359
397
  async with self.client.stream(
360
398
  "GET", f"{self.api_host}/api/v1/data/download", params=params
@@ -376,10 +414,12 @@ class AsyncQuantDBClient:
376
414
  filename = parse_filename_from_content_disposition(cd, fallback)
377
415
 
378
416
  save_path = os.path.join(save_dir, filename)
379
- with open(save_path, "wb") as f:
417
+ tmp_path = save_path + ".part"
418
+ with open(tmp_path, "wb") as f:
380
419
  async for chunk in resp.aiter_bytes(chunk_size=8192):
381
420
  if chunk:
382
421
  f.write(chunk)
422
+ os.replace(tmp_path, save_path)
383
423
  return os.path.abspath(save_path)
384
424
 
385
425
  async def a_load_as_df(
@@ -388,20 +428,26 @@ class AsyncQuantDBClient:
388
428
  sub_category: str,
389
429
  symbol: Optional[str] = None,
390
430
  trade_date: Optional[str] = None,
431
+ layout: Literal["auto", "v1", "v2"] = "auto",
432
+ object_key: Optional[str] = None,
391
433
  ) -> pd.DataFrame:
392
434
  """远端 Parquet 切片加载到内存 DataFrame(消耗下载流量,异步)。
393
435
 
394
436
  带进程内 ETag 缓存:同对象(ETag 未变)不重复下载,避免重复计费。
395
437
  """
396
- cache_key = f"{category_id}/{sub_category}/{symbol}/{trade_date}"
438
+ layout = self._validate_layout(layout)
439
+ cache_key = f"{category_id}/{sub_category}/{symbol}/{trade_date}/{layout}/{object_key or ''}"
397
440
  params: Dict[str, Any] = {
398
441
  "category_id": category_id,
399
442
  "sub_category": sub_category,
400
443
  }
401
444
  if symbol:
402
445
  params["symbol"] = symbol
446
+ params["layout"] = layout
403
447
  if trade_date:
404
448
  params["trade_date"] = trade_date
449
+ if object_key:
450
+ params["object_key"] = object_key
405
451
  cached = self._cache.get(cache_key)
406
452
  req_headers = {}
407
453
  if cached and cached.get("etag"):
@@ -479,3 +525,62 @@ class AsyncQuantDBClient:
479
525
  else:
480
526
  sql = f"SELECT * FROM '{clean_path}' WHERE {sql}"
481
527
  return duckdb.query(sql).df()
528
+
529
+ async def a_sync_dataset(self, dataset: str, save_dir: Optional[str] = None, after_release: Optional[str] = None) -> Dict[str, Any]:
530
+ """异步版 release 增量同步;状态格式与 ``sync_dataset`` 兼容。"""
531
+ category_map = {
532
+ "daily_unadjusted": "1", "daily_forward": "1", "daily_backward": "1", "index_daily": "1",
533
+ "min1_kline": "1", "min5_kline": "1", "margin_trading": "2", "valuation": "5",
534
+ "technical_indicators": "5", "market_sentiment": "5", "features_daily": "6",
535
+ }
536
+ if dataset not in category_map: raise ValidationError(f"不支持同步的数据集: {dataset}")
537
+ root = os.path.abspath(save_dir or default_download_dir()); os.makedirs(root, exist_ok=True)
538
+ state = sqlite3.connect(os.path.join(root, "quantdb_sync.sqlite"))
539
+ state.execute("CREATE TABLE IF NOT EXISTS objects (key TEXT PRIMARY KEY, etag TEXT, sha256 TEXT, path TEXT, layout TEXT, dataset TEXT)")
540
+ cols = {row[1] for row in state.execute("PRAGMA table_info(objects)")}
541
+ if "dataset" not in cols: state.execute("ALTER TABLE objects ADD COLUMN dataset TEXT")
542
+ state.execute("CREATE TABLE IF NOT EXISTS releases (dataset TEXT PRIMARY KEY, release_id TEXT NOT NULL)")
543
+ downloaded: List[str] = []
544
+ try:
545
+ persisted = state.execute("SELECT release_id FROM releases WHERE dataset=?", (dataset,)).fetchone()
546
+ cursor = after_release if after_release is not None else (persisted[0] if persisted else "")
547
+ releases = (await self._get("/api/v1/data/releases", {"datasets": dataset, "after_release": cursor})).get("releases", [])
548
+ if releases:
549
+ for release in releases:
550
+ for obj in release.get("objects", []):
551
+ key = self._normalise_release_key(obj["key"])
552
+ target = os.path.join(root, *key.split("/"))
553
+ old = state.execute("SELECT etag,sha256,path FROM objects WHERE key=?", (key,)).fetchone()
554
+ if old and old[0] == obj.get("etag") and old[1] == obj.get("sha256") and os.path.exists(old[2]): continue
555
+ os.makedirs(os.path.dirname(target), exist_ok=True)
556
+ tmp, digest = target + ".part", hashlib.sha256()
557
+ 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:
558
+ if resp.status_code != 200:
559
+ body = await resp.aread(); self._check_response(httpx.Response(resp.status_code, content=body)); raise QuantDBError("下载失败")
560
+ try:
561
+ with open(tmp, "wb") as fh:
562
+ async for chunk in resp.aiter_bytes(1024 * 1024):
563
+ if chunk: fh.write(chunk); digest.update(chunk)
564
+ actual = digest.hexdigest()
565
+ if obj.get("sha256") and actual.lower() != obj["sha256"].lower(): raise ServerError("对象 SHA-256 校验失败")
566
+ os.replace(tmp, target)
567
+ except Exception:
568
+ if os.path.exists(tmp): os.remove(tmp)
569
+ raise
570
+ 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)
571
+ state.execute("INSERT OR REPLACE INTO releases(dataset,release_id) VALUES(?,?)", (dataset, release["release_id"])); state.commit()
572
+ return {"dataset": dataset, "layout": "v2_daily_partition", "downloaded": downloaded, "after_release": cursor, "release_id": releases[-1]["release_id"]}
573
+ if persisted: return {"dataset": dataset, "layout": "v2_daily_partition", "downloaded": [], "after_release": cursor, "release_id": persisted[0]}
574
+ files = (await self._get("/api/v1/data/download/manifest", {"category_id": category_map[dataset], "sub_category": dataset, "layout": "v1"})).get("files", [])
575
+ for obj in files:
576
+ key, target = obj["key"], os.path.join(root, *(obj.get("relative_path") or obj["key"]).split("/"))
577
+ old = state.execute("SELECT etag,path FROM objects WHERE key=?", (key,)).fetchone()
578
+ if old and old[0] == obj.get("etag") and os.path.exists(old[1]): continue
579
+ await self.a_download_file(category_map[dataset], dataset, symbol=obj.get("symbol"), save_dir=os.path.dirname(target), layout="v1")
580
+ # 下载文件名来自服务端,按清单目标路径归位以保证本地根目录同构。
581
+ source = os.path.join(os.path.dirname(target), os.path.basename(target))
582
+ if os.path.abspath(source) != os.path.abspath(target): os.replace(source, target)
583
+ 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)
584
+ state.commit(); return {"dataset": dataset, "layout": "v1_symbol", "downloaded": downloaded, "after_release": cursor}
585
+ finally:
586
+ 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
- from typing import Any, Dict, List, Optional
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
@@ -45,7 +48,7 @@ class QuantDBClient:
45
48
  self.session = requests.Session()
46
49
  # User-Agent 中的版本与 pyproject.toml 同步,用于服务端日志归因
47
50
  # 维护提示:每次版本号变化必须同步改这里(init 里的 __version__ 走 metadata 自动同步)
48
- self.session.headers.update({"User-Agent": "QuantDB-Python-SDK/0.1.4"})
51
+ self.session.headers.update({"User-Agent": "QuantDB-Python-SDK/0.2.1"})
49
52
 
50
53
  if api_key:
51
54
  self.headers = {"X-API-Key": api_key}
@@ -155,6 +158,44 @@ class QuantDBClient:
155
158
  """
156
159
  self._cache.clear()
157
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_release_key(key: str) -> str:
169
+ """兼容历史 release manifest 遗留的 ``v2/`` 对象前缀。"""
170
+ return key.strip().lstrip("/").removeprefix("v2/")
171
+
172
+ @staticmethod
173
+ def _normalise_kline(df: pd.DataFrame, start_date: Optional[str], end_date: Optional[str], fields: str, limit: Optional[int]) -> pd.DataFrame:
174
+ """统一 V1/V2 日线结果并在客户端按标的、日期与字段过滤。"""
175
+ if "time" in df.columns:
176
+ dt = pd.to_datetime(df["time"], errors="coerce")
177
+ if getattr(dt.dt, "tz", None) is not None:
178
+ dt = dt.dt.tz_convert(None)
179
+ elif "trade_date" in df.columns:
180
+ dt = pd.to_datetime(df["trade_date"], errors="coerce")
181
+ else:
182
+ dt = pd.Series(pd.NaT, index=df.index)
183
+ mask = pd.Series(True, index=df.index)
184
+ if start_date:
185
+ mask &= dt >= pd.to_datetime(start_date)
186
+ if end_date:
187
+ mask &= dt <= pd.to_datetime(end_date)
188
+ result = df.loc[mask].copy()
189
+ result["trade_date"] = dt.loc[mask].dt.strftime("%Y-%m-%d")
190
+ result = result.drop(columns=["time"], errors="ignore")
191
+ result = result.sort_values("trade_date", kind="stable").drop_duplicates("trade_date", keep="last")
192
+ wanted = [x.strip() for x in fields.split(",") if x.strip()]
193
+ columns = ["trade_date"] + [x for x in wanted if x in result.columns]
194
+ result = result.loc[:, list(dict.fromkeys(columns))]
195
+ if limit is not None:
196
+ result = result.tail(limit)
197
+ return result.reset_index(drop=True)
198
+
158
199
  # ========== 账户信息 ==========
159
200
 
160
201
  def get_me(self) -> Dict[str, Any]:
@@ -253,37 +294,42 @@ class QuantDBClient:
253
294
  end_date: Optional[str] = None,
254
295
  fields: str = "open,high,low,close,volume,amount",
255
296
  limit: Optional[int] = None,
297
+ layout: Literal["auto", "v1", "v2"] = "auto",
256
298
  ) -> pd.DataFrame:
257
299
  """查询 K 线数据(下载 COS parquet 切片后客户端解析,消耗下载流量)。
258
300
 
259
- 下载整个 symbol 的日线 parquet,按 start_date/end_date 过滤日期、按 fields 选列。
260
- limit 非 None 时只返回尾部 limit 行。
301
+ ``auto`` 在提供日期范围时优先 V2 全市场日分区;如 V2 覆盖不完整,
302
+ 整次回退 V1 股票历史文件。未提供日期范围时直接使用 V1,以保持旧版
303
+ ``query_kline`` 的全历史语义。显式 ``v2`` 不会静默回退。
261
304
  """
305
+ layout = self._validate_layout(layout)
262
306
  sub_category = f"daily_{adj_type}"
263
- df = self.load_as_df("1", sub_category, symbol)
264
- # 日期过滤:K线 parquet 的日期列为 'time'(采集端 01_kline.py 统一命名)。
265
- # 对外仍以 'trade_date' 暴露(YYYY-MM-DD),便于用户按日期理解。
266
- if "time" in df.columns:
267
- dt = pd.to_datetime(df["time"], errors="coerce")
268
- if dt.dt.tz is not None:
269
- dt = dt.dt.tz_convert(None)
270
- mask = pd.Series(True, index=df.index)
271
- if start_date:
272
- mask &= dt >= pd.to_datetime(start_date)
273
- if end_date:
274
- mask &= dt <= pd.to_datetime(end_date)
275
- df = df[mask].copy()
276
- df["trade_date"] = dt[mask].dt.strftime("%Y-%m-%d")
277
- df = df.drop(columns=["time"])
278
- # 字段过滤:保留 trade_date + 选中字段
279
- field_list = [f.strip() for f in fields.split(",") if f.strip()]
280
- cols = [c for c in field_list if c in df.columns]
281
- keep = (["trade_date"] if "trade_date" in df.columns else []) + cols
282
- keep = list(dict.fromkeys(keep))
283
- df = df[keep]
284
- if limit is not None:
285
- df = df.tail(limit)
286
- return df.reset_index(drop=True)
307
+ has_range = bool(start_date or end_date)
308
+ if layout == "v1" or (layout == "auto" and not has_range):
309
+ return self._normalise_kline(self.load_as_df("1", sub_category, symbol, layout="v1"), start_date, end_date, fields, limit)
310
+
311
+ files = self.query_manifest("1", sub_category, layout="v2")
312
+ 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)]
313
+ # 日历是 V2 完整性的权威。无法得到日历或任一开市日缺分区时,auto 回退 V1;v2 明确报错。
314
+ calendar = self.query_calendar(start_date, end_date)
315
+ expected = set()
316
+ if not calendar.empty:
317
+ date_col = next((c for c in ("trade_date", "date", "cal_date") if c in calendar.columns), None)
318
+ open_col = next((c for c in ("is_open", "is_trading_day", "open") if c in calendar.columns), None)
319
+ if date_col:
320
+ rows = calendar if not open_col else calendar[calendar[open_col].astype(str).isin(["1", "True", "true"])]
321
+ expected = set(pd.to_datetime(rows[date_col], errors="coerce").dropna().dt.strftime("%Y-%m-%d"))
322
+ found = {f.get("trade_date") for f in selected}
323
+ complete = bool(selected) and (not expected or expected.issubset(found))
324
+ if not complete:
325
+ if layout == "auto":
326
+ return self._normalise_kline(self.load_as_df("1", sub_category, symbol, layout="v1"), start_date, end_date, fields, limit)
327
+ raise NotFoundError("V2 日切片在请求日期范围内覆盖不完整;请改用 layout='auto' 或 'v1'")
328
+ frames = [self.load_as_df("1", sub_category, symbol, trade_date=f["trade_date"], layout="v2") for f in selected]
329
+ df = pd.concat(frames, ignore_index=True, sort=False)
330
+ if "symbol" in df.columns:
331
+ df = df[df["symbol"].astype(str).str.upper() == symbol.upper()].copy()
332
+ return self._normalise_kline(df, start_date, end_date, fields, limit)
287
333
 
288
334
  def query_tick(
289
335
  self,
@@ -293,13 +339,14 @@ class QuantDBClient:
293
339
  end_ts: Optional[str] = None,
294
340
  fields: str = "last_price,open,high,low,last_close,volume,amount",
295
341
  limit: Optional[int] = None,
342
+ layout: Literal["auto", "v1", "v2"] = "auto",
296
343
  ) -> pd.DataFrame:
297
344
  """查询 Tick 分笔数据(下载 COS parquet 切片后客户端解析,消耗下载流量)。
298
345
 
299
346
  下载 trade_date 当日该 symbol 的 tick parquet,按 start_ts/end_ts 过滤时间、按 fields 选列。
300
347
  start_ts/end_ts 可传完整时间戳或 "HH:MM:SS"(自动补 trade_date 日期)。
301
348
  """
302
- df = self.load_as_df("1", "tick_data", symbol, trade_date=trade_date)
349
+ df = self.load_as_df("1", "tick_data", symbol, trade_date=trade_date, layout=layout)
303
350
  # 时间过滤
304
351
  ts_col = "ts" if "ts" in df.columns else ("time" if "time" in df.columns else None)
305
352
  if ts_col and (start_ts or end_ts):
@@ -353,12 +400,14 @@ class QuantDBClient:
353
400
  category_id: str,
354
401
  sub_category: str,
355
402
  trade_date: Optional[str] = None,
403
+ layout: Literal["auto", "v1", "v2"] = "auto",
356
404
  ) -> List[Dict[str, Any]]:
357
405
  """查询 COS 可下载文件清单。"""
358
406
  params: Dict[str, Any] = {
359
407
  "category_id": category_id,
360
408
  "sub_category": sub_category,
361
409
  }
410
+ params["layout"] = self._validate_layout(layout)
362
411
  if trade_date:
363
412
  params["trade_date"] = trade_date
364
413
  data = self._get("/api/v1/data/download/manifest", params)
@@ -410,10 +459,12 @@ class QuantDBClient:
410
459
  symbol: Optional[str] = None,
411
460
  save_dir: Optional[str] = None,
412
461
  trade_date: Optional[str] = None,
462
+ layout: Literal["auto", "v1", "v2"] = "auto",
463
+ object_key: Optional[str] = None,
413
464
  ) -> str:
414
465
  """流式下载原始 Parquet 切片到本地,返回保存路径(消耗下载流量)。
415
466
 
416
- trade_date 仅对 tick_data 子分类有效(按交易日下载 {SYM}_{YYYYMMDD}.parquet)。
467
+ ``object_key`` 仅供 release 增量同步使用;服务端会校验它属于请求的数据集前缀。
417
468
  """
418
469
  if save_dir is None:
419
470
  save_dir = default_download_dir()
@@ -425,8 +476,11 @@ class QuantDBClient:
425
476
  }
426
477
  if symbol:
427
478
  params["symbol"] = symbol
479
+ params["layout"] = self._validate_layout(layout)
428
480
  if trade_date:
429
481
  params["trade_date"] = trade_date
482
+ if object_key:
483
+ params["object_key"] = object_key
430
484
 
431
485
  resp = self._request(
432
486
  "GET", "/api/v1/data/download", params=params, stream=True
@@ -442,10 +496,12 @@ class QuantDBClient:
442
496
  )
443
497
 
444
498
  save_path = os.path.join(save_dir, filename)
445
- with open(save_path, "wb") as f:
499
+ tmp_path = save_path + ".part"
500
+ with open(tmp_path, "wb") as f:
446
501
  for chunk in resp.iter_content(chunk_size=8192):
447
502
  if chunk:
448
503
  f.write(chunk)
504
+ os.replace(tmp_path, save_path)
449
505
  return os.path.abspath(save_path)
450
506
 
451
507
  def load_as_df(
@@ -454,6 +510,8 @@ class QuantDBClient:
454
510
  sub_category: str,
455
511
  symbol: Optional[str] = None,
456
512
  trade_date: Optional[str] = None,
513
+ layout: Literal["auto", "v1", "v2"] = "auto",
514
+ object_key: Optional[str] = None,
457
515
  ) -> pd.DataFrame:
458
516
  """将远端 Parquet 切片直接加载到内存 DataFrame(不落盘,消耗下载流量)。
459
517
 
@@ -462,18 +520,20 @@ class QuantDBClient:
462
520
  带进程内 ETag 缓存:同一对象(ETag 未变)不重复下载,避免对同一 symbol
463
521
  多次查询时重复消耗流量。
464
522
  """
465
- cache_key = f"{category_id}/{sub_category}/{symbol}/{trade_date}"
523
+ layout = self._validate_layout(layout)
524
+ cache_key = f"{category_id}/{sub_category}/{symbol}/{trade_date}/{layout}/{object_key or ''}"
466
525
  params: Dict[str, Any] = {
467
526
  "category_id": category_id,
468
527
  "sub_category": sub_category,
469
528
  }
470
529
  if symbol:
471
530
  params["symbol"] = symbol
531
+ params["layout"] = layout
472
532
  if trade_date:
473
533
  params["trade_date"] = trade_date
474
- # 若已有缓存,带 If-None-Match 让服务端在对象未变时返回 304(不计流量下载 body)。
475
- # 注:当前网关透传 COS ETag,但未实现 304;若服务端不支持 304,仍会返回 200
476
- # 全量 body,此时用响应 ETag 命中本地缓存跳过重复解析。
534
+ if object_key:
535
+ params["object_key"] = object_key
536
+ # 若已有缓存,带 If-None-Match;服务端 ETag 命中时返回 304,不扣下载流量。
477
537
  cached = self._cache.get(cache_key)
478
538
  headers = {}
479
539
  if cached and cached.get("etag"):
@@ -557,6 +617,125 @@ class QuantDBClient:
557
617
  """获取 DuckDB 本地数据仓库实例,用于离线多表 SQL JOIN 查询。"""
558
618
  return DuckDBWarehouse(client=self, save_dir=save_dir)
559
619
 
620
+ def sync_dataset(self, dataset: str, save_dir: Optional[str] = None, after_release: Optional[str] = None) -> Dict[str, Any]:
621
+ """按 release 增量同步 V2;无可用 V2 release 时回退 V1 Manifest。"""
622
+ root = os.path.abspath(save_dir or default_download_dir())
623
+ os.makedirs(root, exist_ok=True)
624
+ state = sqlite3.connect(os.path.join(root, "quantdb_sync.sqlite"))
625
+ state.execute("CREATE TABLE IF NOT EXISTS objects (key TEXT PRIMARY KEY, etag TEXT, sha256 TEXT, path TEXT, layout TEXT, dataset TEXT)")
626
+ cols = {row[1] for row in state.execute("PRAGMA table_info(objects)")}
627
+ if "dataset" not in cols:
628
+ state.execute("ALTER TABLE objects ADD COLUMN dataset TEXT")
629
+ state.execute("CREATE TABLE IF NOT EXISTS releases (dataset TEXT PRIMARY KEY, release_id TEXT NOT NULL)")
630
+ category_map = {
631
+ "daily_unadjusted": "1", "daily_forward": "1", "daily_backward": "1",
632
+ "index_daily": "1", "min1_kline": "1", "min5_kline": "1",
633
+ "margin_trading": "2", "valuation": "5", "technical_indicators": "5",
634
+ "market_sentiment": "5", "features_daily": "6",
635
+ }
636
+ if dataset not in category_map:
637
+ raise ValidationError(f"不支持同步的数据集: {dataset}")
638
+
639
+ downloaded: List[str] = []
640
+ try:
641
+ persisted = state.execute("SELECT release_id FROM releases WHERE dataset=?", (dataset,)).fetchone()
642
+ cursor = after_release if after_release is not None else (persisted[0] if persisted else "")
643
+ release_data = self._get("/api/v1/data/releases", {"datasets": dataset, "after_release": cursor})
644
+ releases = release_data.get("releases", [])
645
+ if releases:
646
+ for release in releases:
647
+ release_id = release["release_id"]
648
+ for obj in release.get("objects", []):
649
+ key = self._normalise_release_key(obj["key"])
650
+ relative_path = obj.get("relative_path") or key
651
+ target = os.path.join(root, *relative_path.split("/"))
652
+ old = state.execute("SELECT etag, sha256, path FROM objects WHERE key=?", (key,)).fetchone()
653
+ if old and old[0] == obj.get("etag") and old[1] == obj.get("sha256") and os.path.exists(old[2]):
654
+ continue
655
+ resp = self._request("GET", "/api/v1/data/download", params={"category_id": category_map[dataset], "sub_category": dataset, "layout": "v2", "object_key": key}, stream=True)
656
+ if resp.status_code != 200:
657
+ self._check_response(resp)
658
+ os.makedirs(os.path.dirname(target), exist_ok=True)
659
+ tmp, digest = target + ".part", hashlib.sha256()
660
+ try:
661
+ with open(tmp, "wb") as fh:
662
+ for chunk in resp.iter_content(1024 * 1024):
663
+ if chunk:
664
+ fh.write(chunk); digest.update(chunk)
665
+ actual = digest.hexdigest()
666
+ if obj.get("sha256") and actual.lower() != obj["sha256"].lower():
667
+ raise ServerError("对象 SHA-256 校验失败")
668
+ os.replace(tmp, target)
669
+ except Exception:
670
+ if os.path.exists(tmp): os.remove(tmp)
671
+ raise
672
+ state.execute("INSERT OR REPLACE INTO objects(key,etag,sha256,path,layout,dataset) VALUES(?,?,?,?,?,?)", (key, obj.get("etag"), actual, target, "v2_daily_partition", dataset))
673
+ downloaded.append(key)
674
+ # 仅在该 release 的所有对象成功落盘和校验后提交 cursor。
675
+ state.execute("INSERT OR REPLACE INTO releases(dataset,release_id) VALUES(?,?)", (dataset, release_id))
676
+ state.commit()
677
+ return {"dataset": dataset, "layout": "v2_daily_partition", "downloaded": downloaded, "after_release": cursor, "release_id": releases[-1]["release_id"]}
678
+
679
+ if persisted:
680
+ return {"dataset": dataset, "layout": "v2_daily_partition", "downloaded": [], "after_release": cursor, "release_id": persisted[0]}
681
+
682
+ manifest = self._get("/api/v1/data/download/manifest", {"category_id": category_map[dataset], "sub_category": dataset, "layout": "v1"})
683
+ for obj in manifest.get("files", []):
684
+ key, relative_path = obj["key"], obj.get("relative_path") or obj["key"]
685
+ target = os.path.join(root, *relative_path.split("/"))
686
+ old = state.execute("SELECT etag, path FROM objects WHERE key=?", (key,)).fetchone()
687
+ if old and old[0] == obj.get("etag") and os.path.exists(old[1]):
688
+ continue
689
+ 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)
690
+ if resp.status_code != 200: self._check_response(resp)
691
+ os.makedirs(os.path.dirname(target), exist_ok=True)
692
+ tmp = target + ".part"
693
+ try:
694
+ with open(tmp, "wb") as fh:
695
+ for chunk in resp.iter_content(1024 * 1024):
696
+ if chunk: fh.write(chunk)
697
+ os.replace(tmp, target)
698
+ except Exception:
699
+ if os.path.exists(tmp): os.remove(tmp)
700
+ raise
701
+ state.execute("INSERT OR REPLACE INTO objects(key,etag,sha256,path,layout,dataset) VALUES(?,?,?,?,?,?)", (key, obj.get("etag"), "", target, "v1_symbol", dataset))
702
+ downloaded.append(key)
703
+ state.commit()
704
+ return {"dataset": dataset, "layout": "v1_symbol", "downloaded": downloaded, "after_release": cursor}
705
+ finally:
706
+ state.close()
707
+
708
+ def mount_local_dataset(self, dataset: str, save_dir: Optional[str] = None, view_name: Optional[str] = None) -> "DuckDBWarehouse":
709
+ """挂载本地 V1/V2 同构数据集;V2 patch 与 daily 文件均参与去重。"""
710
+ warehouse = self.get_local_warehouse(save_dir)
711
+ root = os.path.abspath(save_dir or default_download_dir()).replace("\\", "/")
712
+ 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"}
713
+ if dataset not in views: raise ValidationError(f"不支持的 V2 数据集: {dataset}")
714
+ name = view_name or re.sub(r"[^a-zA-Z0-9_]", "_", dataset)
715
+ state_path = os.path.join(root.replace("/", os.sep), "quantdb_sync.sqlite")
716
+ files: List[str] = []
717
+ if os.path.exists(state_path):
718
+ conn = sqlite3.connect(state_path)
719
+ try:
720
+ 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])]
721
+ finally:
722
+ conn.close()
723
+ if not files:
724
+ daily = f"{root}/{views[dataset]}/dt=*/data.parquet"
725
+ v1 = f"{root}/{views[dataset]}/*.parquet"
726
+ files = glob.glob(daily) or glob.glob(v1)
727
+ if not files: raise NotFoundError(f"本地尚未同步数据集: {dataset}")
728
+ quoted = ", ".join("'" + p.replace("'", "''") + "'" for p in files)
729
+ relation = f"read_parquet([{quoted}], union_by_name=true)"
730
+ columns = {row[0] for row in warehouse.conn.execute(f"DESCRIBE SELECT * FROM {relation}").fetchall()}
731
+ if {"symbol", "time"}.issubset(columns):
732
+ order = "coalesce(release_id, '') DESC" if "release_id" in columns else "0"
733
+ 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")
734
+ else:
735
+ warehouse.conn.execute(f"CREATE OR REPLACE VIEW {name} AS SELECT * FROM {relation}")
736
+ warehouse._views[name] = f"{dataset}"
737
+ return warehouse
738
+
560
739
 
561
740
  class DuckDBWarehouse:
562
741
  """DuckDB 本地仓库管理,支持自动下载数据并注册为 DuckDB 视图,方便多表复杂 SQL 查询。"""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quantdb-sdk
3
- Version: 0.1.4
3
+ Version: 0.2.1
4
4
  Summary: QuantDB 量化数据平台官方 Python SDK
5
5
  Author: QuantDB Team
6
6
  License: MIT
@@ -88,6 +88,24 @@ 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 从账户余额扣减。余额不足时下载会被拦截。
@@ -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": "v2/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():
@@ -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": "v2/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(
File without changes
File without changes
File without changes