quantdb-sdk 0.4.7__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 (41) hide show
  1. {quantdb_sdk-0.4.7 → quantdb-sdk-0.5.0}/CHANGELOG.md +70 -14
  2. {quantdb_sdk-0.4.7/quantdb_sdk.egg-info → quantdb-sdk-0.5.0}/PKG-INFO +3 -13
  3. {quantdb_sdk-0.4.7 → quantdb-sdk-0.5.0}/pyproject.toml +1 -1
  4. {quantdb_sdk-0.4.7 → quantdb-sdk-0.5.0}/quantdb_sdk/__init__.py +1 -1
  5. {quantdb_sdk-0.4.7 → quantdb-sdk-0.5.0}/quantdb_sdk/__main__.py +71 -14
  6. {quantdb_sdk-0.4.7 → quantdb-sdk-0.5.0}/quantdb_sdk/_utils.py +1 -0
  7. {quantdb_sdk-0.4.7 → quantdb-sdk-0.5.0}/quantdb_sdk/async_client.py +59 -1
  8. {quantdb_sdk-0.4.7 → quantdb-sdk-0.5.0}/quantdb_sdk/client.py +105 -24
  9. {quantdb_sdk-0.4.7 → quantdb-sdk-0.5.0}/quantdb_sdk/hub.py +62 -5
  10. {quantdb_sdk-0.4.7 → quantdb-sdk-0.5.0}/quantdb_sdk/layout.py +5 -0
  11. {quantdb_sdk-0.4.7 → 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.7/quantdb_sdk/examples/__init__.py +0 -9
  14. quantdb_sdk-0.4.7/quantdb_sdk/examples/demo_01_connect_basics.py +0 -55
  15. quantdb_sdk-0.4.7/quantdb_sdk/examples/demo_02_kline_v2_daily.py +0 -48
  16. quantdb_sdk-0.4.7/quantdb_sdk/examples/demo_03_sync_full_market.py +0 -50
  17. quantdb_sdk-0.4.7/quantdb_sdk/examples/demo_04_duckdb_warehouse.py +0 -46
  18. quantdb_sdk-0.4.7/quantdb_sdk/examples/demo_05_read_local_data.py +0 -68
  19. quantdb_sdk-0.4.7/quantdb_sdk/examples/demo_06_tick_and_factors.py +0 -45
  20. quantdb_sdk-0.4.7/quantdb_sdk/examples/demo_07_data_hub.py +0 -66
  21. quantdb_sdk-0.4.7/quantdb_sdk.egg-info/SOURCES.txt +0 -38
  22. quantdb_sdk-0.4.7/tests/test_async_client.py +0 -218
  23. quantdb_sdk-0.4.7/tests/test_client.py +0 -312
  24. quantdb_sdk-0.4.7/tests/test_data_hub.py +0 -351
  25. quantdb_sdk-0.4.7/tests/test_hub_models.py +0 -125
  26. quantdb_sdk-0.4.7/tests/test_layout.py +0 -101
  27. quantdb_sdk-0.4.7/tests/test_mcp_download.py +0 -134
  28. quantdb_sdk-0.4.7/tests/test_technical_indicators.py +0 -237
  29. quantdb_sdk-0.4.7/tests/test_update_pipeline.py +0 -44
  30. quantdb_sdk-0.4.7/tests/test_utils.py +0 -102
  31. {quantdb_sdk-0.4.7 → quantdb-sdk-0.5.0}/LICENSE +0 -0
  32. {quantdb_sdk-0.4.7 → quantdb-sdk-0.5.0}/MANIFEST.in +0 -0
  33. {quantdb_sdk-0.4.7 → quantdb-sdk-0.5.0}/README.md +0 -0
  34. {quantdb_sdk-0.4.7 → quantdb-sdk-0.5.0}/quantdb_sdk/errors.py +0 -0
  35. {quantdb_sdk-0.4.7 → quantdb-sdk-0.5.0}/quantdb_sdk/mcp_download.py +0 -0
  36. {quantdb_sdk-0.4.7 → quantdb-sdk-0.5.0}/quantdb_sdk/py.typed +0 -0
  37. {quantdb_sdk-0.4.7 → quantdb-sdk-0.5.0}/quantdb_sdk.egg-info/dependency_links.txt +0 -0
  38. {quantdb_sdk-0.4.7 → quantdb-sdk-0.5.0}/quantdb_sdk.egg-info/entry_points.txt +0 -0
  39. {quantdb_sdk-0.4.7 → quantdb-sdk-0.5.0}/quantdb_sdk.egg-info/requires.txt +0 -0
  40. {quantdb_sdk-0.4.7 → quantdb-sdk-0.5.0}/quantdb_sdk.egg-info/top_level.txt +0 -0
  41. {quantdb_sdk-0.4.7 → quantdb-sdk-0.5.0}/setup.cfg +0 -0
@@ -3,6 +3,62 @@
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
+
48
+ ## \[0.4.8] - 2026-10-27
49
+
50
+ ### Fixed
51
+
52
+ - **`quantdb usage` 在中文 Windows 控制台崩溃**:`余额` 一行输出的是窄人民币符号 U+00A5,而 Windows 中文控制台默认编码是 GBK/cp936,**GBK 中不存在该字符**(GBK 用 0x80 表示),`print` 抛 `UnicodeEncodeError: 'gbk' codec can't encode character '\xa5'`,命令以异常收场、后续输出(订阅状态等)全部丢失。
53
+ 修复两处:① 金额改用**全角人民币符号 U+FFE5**(GBK 可编码,中文终端显示正常);② CLI 入口新增 `_configure_stdout()`,把 stdout/stderr 的编码错误处理降级为 `replace`——以后任何不可编码字符只会显示为 `?`,不再中断命令。
54
+
55
+ ### Added
56
+
57
+ - 新增回归测试 `tests/test_cli_encoding.py`(仓库根 `tests/`,不进 wheel):
58
+ - 扫描 SDK 全部源码的**字符串字面量**,断言每个字符都能被 GBK 编码——防止再次引入不可编码字符;
59
+ - 在 GBK 编码的 stdout 下渲染用量输出,断言不抛异常;
60
+ - 断言 `_configure_stdout()` 确实把编码错误处理改为 `replace`。
61
+
6
62
  ## \[0.4.7] - 2026-10-27
7
63
 
8
64
  ### Fixed
@@ -17,20 +73,20 @@
17
73
 
18
74
  > 兼容性:0.4.7 对接 2026-10-27 上线的服务端版本;旧服务端(两跳 cdn-bridge)仍可正常同步,无需回退。
19
75
 
20
- ## \[0.4.6] - 2026-10-27
21
-
22
- ### Fixed
23
-
24
- - **增量同步的「检查更新」不再重复计费**:`sync_dataset` 此前对本地已存在的文件先发一次带 `If-None-Match` 的 GET 探测版本。ETag 命中时服务端在预扣前返回 304(免流量),但**ETag 不匹配时(对象已更新,正是需要下载的常见情况)该请求会继续走到流量预扣 + CDN 直连,把整个文件传完**,客户端却丢弃响应体再重下一次。CDN 直连模式签发即计费、不退款,于是「检查更新」被白白计费一次。
25
-
26
- 实测近 2 天生产数据:下载请求 309,973 次但只涉及 71,675 个不同文件(**放大到 4.3 倍**),其中 **93.6% 是同 ETag 的纯重复下载**,纯浪费带宽与用户流量。
27
-
28
- 修复:探测改用服务端新增的 `HEAD /api/v1/data/download`——只回 ETag 与长度,在流量预扣之前返回,**零流量、零令牌、零 PG 事务**。ETag 命中时同样返回 304。同步与异步客户端的 V2 release 与 V1 manifest 两条探测路径均已改用 HEAD。
29
-
30
- 客户端侧行为不变:探测结果与本地记录一致即免流量跳过,不一致才真正下载。
31
-
32
- 兼容性:服务端不支持 HEAD 时(老版本返回 405)自动退回原 GET 流程,不影响可用性。
33
-
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
+
34
90
  ## \[0.4.5] - 2026-10-18
35
91
 
36
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.7
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.7"
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
@@ -63,11 +60,46 @@ def cmd_datasets(name: Optional[str] = None) -> int:
63
60
  return 0
64
61
 
65
62
 
63
+ def _configure_stdout() -> None:
64
+ """让 CLI 在非 UTF-8 控制台(中文 Windows 默认 GBK/cp936)下不因不可编码字符崩溃。
65
+
66
+ Windows 中文控制台的默认编码是 GBK,而窄人民币符号 U+00A5 在 GBK 中**不存在**
67
+ (GBK 用 0x80 表示该符号),直接 print 会抛
68
+ ``UnicodeEncodeError: 'gbk' codec can't encode character '\\xa5'``,
69
+ 导致整条命令以异常收场、后续输出全部丢失。
70
+
71
+ 这里把 stdout/stderr 的编码错误处理改成 replace:无法编码的字符降级为 '?',
72
+ 命令本身不再中断。配合正文中改用全角 ¥(U+FFE5,GBK 可编码),
73
+ 中文 Windows 终端可正常显示金额。
74
+ """
75
+ for stream in (sys.stdout, sys.stderr):
76
+ try:
77
+ stream.reconfigure(errors="replace") # type: ignore[attr-defined]
78
+ except Exception: # pragma: no cover - 老版本/非 TextIOWrapper 忽略
79
+ pass
80
+
81
+
82
+ def print_usage(usage: dict) -> None:
83
+ """打印账户用量(抽成独立函数便于单测覆盖非 UTF-8 控制台场景)。"""
84
+ print("账户用量:")
85
+ print(f" 套餐: 已用 {usage['plan_used_gb']:.2f} GB / {usage['plan_total_gb']:.2f} GB"
86
+ f"(剩余 {usage['plan_remaining_gb']:.2f} GB)")
87
+ print(f" 流量包: 已用 {usage['package_used_gb']:.2f} GB / {usage['package_total_gb']:.2f} GB"
88
+ f"(剩余 {usage['package_remaining_gb']:.2f} GB)")
89
+ print(f" 合计: 已用 {usage['used_gb']:.2f} GB / {usage['total_gb']:.2f} GB"
90
+ f"(剩余 {usage['remaining_gb']:.2f} GB)")
91
+ # 全角人民币符号 U+FFE5:GBK 可编码;窄符号 U+00A5 在 GBK 中不存在会崩溃
92
+ print(f" 余额: ¥{usage['balance_yuan']:.2f}")
93
+ sub = usage.get("subscription", {})
94
+ print(f" 订阅: {sub.get('status', 'none')}")
95
+
96
+
66
97
  def main() -> int:
98
+ _configure_stdout()
67
99
  parser = argparse.ArgumentParser(
68
100
  prog="quantdb",
69
101
  description="QuantDB 量化数据平台官方 Python SDK",
70
- epilog="演示脚本: python -m quantdb_sdk.examples.demo_01 .. demo_06;目录规范: quantdb layout",
102
+ epilog="目录规范: quantdb layout;数据集目录: quantdb datasets",
71
103
  )
72
104
  parser.add_argument(
73
105
  "--version",
@@ -115,6 +147,14 @@ def main() -> int:
115
147
  download_parser.add_argument("--save-dir", default=None, help="本地数据根目录")
116
148
  download_parser.add_argument("--json", action="store_true", help="输出 JSON 保存路径和文件大小")
117
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
+
118
158
  args = parser.parse_args()
119
159
 
120
160
  if not args.command:
@@ -158,16 +198,7 @@ def main() -> int:
158
198
 
159
199
  if args.command == "usage":
160
200
  usage = client.get_usage()
161
- print(f"账户用量:")
162
- print(f" 套餐: 已用 {usage['plan_used_gb']:.2f} GB / {usage['plan_total_gb']:.2f} GB"
163
- f"(剩余 {usage['plan_remaining_gb']:.2f} GB)")
164
- print(f" 流量包: 已用 {usage['package_used_gb']:.2f} GB / {usage['package_total_gb']:.2f} GB"
165
- f"(剩余 {usage['package_remaining_gb']:.2f} GB)")
166
- print(f" 合计: 已用 {usage['used_gb']:.2f} GB / {usage['total_gb']:.2f} GB"
167
- f"(剩余 {usage['remaining_gb']:.2f} GB)")
168
- print(f" 余额: ¥{usage['balance_yuan']:.2f}")
169
- sub = usage.get("subscription", {})
170
- print(f" 订阅: {sub.get('status', 'none')}")
201
+ print_usage(usage)
171
202
  return 0
172
203
 
173
204
  if args.command == "sync":
@@ -179,6 +210,32 @@ def main() -> int:
179
210
  print(f" Release: {result['release_id']}")
180
211
  return 0
181
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
+
182
239
  except Exception as e:
183
240
  print(f"[ERROR] {e}", file=sys.stderr)
184
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.7"
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.7"
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,