quantdb-sdk 0.4.8__tar.gz → 0.5.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.
Files changed (42) hide show
  1. {quantdb_sdk-0.4.8 → quantdb-sdk-0.5.0}/CHANGELOG.md +56 -14
  2. {quantdb_sdk-0.4.8/quantdb_sdk.egg-info → quantdb-sdk-0.5.0}/PKG-INFO +3 -13
  3. {quantdb_sdk-0.4.8 → quantdb-sdk-0.5.0}/pyproject.toml +1 -1
  4. {quantdb_sdk-0.4.8 → quantdb-sdk-0.5.0}/quantdb_sdk/__init__.py +1 -1
  5. {quantdb_sdk-0.4.8 → quantdb-sdk-0.5.0}/quantdb_sdk/__main__.py +35 -4
  6. {quantdb_sdk-0.4.8 → quantdb-sdk-0.5.0}/quantdb_sdk/_utils.py +1 -0
  7. {quantdb_sdk-0.4.8 → quantdb-sdk-0.5.0}/quantdb_sdk/async_client.py +59 -1
  8. {quantdb_sdk-0.4.8 → quantdb-sdk-0.5.0}/quantdb_sdk/client.py +105 -24
  9. {quantdb_sdk-0.4.8 → quantdb-sdk-0.5.0}/quantdb_sdk/hub.py +62 -5
  10. {quantdb_sdk-0.4.8 → quantdb-sdk-0.5.0}/quantdb_sdk/layout.py +5 -0
  11. {quantdb_sdk-0.4.8 → quantdb-sdk-0.5.0/quantdb_sdk.egg-info}/PKG-INFO +3 -13
  12. quantdb-sdk-0.5.0/quantdb_sdk.egg-info/SOURCES.txt +21 -0
  13. quantdb_sdk-0.4.8/quantdb_sdk/examples/__init__.py +0 -9
  14. quantdb_sdk-0.4.8/quantdb_sdk/examples/demo_01_connect_basics.py +0 -55
  15. quantdb_sdk-0.4.8/quantdb_sdk/examples/demo_02_kline_v2_daily.py +0 -48
  16. quantdb_sdk-0.4.8/quantdb_sdk/examples/demo_03_sync_full_market.py +0 -50
  17. quantdb_sdk-0.4.8/quantdb_sdk/examples/demo_04_duckdb_warehouse.py +0 -46
  18. quantdb_sdk-0.4.8/quantdb_sdk/examples/demo_05_read_local_data.py +0 -68
  19. quantdb_sdk-0.4.8/quantdb_sdk/examples/demo_06_tick_and_factors.py +0 -45
  20. quantdb_sdk-0.4.8/quantdb_sdk/examples/demo_07_data_hub.py +0 -66
  21. quantdb_sdk-0.4.8/quantdb_sdk.egg-info/SOURCES.txt +0 -39
  22. quantdb_sdk-0.4.8/tests/test_async_client.py +0 -218
  23. quantdb_sdk-0.4.8/tests/test_cli_encoding.py +0 -92
  24. quantdb_sdk-0.4.8/tests/test_client.py +0 -312
  25. quantdb_sdk-0.4.8/tests/test_data_hub.py +0 -351
  26. quantdb_sdk-0.4.8/tests/test_hub_models.py +0 -125
  27. quantdb_sdk-0.4.8/tests/test_layout.py +0 -101
  28. quantdb_sdk-0.4.8/tests/test_mcp_download.py +0 -134
  29. quantdb_sdk-0.4.8/tests/test_technical_indicators.py +0 -237
  30. quantdb_sdk-0.4.8/tests/test_update_pipeline.py +0 -44
  31. quantdb_sdk-0.4.8/tests/test_utils.py +0 -102
  32. {quantdb_sdk-0.4.8 → quantdb-sdk-0.5.0}/LICENSE +0 -0
  33. {quantdb_sdk-0.4.8 → quantdb-sdk-0.5.0}/MANIFEST.in +0 -0
  34. {quantdb_sdk-0.4.8 → quantdb-sdk-0.5.0}/README.md +0 -0
  35. {quantdb_sdk-0.4.8 → quantdb-sdk-0.5.0}/quantdb_sdk/errors.py +0 -0
  36. {quantdb_sdk-0.4.8 → quantdb-sdk-0.5.0}/quantdb_sdk/mcp_download.py +0 -0
  37. {quantdb_sdk-0.4.8 → quantdb-sdk-0.5.0}/quantdb_sdk/py.typed +0 -0
  38. {quantdb_sdk-0.4.8 → quantdb-sdk-0.5.0}/quantdb_sdk.egg-info/dependency_links.txt +0 -0
  39. {quantdb_sdk-0.4.8 → quantdb-sdk-0.5.0}/quantdb_sdk.egg-info/entry_points.txt +0 -0
  40. {quantdb_sdk-0.4.8 → quantdb-sdk-0.5.0}/quantdb_sdk.egg-info/requires.txt +0 -0
  41. {quantdb_sdk-0.4.8 → quantdb-sdk-0.5.0}/quantdb_sdk.egg-info/top_level.txt +0 -0
  42. {quantdb_sdk-0.4.8 → quantdb-sdk-0.5.0}/setup.cfg +0 -0
@@ -3,6 +3,48 @@
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
+ ## \[Unreleased]
7
+
8
+ ### Removed
9
+
10
+ - **移除演示脚本包 `quantdb_sdk.examples`**(demo_01 ~ demo_07,共 7 个脚本):不再随 wheel 发布。CLI 帮助与文档中的演示脚本指引已同步删除,改用 `quantdb layout` / `quantdb datasets` 自述目录规范与数据集目录。
11
+
12
+ ## \[0.5.0] - 2026-10-31
13
+
14
+ ### Added
15
+
16
+ - **字段字典接入(列级字段说明)**:数据集可以自带一份字段字典 JSON(随数据集放在 COS),SDK 现在能直接读到列级的「中文名 / 公式 / 释义 / 分组 / 来源 / 方向」。首批支持 `factor_dictionary`(V5 全因子宽表,571 列 = 2 键 + 6 列后复权 OHLCV + 563 因子)。
17
+ - `client.get_fields(sub_category, group=..., query=..., field=..., limit=...)` → 列明细 DataFrame;
18
+ - `client.get_field_groups(sub_category)` → 分组摘要(组名 + 字段数);
19
+ - `client.get_field_dictionary(...)` → 原始响应(含 `schema_version` / `total_columns` / `groups`);
20
+ - `client.data.fields(...)` / `client.data.field_groups(...)` 为 Hub 层封装;
21
+ - 异步端对应 `a_get_fields` / `a_get_field_groups` / `a_get_field_dictionary` 与 `a_fields` / `a_field_groups`;
22
+ - `client.data.info("factor_dictionary")` 现在额外返回 `field_count` 与 `field_groups`(没有字典的数据集不受影响,404 被静默忽略)。
23
+ - 新增 CLI:`quantdb fields factor_dictionary [--group/--query/--field/--limit/--json]`,不带过滤条件时打印分组摘要。
24
+ - 字典接口**不消耗下载流量**;摘要响应在客户端进程内缓存,重复调用不重复请求(也不额外占用 30/min 限流额度)。
25
+
26
+ ### Changed
27
+
28
+ - `describe_dataset`(MCP)在有字典时额外返回 `field_groups` / `field_count`,并提示用 `describe_fields` 查看列明细。
29
+
30
+ ## \[0.4.9] - 2026-10-31
31
+
32
+ ### Added
33
+
34
+ - **注册新数据集 `factor_dictionary`(第 6 大类,订阅级)**:V5 全因子宽表,571 列(8 个键与后复权 OHLCV + 563 个因子),COS 布局为 `6_ml_datasets/factor_dictionary/dt=YYYYMMDD/data.parquet`。此前该数据集在 SDK 侧完全未注册,`client.data.*` / `sync_dataset` / `mount_local_dataset` / CLI / MCP 全部拒绝。本次补齐三张表:
35
+ - `_utils.SYNC_DATASET_CATEGORIES` 加 `"factor_dictionary" -> "6"`;
36
+ - `hub._ENTRIES` 加一条注册表条目(名称/描述/`subscription`/关键词/`v2_daily_partition`/选择器);
37
+ - `layout.V2_DAILY_PARTITION` 加 `factor_dictionary`。
38
+
39
+ ### Fixed
40
+
41
+ - **`layout.V2_DAILY_PARTITION` 与 COS 实际布局漂移**:补入 `min1_kline`、`min5_kline`、`etf_kline`。经 COS 复核,四者均为 `{category}/{sub}/dt=YYYYMMDD/data.parquet`,此前被 `dataset_layout()` 误判为 `v1_symbol`,导致公开 API `dataset_layout()` / `local_relative_path()` 与 `quantdb layout` 输出错误路径(etf_kline 的 V1 逐股票文件在 COS 上早已删除)。
42
+ - `hub` 模块 docstring 与 `DATASET_REGISTRY` 注释中的数据集数量(29)更正为 31;`__init__.__version__` 的 importlib 回退值(0.4.5)同步到 0.4.9。
43
+
44
+ ### Known issues
45
+
46
+ - `tick_data` 在 COS 上同样是 `dt=YYYYMMDD/data.parquet`(单日全市场约 189 MB),但 SDK 仍按 `tick_shard` 处理本地读取(`{SYM}_{YYYYMMDD}.parquet` glob + 文件名取日期)。带 `symbol` 的 `read_local("tick_data", ...)` 因此找不到 dt 分区,属既有缺陷,切换布局是行为变更,留待单独修复。
47
+
6
48
  ## \[0.4.8] - 2026-10-27
7
49
 
8
50
  ### Fixed
@@ -31,20 +73,20 @@
31
73
 
32
74
  > 兼容性:0.4.7 对接 2026-10-27 上线的服务端版本;旧服务端(两跳 cdn-bridge)仍可正常同步,无需回退。
33
75
 
34
- ## \[0.4.6] - 2026-10-27
35
-
36
- ### Fixed
37
-
38
- - **增量同步的「检查更新」不再重复计费**:`sync_dataset` 此前对本地已存在的文件先发一次带 `If-None-Match` 的 GET 探测版本。ETag 命中时服务端在预扣前返回 304(免流量),但**ETag 不匹配时(对象已更新,正是需要下载的常见情况)该请求会继续走到流量预扣 + CDN 直连,把整个文件传完**,客户端却丢弃响应体再重下一次。CDN 直连模式签发即计费、不退款,于是「检查更新」被白白计费一次。
39
-
40
- 实测近 2 天生产数据:下载请求 309,973 次但只涉及 71,675 个不同文件(**放大到 4.3 倍**),其中 **93.6% 是同 ETag 的纯重复下载**,纯浪费带宽与用户流量。
41
-
42
- 修复:探测改用服务端新增的 `HEAD /api/v1/data/download`——只回 ETag 与长度,在流量预扣之前返回,**零流量、零令牌、零 PG 事务**。ETag 命中时同样返回 304。同步与异步客户端的 V2 release 与 V1 manifest 两条探测路径均已改用 HEAD。
43
-
44
- 客户端侧行为不变:探测结果与本地记录一致即免流量跳过,不一致才真正下载。
45
-
46
- 兼容性:服务端不支持 HEAD 时(老版本返回 405)自动退回原 GET 流程,不影响可用性。
47
-
76
+ ## \[0.4.6] - 2026-10-27
77
+
78
+ ### Fixed
79
+
80
+ - **增量同步的「检查更新」不再重复计费**:`sync_dataset` 此前对本地已存在的文件先发一次带 `If-None-Match` 的 GET 探测版本。ETag 命中时服务端在预扣前返回 304(免流量),但**ETag 不匹配时(对象已更新,正是需要下载的常见情况)该请求会继续走到流量预扣 + CDN 直连,把整个文件传完**,客户端却丢弃响应体再重下一次。CDN 直连模式签发即计费、不退款,于是「检查更新」被白白计费一次。
81
+
82
+ 实测近 2 天生产数据:下载请求 309,973 次但只涉及 71,675 个不同文件(**放大到 4.3 倍**),其中 **93.6% 是同 ETag 的纯重复下载**,纯浪费带宽与用户流量。
83
+
84
+ 修复:探测改用服务端新增的 `HEAD /api/v1/data/download`——只回 ETag 与长度,在流量预扣之前返回,**零流量、零令牌、零 PG 事务**。ETag 命中时同样返回 304。同步与异步客户端的 V2 release 与 V1 manifest 两条探测路径均已改用 HEAD。
85
+
86
+ 客户端侧行为不变:探测结果与本地记录一致即免流量跳过,不一致才真正下载。
87
+
88
+ 兼容性:服务端不支持 HEAD 时(老版本返回 405)自动退回原 GET 流程,不影响可用性。
89
+
48
90
  ## \[0.4.5] - 2026-10-18
49
91
 
50
92
  ### Added
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.1
2
2
  Name: quantdb-sdk
3
- Version: 0.4.8
3
+ Version: 0.5.0
4
4
  Summary: QuantDB 量化数据平台官方 Python SDK
5
5
  Author: QuantDB Team
6
6
  License: MIT
@@ -22,18 +22,8 @@ Classifier: Programming Language :: Python :: 3.12
22
22
  Classifier: Topic :: Office/Business :: Financial
23
23
  Requires-Python: >=3.8
24
24
  Description-Content-Type: text/markdown
25
- License-File: LICENSE
26
- Requires-Dist: requests>=2.25.0
27
- Requires-Dist: pandas>=1.3.0
28
- Requires-Dist: httpx>=0.24.0
29
- Requires-Dist: duckdb>=1.0.0
30
- Requires-Dist: pyarrow>=10.0.0
31
25
  Provides-Extra: dev
32
- Requires-Dist: pytest>=7.0.0; extra == "dev"
33
- Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
34
- Requires-Dist: responses>=0.23.0; extra == "dev"
35
- Requires-Dist: respx>=0.20.0; extra == "dev"
36
- Dynamic: license-file
26
+ License-File: LICENSE
37
27
 
38
28
  # QuantDB Python SDK
39
29
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "quantdb-sdk"
7
- version = "0.4.8"
7
+ version = "0.5.0"
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.4.5"
8
+ __version__ = "0.5.0"
9
9
 
10
10
  from . import layout
11
11
  from ._utils import scan_local_directory
@@ -7,9 +7,6 @@
7
7
  python -m quantdb datasets # 列出全部数据集目录(无需 API Key)
8
8
  python -m quantdb datasets l1_factors # 查看某个数据集详情
9
9
  QUANTDB_API_KEY=qdb_xxx quantdb check # 验证 API Key 是否有效
10
-
11
- 演示脚本见 quantdb_sdk.examples(demo_01 ~ demo_06),
12
- 如: python -m quantdb_sdk.examples.demo_03_sync_full_market
13
10
  """
14
11
 
15
12
  import argparse
@@ -102,7 +99,7 @@ def main() -> int:
102
99
  parser = argparse.ArgumentParser(
103
100
  prog="quantdb",
104
101
  description="QuantDB 量化数据平台官方 Python SDK",
105
- epilog="演示脚本: python -m quantdb_sdk.examples.demo_01 .. demo_06;目录规范: quantdb layout",
102
+ epilog="目录规范: quantdb layout;数据集目录: quantdb datasets",
106
103
  )
107
104
  parser.add_argument(
108
105
  "--version",
@@ -150,6 +147,14 @@ def main() -> int:
150
147
  download_parser.add_argument("--save-dir", default=None, help="本地数据根目录")
151
148
  download_parser.add_argument("--json", action="store_true", help="输出 JSON 保存路径和文件大小")
152
149
 
150
+ fields_parser = subparsers.add_parser("fields", help="查询数据集的字段字典(列级说明,不消耗流量)")
151
+ fields_parser.add_argument("dataset", help="数据集,如 factor_dictionary")
152
+ fields_parser.add_argument("--group", default=None, help="分组子串,如 动量 / L2 2.1")
153
+ fields_parser.add_argument("--query", default=None, help="在字段名/中文名/释义/公式上子串匹配")
154
+ fields_parser.add_argument("--field", default=None, help="精确字段名,如 turn_1")
155
+ fields_parser.add_argument("--limit", type=int, default=None, help="返回列数上限(默认 200)")
156
+ fields_parser.add_argument("--json", action="store_true", help="输出 JSON")
157
+
153
158
  args = parser.parse_args()
154
159
 
155
160
  if not args.command:
@@ -205,6 +210,32 @@ def main() -> int:
205
210
  print(f" Release: {result['release_id']}")
206
211
  return 0
207
212
 
213
+ if args.command == "fields":
214
+ filtered = bool(args.group or args.query or args.field)
215
+ if args.json:
216
+ payload = client.get_field_dictionary(
217
+ args.dataset, group=args.group, query=args.query,
218
+ field=args.field, limit=args.limit,
219
+ )
220
+ print(json.dumps(payload, ensure_ascii=False))
221
+ return 0
222
+ if not filtered:
223
+ groups = client.get_field_groups(args.dataset)
224
+ print(f"{args.dataset} 字段分组(共 {int(groups['count'].sum())} 列):")
225
+ for _, row in groups.iterrows():
226
+ print(f" {int(row['count']):>4} {row['group']}")
227
+ print("\n加 --group / --query / --field 查看列明细。")
228
+ return 0
229
+ df = client.get_fields(args.dataset, group=args.group, query=args.query,
230
+ field=args.field, limit=args.limit)
231
+ if df.empty:
232
+ print("没有匹配的字段。")
233
+ return 0
234
+ print(f"匹配 {len(df)} 列:")
235
+ for _, row in df.iterrows():
236
+ print(f" {row['field']:<32} {row['name_cn']:<18} [{row['group']}]")
237
+ return 0
238
+
208
239
  except Exception as e:
209
240
  print(f"[ERROR] {e}", file=sys.stderr)
210
241
  return 1
@@ -23,6 +23,7 @@ SYNC_DATASET_CATEGORIES: Dict[str, str] = {
23
23
  "etf_pcf": "4", "convertible_bond": "4", "etf_kline": "4",
24
24
  "valuation": "5", "technical_indicators": "5", "market_sentiment": "5",
25
25
  "features_daily": "6", "l1_factors": "6", "l1_l2_factors": "6", "l2_factors": "6",
26
+ "factor_dictionary": "6",
26
27
  }
27
28
 
28
29
 
@@ -28,7 +28,7 @@ from .errors import (
28
28
  )
29
29
 
30
30
  # 维护提示:每次发版必须与 pyproject.toml 版本号同步
31
- _USER_AGENT = "QuantDB-Python-SDK/0.4.8"
31
+ _USER_AGENT = "QuantDB-Python-SDK/0.5.0"
32
32
 
33
33
 
34
34
  def _etag_hex(value: Optional[str]) -> Optional[str]:
@@ -83,6 +83,9 @@ class AsyncQuantDBClient:
83
83
  # 进程内 parquet ETag 缓存:同对象(ETag 未变)不重复下载,避免重复计费。
84
84
  self._cache: Dict[str, Dict[str, Any]] = {}
85
85
 
86
+ # 字段字典摘要缓存(dataset -> 原始响应),与同步客户端同语义。
87
+ self._field_cache: Dict[str, Dict[str, Any]] = {}
88
+
86
89
  # 统一数据入口(quantdb_sdk.hub.AsyncDataHub)惰性实例,见 data property。
87
90
  self._data_hub: Optional[Any] = None
88
91
 
@@ -609,6 +612,61 @@ class AsyncQuantDBClient:
609
612
  df["file_size_gb"] = df["file_size"].fillna(0).map(bytes_to_gb).round(2)
610
613
  return df
611
614
 
615
+ # ========== 字段字典(列级说明,不消耗下载流量) ==========
616
+
617
+ async def a_get_field_dictionary(
618
+ self,
619
+ sub_category: str,
620
+ group: Optional[str] = None,
621
+ query: Optional[str] = None,
622
+ field: Optional[str] = None,
623
+ limit: Optional[int] = None,
624
+ use_cache: bool = True,
625
+ ) -> Dict[str, Any]:
626
+ """返回字段字典原始响应(异步):schema 信息 + ``groups`` [+ ``columns``]。
627
+
628
+ 不带过滤条件时服务端只返回分组摘要;同一进程内摘要会缓存复用。
629
+ """
630
+ filtered = bool(group or query or field)
631
+ cache_key = f"summary:{sub_category}"
632
+ if use_cache and not filtered and cache_key in self._field_cache:
633
+ return self._field_cache[cache_key]
634
+
635
+ params: Dict[str, Any] = {"sub_category": sub_category}
636
+ if group:
637
+ params["group"] = group
638
+ if query:
639
+ params["query"] = query
640
+ if field:
641
+ params["field"] = field
642
+ if limit:
643
+ params["limit"] = int(limit)
644
+ payload = await self._get("/api/v1/data/fields", params)
645
+ if use_cache and not filtered:
646
+ self._field_cache[cache_key] = payload
647
+ return payload
648
+
649
+ async def a_get_fields(
650
+ self,
651
+ sub_category: str,
652
+ group: Optional[str] = None,
653
+ query: Optional[str] = None,
654
+ field: Optional[str] = None,
655
+ limit: Optional[int] = None,
656
+ ) -> pd.DataFrame:
657
+ """查询列级字段说明(异步);必须带 group / query / field 之一。"""
658
+ payload = await self.a_get_field_dictionary(
659
+ sub_category, group=group, query=query, field=field, limit=limit,
660
+ )
661
+ columns = payload.get("columns") or []
662
+ return pd.DataFrame(columns, columns=["field", "name_cn", "formula", "description",
663
+ "group", "source", "direction", "order"])
664
+
665
+ async def a_get_field_groups(self, sub_category: str) -> pd.DataFrame:
666
+ """字段分组摘要(异步):``group`` + ``count``。"""
667
+ payload = await self.a_get_field_dictionary(sub_category)
668
+ return pd.DataFrame(payload.get("groups") or [], columns=["group", "count"])
669
+
612
670
  # ========== 数据预览与下载 ==========
613
671
 
614
672
  async def a_preview_as_df(
@@ -32,7 +32,13 @@ from .errors import (
32
32
  )
33
33
 
34
34
  # 维护提示:每次发版必须与 pyproject.toml 版本号同步
35
- _USER_AGENT = "QuantDB-Python-SDK/0.4.8"
35
+ _USER_AGENT = "QuantDB-Python-SDK/0.5.0"
36
+
37
+ # 字段字典的列顺序(与后端 /api/v1/data/fields 返回的 columns[] 一致)
38
+ _FIELD_COLUMNS = (
39
+ "field", "name_cn", "formula", "description",
40
+ "group", "source", "direction", "order",
41
+ )
36
42
 
37
43
 
38
44
  def _etag_hex(value: Optional[str]) -> Optional[str]:
@@ -95,6 +101,10 @@ class QuantDBClient:
95
101
  # 只需下载一次。进程退出即失效(无跨进程副作用)。
96
102
  self._cache: Dict[str, Dict[str, Any]] = {}
97
103
 
104
+ # 字段字典摘要缓存(dataset -> 原始响应):字典是静态文件,
105
+ # 同一进程内重复 info()/get_field_groups() 不必反复请求(也不吃 30/min 限流)。
106
+ self._field_cache: Dict[str, Dict[str, Any]] = {}
107
+
98
108
  # 统一数据入口(quantdb_sdk.hub.DataHub)惰性实例,见 data property。
99
109
  self._data_hub: Optional[Any] = None
100
110
 
@@ -145,29 +155,29 @@ class QuantDBClient:
145
155
  resp = self._request("GET", path, params=params)
146
156
  return self._check_response(resp)
147
157
 
148
- def _probe_head(self, params: dict, headers: Optional[dict] = None) -> requests.Response:
149
- """HEAD 探测对象元数据(增量同步的「检查更新」):零流量、零令牌、零计费。
150
-
151
- 服务端在流量预扣之前返回 ETag 与 Content-Length,客户端与本地记录
152
- 比对后决定是否需要真正下载。
153
-
154
- 为什么必须用 HEAD 而不是带 If-None-Match 的 GET:
155
- GET 探测在 ETag 不匹配时(对象已更新,正是需要下载的常见情况)会
156
- 继续走到流量预扣 + CDN 直连,把整个文件传完;客户端却丢弃响应体
157
- 再重下一次。CDN 直连模式签发即计费、不退款,于是「检查更新」被
158
- 白白计费一次——实测近 2 天下载请求量放大到文件数的 4.3 倍,
159
- 其中 93.6% 是同 ETag 的纯重复下载。
160
-
161
- 服务端不支持 HEAD 时(老版本)返回 405,调用方应退回 GET 流程。
162
- """
163
- try:
164
- return self._request(
165
- "HEAD", "/api/v1/data/download", params=params,
166
- headers=headers, allow_redirects=False,
167
- )
168
- except requests.RequestException:
169
- return requests.Response()
170
-
158
+ def _probe_head(self, params: dict, headers: Optional[dict] = None) -> requests.Response:
159
+ """HEAD 探测对象元数据(增量同步的「检查更新」):零流量、零令牌、零计费。
160
+
161
+ 服务端在流量预扣之前返回 ETag 与 Content-Length,客户端与本地记录
162
+ 比对后决定是否需要真正下载。
163
+
164
+ 为什么必须用 HEAD 而不是带 If-None-Match 的 GET:
165
+ GET 探测在 ETag 不匹配时(对象已更新,正是需要下载的常见情况)会
166
+ 继续走到流量预扣 + CDN 直连,把整个文件传完;客户端却丢弃响应体
167
+ 再重下一次。CDN 直连模式签发即计费、不退款,于是「检查更新」被
168
+ 白白计费一次——实测近 2 天下载请求量放大到文件数的 4.3 倍,
169
+ 其中 93.6% 是同 ETag 的纯重复下载。
170
+
171
+ 服务端不支持 HEAD 时(老版本)返回 405,调用方应退回 GET 流程。
172
+ """
173
+ try:
174
+ return self._request(
175
+ "HEAD", "/api/v1/data/download", params=params,
176
+ headers=headers, allow_redirects=False,
177
+ )
178
+ except requests.RequestException:
179
+ return requests.Response()
180
+
171
181
  def _download_stream(self, params: dict, headers: Optional[dict] = None) -> requests.Response:
172
182
  """下载专用流式请求:手动处理服务端 302 CDN 直连跳转。
173
183
 
@@ -684,6 +694,77 @@ class QuantDBClient:
684
694
  df["file_size_gb"] = df["file_size"].fillna(0).map(bytes_to_gb).round(2)
685
695
  return df
686
696
 
697
+ # ========== 字段字典(列级说明,不消耗下载流量) ==========
698
+
699
+ def get_field_dictionary(
700
+ self,
701
+ sub_category: str,
702
+ group: Optional[str] = None,
703
+ query: Optional[str] = None,
704
+ field: Optional[str] = None,
705
+ limit: Optional[int] = None,
706
+ use_cache: bool = True,
707
+ ) -> Dict[str, Any]:
708
+ """返回字段字典原始响应(schema 信息 + ``groups`` 分组摘要 [+ ``columns``])。
709
+
710
+ 字典由数据侧随数据集发布(如 ``factor_dictionary`` 的
711
+ ``v5_factor_dictionary.json``),后端读取 COS 后缓存,**不消耗下载流量**。
712
+
713
+ :param sub_category: 数据集标识,如 ``factor_dictionary``。
714
+ :param group: 可选,分组子串匹配(如 ``动量`` / ``L2 2.1``)。
715
+ :param query: 可选,在字段名/中文名/释义/公式上做子串匹配。
716
+ :param field: 可选,精确字段名(如 ``turn_1``)。
717
+ :param limit: 可选,返回列数上限(服务端默认 200,最大 1000)。
718
+ :param use_cache: 同一进程内复用已取过的「摘要」响应(仅当不带过滤条件时)。
719
+ :raises NotFoundError: 该数据集没有字段字典。
720
+ """
721
+ filtered = bool(group or query or field)
722
+ cache_key = f"summary:{sub_category}"
723
+ if use_cache and not filtered and cache_key in self._field_cache:
724
+ return self._field_cache[cache_key]
725
+
726
+ params: Dict[str, Any] = {"sub_category": sub_category}
727
+ if group:
728
+ params["group"] = group
729
+ if query:
730
+ params["query"] = query
731
+ if field:
732
+ params["field"] = field
733
+ if limit:
734
+ params["limit"] = int(limit)
735
+ payload = self._get("/api/v1/data/fields", params)
736
+ if use_cache and not filtered:
737
+ self._field_cache[cache_key] = payload
738
+ return payload
739
+
740
+ def get_fields(
741
+ self,
742
+ sub_category: str,
743
+ group: Optional[str] = None,
744
+ query: Optional[str] = None,
745
+ field: Optional[str] = None,
746
+ limit: Optional[int] = None,
747
+ ) -> pd.DataFrame:
748
+ """查询某数据集的列级字段说明(中文名 / 公式 / 释义 / 分组 / 方向)。
749
+
750
+ **必须带 ``group`` / ``query`` / ``field`` 之一**:不带过滤条件时服务端只返回
751
+ 分组摘要(571 列全量下发会撑爆调用方上下文),此时返回空 DataFrame,
752
+ 请改用 :meth:`get_field_groups`。
753
+
754
+ :return: DataFrame,列为 ``field`` / ``name_cn`` / ``formula`` /
755
+ ``description`` / ``group`` / ``source`` / ``direction`` / ``order``。
756
+ """
757
+ payload = self.get_field_dictionary(
758
+ sub_category, group=group, query=query, field=field, limit=limit,
759
+ )
760
+ columns = payload.get("columns") or []
761
+ return pd.DataFrame(columns, columns=list(_FIELD_COLUMNS))
762
+
763
+ def get_field_groups(self, sub_category: str) -> pd.DataFrame:
764
+ """字段分组摘要(``group`` 组名 + ``count`` 字段数),不消耗下载流量。"""
765
+ payload = self.get_field_dictionary(sub_category)
766
+ return pd.DataFrame(payload.get("groups") or [], columns=["group", "count"])
767
+
687
768
  # ========== 数据预览与下载(消耗下载流量) ==========
688
769
 
689
770
  def preview_as_df(
@@ -9,7 +9,7 @@
9
9
  from quantdb_sdk import QuantDBClient
10
10
 
11
11
  client = QuantDBClient(api_key="qdb_xxx")
12
- client.data.datasets() # 29 个数据集目录(零流量)
12
+ client.data.datasets() # 31 个数据集目录(零流量)
13
13
  client.data.info("l1_factors") # 覆盖区间 + 文件大小
14
14
  client.data.read_local("daily_forward", start_date="2026-09-01") # 本地零流量
15
15
  client.data.preview("balance", symbol="600519.SH") # 在线预览
@@ -21,7 +21,8 @@
21
21
  ``category_dir`` 复用 :data:`quantdb_sdk.layout.CATEGORY_DIRS`,避免重复维护分类事实源。
22
22
  - ``layout`` 记录数据集的**实际 COS 形态**(``v2_daily_partition`` / ``v1_symbol`` /
23
23
  ``tick_shard``),用于 ``query()`` 路由;不用 :func:`quantdb_sdk.layout.dataset_layout`
24
- (其 V2 集合已过期,缺 min1/min5/etf/tick)。
24
+ (其 V2 集合截至 2026-10-31 已与 COS 对齐,但 tick_data 例外:COS 已改为 dt= 分区,
25
+ 本地读取仍是分片语义)。
25
26
  - 本地读取用**多形态 glob** 与目录布局集合解耦,支持显式 ``root``(数据根目录可配置,
26
27
  非固定目录;缺省读 :func:`quantdb_sdk.layout.data_root`);读取本身交给 DuckDB 对
27
28
  精确文件列表做一次 ``read_parquet``(列裁剪 / symbol / 日期下推),不逐文件拼接。
@@ -157,6 +158,8 @@ _ENTRIES: Tuple[Tuple[str, str, str, str, Tuple[str, ...], str, Tuple[str, ...]]
157
158
  ("l2", "因子", "factor"), "v2_daily_partition", ("symbol", "trade_date")),
158
159
  ("l1_l2_factors", "L1+L2 合并因子", "L1 和 L2 的合并宽表。", "subscription",
159
160
  ("l1", "l2", "因子", "factor", "合并"), "v2_daily_partition", ("symbol", "trade_date")),
161
+ ("factor_dictionary", "V5 因子宽表", "V5 全因子宽表:571 列(2 键 + 6 列后复权 OHLCV + 563 因子),按交易日分区;字段字典见数据集目录内 v5_factor_dictionary.json。", "subscription",
162
+ ("因子", "factor", "v5", "宽表", "f500", "字典"), "v2_daily_partition", ("symbol", "trade_date")),
160
163
  )
161
164
 
162
165
 
@@ -178,7 +181,7 @@ def _build_registry() -> Dict[str, DatasetInfo]:
178
181
  return registry
179
182
 
180
183
 
181
- #: dataset 名 -> DatasetInfo(29 项)
184
+ #: dataset 名 -> DatasetInfo(31 项)
182
185
  DATASET_REGISTRY: Dict[str, DatasetInfo] = _build_registry()
183
186
 
184
187
 
@@ -509,7 +512,9 @@ class DataHub:
509
512
  """返回数据集注册表信息,并可选叠加云端元数据(覆盖区间/文件数/大小)。
510
513
 
511
514
  ``with_meta=True``(默认)时叠加 ``client.query_meta(sub_category=dataset)``
512
- 的 ``min_date/max_date/file_count/file_size_gb/row_count/updated_at``。
515
+ 的 ``min_date/max_date/file_count/file_size_gb/row_count/updated_at``,
516
+ 并在该数据集带**字段字典**时额外叠加 ``field_count`` 与 ``field_groups``
517
+ (如 ``factor_dictionary`` 的 571 列分组)。没有字典的数据集不受影响。
513
518
  """
514
519
  entry = _info(dataset).to_dict()
515
520
  if not with_meta:
@@ -519,8 +524,36 @@ class DataHub:
519
524
  except Exception:
520
525
  meta = None
521
526
  entry.update(_meta_row(meta))
527
+ try:
528
+ fields = self._client.get_field_dictionary(dataset)
529
+ except Exception:
530
+ fields = None
531
+ if isinstance(fields, dict) and fields.get("groups"):
532
+ entry["field_count"] = fields.get("total_columns")
533
+ entry["field_groups"] = fields.get("groups")
522
534
  return entry
523
535
 
536
+ def fields(
537
+ self,
538
+ dataset: str,
539
+ group: Optional[str] = None,
540
+ query: Optional[str] = None,
541
+ field: Optional[str] = None,
542
+ limit: Optional[int] = None,
543
+ ) -> pd.DataFrame:
544
+ """查询列级字段说明(中文名 / 公式 / 释义 / 分组 / 方向),不消耗下载流量。
545
+
546
+ 必须带 ``group`` / ``query`` / ``field`` 之一,否则服务端只回分组摘要、
547
+ 返回空 DataFrame(用 :meth:`field_groups` 拿摘要)。
548
+ """
549
+ return self._client.get_fields(
550
+ _info(dataset).dataset, group=group, query=query, field=field, limit=limit,
551
+ )
552
+
553
+ def field_groups(self, dataset: str) -> pd.DataFrame:
554
+ """字段分组摘要(``group`` + ``count``),不消耗下载流量。"""
555
+ return self._client.get_field_groups(_info(dataset).dataset)
556
+
524
557
  def local_files(
525
558
  self,
526
559
  dataset: str,
@@ -827,7 +860,7 @@ class AsyncDataHub:
827
860
  return pd.DataFrame(rows, columns=cols)
828
861
 
829
862
  async def a_info(self, dataset: str, with_meta: bool = True) -> Dict[str, Any]:
830
- """异步版 :meth:`DataHub.info`。"""
863
+ """异步版 :meth:`DataHub.info`(含字段字典分组摘要)。"""
831
864
  entry = _info(dataset).to_dict()
832
865
  if not with_meta:
833
866
  return entry
@@ -836,8 +869,32 @@ class AsyncDataHub:
836
869
  except Exception:
837
870
  meta = None
838
871
  entry.update(_meta_row(meta))
872
+ try:
873
+ fields = await self._client.a_get_field_dictionary(dataset)
874
+ except Exception:
875
+ fields = None
876
+ if isinstance(fields, dict) and fields.get("groups"):
877
+ entry["field_count"] = fields.get("total_columns")
878
+ entry["field_groups"] = fields.get("groups")
839
879
  return entry
840
880
 
881
+ async def a_fields(
882
+ self,
883
+ dataset: str,
884
+ group: Optional[str] = None,
885
+ query: Optional[str] = None,
886
+ field: Optional[str] = None,
887
+ limit: Optional[int] = None,
888
+ ) -> pd.DataFrame:
889
+ """异步版 :meth:`DataHub.fields`。"""
890
+ return await self._client.a_get_fields(
891
+ _info(dataset).dataset, group=group, query=query, field=field, limit=limit,
892
+ )
893
+
894
+ async def a_field_groups(self, dataset: str) -> pd.DataFrame:
895
+ """异步版 :meth:`DataHub.field_groups`。"""
896
+ return await self._client.a_get_field_groups(_info(dataset).dataset)
897
+
841
898
  async def a_local_files(
842
899
  self,
843
900
  dataset: str,
@@ -39,13 +39,18 @@ CATEGORY_DIRS = {
39
39
  }
40
40
 
41
41
  # 纯 V2 按日分区数据集(COS 上无 V1 逐股票文件)
42
+ # 2026-10-31 按 COS 实际布局补齐 min1_kline / min5_kline / etf_kline / factor_dictionary:
43
+ # 四者在 COS 上均为 {category}/{sub}/dt=YYYYMMDD/data.parquet(单文件全市场当日)。
42
44
  V2_DAILY_PARTITION = frozenset({
43
45
  "daily_unadjusted", "daily_forward", "daily_backward", "index_daily",
44
46
  "margin_trading", "valuation", "technical_indicators", "market_sentiment",
45
47
  "features_daily", "l1_factors", "l1_l2_factors", "l2_factors",
48
+ "min1_kline", "min5_kline", "etf_kline", "factor_dictionary",
46
49
  })
47
50
 
48
51
  # tick 按交易日分片数据集
52
+ # 注意:tick_data 在 COS 上同样已是 dt=YYYYMMDD/data.parquet(V1 分片已删),
53
+ # 但其本地读取仍走分片 glob 与文件名取日期,切换布局属于行为变更,待单独修复。
49
54
  TICK_SHARD = frozenset({"tick_data"})
50
55
 
51
56
  Layout = Literal["v2_daily_partition", "v1_symbol", "tick_shard"]
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.1
2
2
  Name: quantdb-sdk
3
- Version: 0.4.8
3
+ Version: 0.5.0
4
4
  Summary: QuantDB 量化数据平台官方 Python SDK
5
5
  Author: QuantDB Team
6
6
  License: MIT
@@ -22,18 +22,8 @@ Classifier: Programming Language :: Python :: 3.12
22
22
  Classifier: Topic :: Office/Business :: Financial
23
23
  Requires-Python: >=3.8
24
24
  Description-Content-Type: text/markdown
25
- License-File: LICENSE
26
- Requires-Dist: requests>=2.25.0
27
- Requires-Dist: pandas>=1.3.0
28
- Requires-Dist: httpx>=0.24.0
29
- Requires-Dist: duckdb>=1.0.0
30
- Requires-Dist: pyarrow>=10.0.0
31
25
  Provides-Extra: dev
32
- Requires-Dist: pytest>=7.0.0; extra == "dev"
33
- Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
34
- Requires-Dist: responses>=0.23.0; extra == "dev"
35
- Requires-Dist: respx>=0.20.0; extra == "dev"
36
- Dynamic: license-file
26
+ License-File: LICENSE
37
27
 
38
28
  # QuantDB Python SDK
39
29
 
@@ -0,0 +1,21 @@
1
+ CHANGELOG.md
2
+ LICENSE
3
+ MANIFEST.in
4
+ README.md
5
+ pyproject.toml
6
+ quantdb_sdk/__init__.py
7
+ quantdb_sdk/__main__.py
8
+ quantdb_sdk/_utils.py
9
+ quantdb_sdk/async_client.py
10
+ quantdb_sdk/client.py
11
+ quantdb_sdk/errors.py
12
+ quantdb_sdk/hub.py
13
+ quantdb_sdk/layout.py
14
+ quantdb_sdk/mcp_download.py
15
+ quantdb_sdk/py.typed
16
+ quantdb_sdk.egg-info/PKG-INFO
17
+ quantdb_sdk.egg-info/SOURCES.txt
18
+ quantdb_sdk.egg-info/dependency_links.txt
19
+ quantdb_sdk.egg-info/entry_points.txt
20
+ quantdb_sdk.egg-info/requires.txt
21
+ quantdb_sdk.egg-info/top_level.txt
@@ -1,9 +0,0 @@
1
- """QuantDB SDK 演示脚本包。
2
-
3
- 运行方式(任一)::
4
-
5
- python -m quantdb_sdk.examples.demo_01_connect_basics
6
- python -m quantdb_sdk.examples.demo_02_kline_v2_daily --symbol 600519.SH
7
-
8
- 脚本均从 ``QUANTDB_API_KEY`` 环境变量读取 API Key,也可用 ``--api-key`` 参数传入。
9
- """