ffdata 0.2.0__py3-none-any.whl

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.
ffdata/local/store.py ADDED
@@ -0,0 +1,146 @@
1
+ """客户端本地 Parquet 数据集发现与受控过滤。
2
+
3
+ 本地查询仅支持服务端明确发布为数据集的 API,不允许执行任意 SQL 或读取 manifest
4
+ 之外的文件。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import date, datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import pandas as pd
14
+ import polars as pl
15
+ from ffdata_common.errors import DatasetError
16
+ from ffdata_common.schemas import DatasetManifest
17
+
18
+
19
+ def _date(value: Any) -> date | None:
20
+ if value is None:
21
+ return None
22
+ if isinstance(value, datetime):
23
+ return value.date()
24
+ if isinstance(value, date):
25
+ return value
26
+ if isinstance(value, str):
27
+ for fmt in ("%Y%m%d", "%Y-%m-%d"):
28
+ try:
29
+ return datetime.strptime(value, fmt).date()
30
+ except ValueError:
31
+ pass
32
+ raise ValueError(f"invalid date value: {value!r}")
33
+
34
+
35
+ def _codes(value: Any) -> tuple[str, ...] | None:
36
+ if value is None:
37
+ return None
38
+ values = [value] if isinstance(value, str) else list(value)
39
+ result: list[str] = []
40
+ seen: set[str] = set()
41
+ for item in values:
42
+ for piece in str(item).split(","):
43
+ code = piece.strip()
44
+ if not code:
45
+ raise ValueError("ts_code must contain non-empty strings")
46
+ if code not in seen:
47
+ seen.add(code)
48
+ result.append(code)
49
+ return tuple(result)
50
+
51
+
52
+ class LocalStore:
53
+ """读取本地 manifest,并使用 Polars 懒扫描执行列裁剪和条件下推。"""
54
+
55
+ def __init__(self, root: Path):
56
+ self.root = root.expanduser().resolve()
57
+
58
+ def manifest(self, dataset: str) -> DatasetManifest | None:
59
+ path = self.root / dataset / "manifest.json"
60
+ if not path.is_file():
61
+ return None
62
+ try:
63
+ return DatasetManifest.model_validate_json(path.read_text(encoding="utf-8"))
64
+ except Exception as exc:
65
+ raise DatasetError(f"invalid local manifest for {dataset}: {exc}") from exc
66
+
67
+ def covers(self, dataset: str, params: dict[str, Any]) -> bool:
68
+ """判断本地数据集是否完整覆盖本次查询所需的日期范围。"""
69
+ manifest = self.manifest(dataset)
70
+ if manifest is None or not manifest.files:
71
+ return False
72
+ requested_start = _date(params.get("trade_date") or params.get("start_date"))
73
+ requested_end = _date(params.get("trade_date") or params.get("end_date"))
74
+ minimums = [_date(item.min_trade_date) for item in manifest.files if item.min_trade_date]
75
+ maximums = [_date(item.max_trade_date) for item in manifest.files if item.max_trade_date]
76
+ if requested_start is not None and minimums and requested_start < min(minimums):
77
+ return False
78
+ if requested_end is not None and maximums and requested_end > max(maximums):
79
+ return False
80
+ has_requested_range = requested_start is not None or requested_end is not None
81
+ if has_requested_range and not (minimums and maximums):
82
+ return False
83
+ return all((self.root / dataset / item.path).is_file() for item in manifest.files)
84
+
85
+ def query(
86
+ self,
87
+ dataset: str,
88
+ params: dict[str, Any],
89
+ *,
90
+ fields: list[str] | None = None,
91
+ ) -> pd.DataFrame:
92
+ """在 manifest 声明的 Parquet 分片上执行本地查询。"""
93
+ manifest = self.manifest(dataset)
94
+ if manifest is None:
95
+ raise DatasetError(
96
+ f"local dataset {dataset!r} is not installed; run ffdata dataset sync {dataset}"
97
+ )
98
+ paths = [str(self.root / dataset / item.path) for item in manifest.files]
99
+ missing = [path for path in paths if not Path(path).is_file()]
100
+ if missing:
101
+ raise DatasetError(f"local dataset has {len(missing)} missing files")
102
+ frame = pl.scan_parquet(paths)
103
+ schema_names = frame.collect_schema().names()
104
+ selected = fields or schema_names
105
+ unknown = set(selected).difference(schema_names)
106
+ if unknown:
107
+ raise ValueError(f"unknown fields: {sorted(unknown)}")
108
+
109
+ codes = _codes(params.get("ts_code"))
110
+ if codes is not None:
111
+ pure_codes = tuple(code.split(".", 1)[0] for code in codes)
112
+ frame = frame.filter(
113
+ pl.col("ts_code").str.replace(r"\.[A-Za-z]+$", "").is_in(pure_codes)
114
+ )
115
+ trade_date = _date(params.get("trade_date"))
116
+ start_date = _date(params.get("start_date"))
117
+ end_date = _date(params.get("end_date"))
118
+ if trade_date is not None:
119
+ frame = frame.filter(pl.col("trade_date") == trade_date)
120
+ if start_date is not None:
121
+ frame = frame.filter(pl.col("trade_date") >= start_date)
122
+ if end_date is not None:
123
+ frame = frame.filter(pl.col("trade_date") <= end_date)
124
+
125
+ sort_columns = [name for name in ("trade_date", "ts_code") if name in selected]
126
+ if sort_columns:
127
+ descending = [name == "trade_date" for name in sort_columns]
128
+ frame = frame.select(selected).sort(sort_columns, descending=descending)
129
+ else:
130
+ frame = frame.select(selected)
131
+ result = frame.collect().to_pandas()
132
+ if "trade_date" in result.columns:
133
+ result["trade_date"] = result["trade_date"].map(
134
+ lambda value: pd.Timestamp(value).strftime("%Y%m%d") if not pd.isna(value) else None
135
+ )
136
+ result.attrs.update(
137
+ {
138
+ "ffdata_source": "local",
139
+ "ffdata_version": manifest.version,
140
+ "ffdata_last_updated": manifest.published_at.isoformat(),
141
+ }
142
+ )
143
+ return result
144
+
145
+
146
+ __all__ = ["LocalStore"]
@@ -0,0 +1,5 @@
1
+ """客户端远程传输实现。"""
2
+
3
+ from .http import HttpTransport
4
+
5
+ __all__ = ["HttpTransport"]
@@ -0,0 +1,175 @@
1
+ """客户端 HTTP 传输与协议解码。
2
+
3
+ 本模块只处理网络协议、鉴权头、Arrow/JSON 解码和文件下载,不包含任何业务 SQL。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import httpx
12
+ import pandas as pd
13
+ from ffdata_common.codecs import arrow_stream_to_dataframe
14
+ from ffdata_common.errors import (
15
+ AuthenticationError,
16
+ AuthorizationError,
17
+ QueryError,
18
+ )
19
+ from ffdata_common.protocol import ARROW_STREAM_MEDIA_TYPE
20
+ from ffdata_common.schemas import DatasetManifest, DownloadPlan
21
+
22
+
23
+ class HttpTransport:
24
+ """封装 ffdata HTTP 协议的同步传输客户端。"""
25
+
26
+ def __init__(
27
+ self,
28
+ *,
29
+ base_url: str,
30
+ token: str | None,
31
+ timeout: float = 30.0,
32
+ client: httpx.Client | None = None,
33
+ ):
34
+ headers = {"Authorization": f"Bearer {token}"} if token else {}
35
+ self._client = client or httpx.Client(
36
+ base_url=base_url.rstrip("/"),
37
+ headers=headers,
38
+ timeout=timeout,
39
+ # 数据服务是本机或内网地址,默认不读取环境变量与 macOS 系统代理:
40
+ # 否则系统代理会把 127.0.0.1 请求转发出去,返回难以排查的 502。
41
+ # 确需经过代理时,请显式传入已配置代理的 http_client。
42
+ trust_env=False,
43
+ )
44
+ self._owns_client = client is None
45
+ if client is not None and headers:
46
+ self._client.headers.update(headers)
47
+
48
+ def close(self) -> None:
49
+ if self._owns_client:
50
+ self._client.close()
51
+
52
+ def query(
53
+ self,
54
+ api_name: str,
55
+ params: dict[str, Any],
56
+ *,
57
+ fields: list[str] | None = None,
58
+ response_format: str = "arrow",
59
+ ) -> pd.DataFrame:
60
+ """调用远程查询接口,并将 JSON 或 Arrow 响应还原为 DataFrame。"""
61
+ headers = (
62
+ {"Accept": ARROW_STREAM_MEDIA_TYPE}
63
+ if response_format == "arrow"
64
+ else {"Accept": "application/json"}
65
+ )
66
+ response = self._client.post(
67
+ "/v1/query",
68
+ json={
69
+ "api_name": api_name,
70
+ "params": params,
71
+ "fields": fields,
72
+ "format": response_format,
73
+ },
74
+ headers=headers,
75
+ )
76
+ self._raise_for_status(response)
77
+ if ARROW_STREAM_MEDIA_TYPE in response.headers.get("content-type", ""):
78
+ frame = arrow_stream_to_dataframe(response.content)
79
+ else:
80
+ payload = response.json()
81
+ frame = pd.DataFrame(payload["data"], columns=payload["columns"])
82
+ frame.attrs.update(payload.get("metadata", {}))
83
+ frame.attrs.update(
84
+ {
85
+ "ffdata_source": "remote",
86
+ "ffdata_request_id": response.headers.get("X-Request-ID"),
87
+ "ffdata_cache": response.headers.get("X-Cache"),
88
+ }
89
+ )
90
+ return frame
91
+
92
+ def api_catalog(self) -> list[dict[str, Any]]:
93
+ response = self._client.get("/v1/apis")
94
+ self._raise_for_status(response)
95
+ return response.json()["apis"]
96
+
97
+ def create_export(
98
+ self,
99
+ api_name: str,
100
+ params: dict[str, Any],
101
+ *,
102
+ fields: list[str] | None = None,
103
+ ) -> dict[str, Any]:
104
+ response = self._client.post(
105
+ "/v1/exports",
106
+ json={"api_name": api_name, "params": params, "fields": fields},
107
+ )
108
+ self._raise_for_status(response)
109
+ return response.json()
110
+
111
+ def export_status(self, job_id: str) -> dict[str, Any]:
112
+ response = self._client.get(f"/v1/exports/{job_id}")
113
+ self._raise_for_status(response)
114
+ return response.json()
115
+
116
+ def download_export(self, job_id: str, destination: Path) -> Path:
117
+ response = self._client.get(f"/v1/exports/{job_id}/download")
118
+ self._raise_for_status(response)
119
+ temporary = destination.with_suffix(f"{destination.suffix}.partial")
120
+ temporary.parent.mkdir(parents=True, exist_ok=True)
121
+ temporary.write_bytes(response.content)
122
+ temporary.replace(destination)
123
+ return destination
124
+
125
+ def dataset_names(self) -> list[str]:
126
+ response = self._client.get("/v1/datasets")
127
+ self._raise_for_status(response)
128
+ return response.json()["datasets"]
129
+
130
+ def dataset_manifest(self, name: str) -> DatasetManifest:
131
+ response = self._client.get(f"/v1/datasets/{name}/manifest")
132
+ self._raise_for_status(response)
133
+ return DatasetManifest.model_validate(response.json())
134
+
135
+ def download_plan(
136
+ self,
137
+ name: str,
138
+ paths: list[str] | None,
139
+ ) -> DownloadPlan:
140
+ response = self._client.post(
141
+ f"/v1/datasets/{name}/download-plan",
142
+ json={"paths": paths},
143
+ )
144
+ self._raise_for_status(response)
145
+ return DownloadPlan.model_validate(response.json())
146
+
147
+ def download(self, url: str, destination: Path, *, offset: int = 0) -> None:
148
+ """按流式分块下载文件;存在偏移量时尝试使用 HTTP Range 续传。"""
149
+ headers = {"Range": f"bytes={offset}-"} if offset else {}
150
+ mode = "ab" if offset else "wb"
151
+ with self._client.stream("GET", url, headers=headers) as response:
152
+ self._raise_for_status(response)
153
+ if offset and response.status_code != 206:
154
+ mode = "wb"
155
+ with destination.open(mode) as output:
156
+ for chunk in response.iter_bytes(chunk_size=1024 * 1024):
157
+ output.write(chunk)
158
+
159
+ @staticmethod
160
+ def _raise_for_status(response: httpx.Response) -> None:
161
+ if response.is_success:
162
+ return
163
+ try:
164
+ detail = response.json().get("detail", response.text)
165
+ except ValueError:
166
+ detail = response.text
167
+ message = str(detail.get("message", detail)) if isinstance(detail, dict) else str(detail)
168
+ if response.status_code == 401:
169
+ raise AuthenticationError(message)
170
+ if response.status_code == 403:
171
+ raise AuthorizationError(message)
172
+ raise QueryError(f"HTTP {response.status_code}: {message}")
173
+
174
+
175
+ __all__ = ["HttpTransport"]
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env bash
2
+ # 配置 ffdata 客户端所需的服务地址与研究员 Token。
3
+ # 请使用 source 执行本脚本,使环境变量立即对当前终端生效:
4
+ # source client/scripts/configure_ffdata.sh
5
+
6
+ if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
7
+ printf '%s\n' "请使用 source 执行本脚本:source client/scripts/configure_ffdata.sh" >&2
8
+ exit 1
9
+ fi
10
+
11
+ printf '%s' 'ffdata 服务地址 [http://127.0.0.1:8000]: '
12
+ IFS= read -r FFDATA_SETUP_BASE_URL
13
+ FFDATA_SETUP_BASE_URL=${FFDATA_SETUP_BASE_URL:-http://127.0.0.1:8000}
14
+
15
+ case "$FFDATA_SETUP_BASE_URL" in
16
+ http://*|https://*) ;;
17
+ *)
18
+ printf '%s\n' '服务地址必须以 http:// 或 https:// 开头。' >&2
19
+ return 1
20
+ ;;
21
+ esac
22
+
23
+ printf '%s' '研究员 Token(输入不回显): '
24
+ IFS= read -r -s FFDATA_SETUP_TOKEN
25
+ printf '\n'
26
+ if [[ -z "$FFDATA_SETUP_TOKEN" ]]; then
27
+ printf '%s\n' 'Token 不能为空。' >&2
28
+ return 1
29
+ fi
30
+
31
+ export FFDATA_BASE_URL="$FFDATA_SETUP_BASE_URL"
32
+ export FFDATA_TOKEN="$FFDATA_SETUP_TOKEN"
33
+
34
+ FFDATA_SETUP_PROFILE="$HOME/.bash_profile"
35
+ FFDATA_SETUP_TEMP_FILE=$(mktemp "$HOME/.bash_profile.ffdata.XXXXXX")
36
+
37
+ if [[ -f "$FFDATA_SETUP_PROFILE" ]]; then
38
+ awk '
39
+ /^# >>> ffdata environment >>>$/ { in_ffdata_block = 1; next }
40
+ /^# <<< ffdata environment <<</ { in_ffdata_block = 0; next }
41
+ !in_ffdata_block { print }
42
+ ' "$FFDATA_SETUP_PROFILE" > "$FFDATA_SETUP_TEMP_FILE"
43
+ fi
44
+
45
+ {
46
+ printf '\n# >>> ffdata environment >>>\n'
47
+ printf 'export FFDATA_BASE_URL=%q\n' "$FFDATA_BASE_URL"
48
+ printf 'export FFDATA_TOKEN=%q\n' "$FFDATA_TOKEN"
49
+ printf '# <<< ffdata environment <<<\n'
50
+ } >> "$FFDATA_SETUP_TEMP_FILE"
51
+
52
+ mv "$FFDATA_SETUP_TEMP_FILE" "$FFDATA_SETUP_PROFILE"
53
+ chmod 600 "$FFDATA_SETUP_PROFILE"
54
+
55
+ printf '%s\n' '当前终端已设置 FFDATA_BASE_URL 与 FFDATA_TOKEN。'
56
+ printf '%s\n' "已写入 $FFDATA_SETUP_PROFILE(仅当前用户可读)。"
57
+ printf '%s\n' '新的 Bash 终端会自动加载;使用其他 shell 时可执行:source ~/.bash_profile'
58
+
59
+ unset FFDATA_SETUP_BASE_URL FFDATA_SETUP_TOKEN FFDATA_SETUP_PROFILE FFDATA_SETUP_TEMP_FILE
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: ffdata
3
+ Version: 0.2.0
4
+ Summary: ffdata lightweight Python client for governed financial-data APIs
5
+ Requires-Python: <3.13,>=3.12
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: httpx>=0.28.0
8
+ Requires-Dist: pandas>=3.0.5
9
+ Requires-Dist: polars>=1.43.2
10
+ Requires-Dist: pyarrow>=21.0.0
11
+ Requires-Dist: pydantic>=2.11.0
12
+
13
+ # ffdata
14
+
15
+ `ffdata` 是面向量化研究的轻量 Python 客户端。它只通过 HTTP 调用已部署的
16
+ ffdata 数据服务,不包含服务端、JYDB SQL、数据库配置或后台管理实现。
17
+
18
+ 完整的客户端职责说明见 [客户端模块说明](../docs/模块说明/客户端.md);安装、构建和升级见
19
+ [客户端打包与交付](../docs/部署与运维/客户端打包与交付.md);每个对外 API 的可运行案例见
20
+ [使用案例](examples/README.md)。
21
+
22
+ ## 配置服务地址与 Token
23
+
24
+ 安装客户端 wheel 后运行:
25
+
26
+ ```bash
27
+ source "$(dirname "$(command -v ffdata)")/configure_ffdata.sh"
28
+ ```
29
+
30
+ 脚本会交互式设置 `FFDATA_BASE_URL` 与 `FFDATA_TOKEN`,Token 输入不回显,并将两者写入
31
+ `~/.bash_profile`(仅当前用户可读)。因此新的 Bash 终端及其他项目都能直接读取配置;若当前使用
32
+ 的是 zsh 等其他 shell,可执行 `source ~/.bash_profile` 加载。
@@ -0,0 +1,22 @@
1
+ ffdata/__init__.py,sha256=rLbnK91_ZF9T9rBA9-gT7St7EJ0zhTOBZveMFki0ZkQ,4942
2
+ ffdata/cli.py,sha256=vQ1R8g9dqgD30wy8qG7l108UF3r355wqCdLdipK31kQ,1448
3
+ ffdata/client.py,sha256=uNypPELp0qLIBtENRCM2YZ1oNdG5pXujww7l1LNF_Ns,19493
4
+ ffdata/datasets/__init__.py,sha256=t1NkXLsuyw7b5UTWtbVAWBSRROFlHGNTDLadf0ZEPxQ,107
5
+ ffdata/datasets/manager.py,sha256=FH0ouWyGhTua560Ouqg1fAcSaLvxVVcPD4g-gpSrxLc,5197
6
+ ffdata/local/__init__.py,sha256=NKNUhM4HVkuiTM9b_hSxTqhBEGdxZ_0A6G3h-OAzD7s,100
7
+ ffdata/local/store.py,sha256=b7aNport6l3cSQZ9y4nDoJgL2TUOA-gV9HG8Ag3i6GQ,5778
8
+ ffdata/transport/__init__.py,sha256=uooj8S_VGUh7jLgHI8GNNzt6bUOjfl5Xv9_DGA4mMN0,99
9
+ ffdata/transport/http.py,sha256=B5MqYb8mMvQxJ2-vZPy0fEUD7xmnnvvpaL5Xqj250qw,6248
10
+ ffdata-0.2.0.data/scripts/configure_ffdata.sh,sha256=vMMDc1_YudIdpydC6uGx4dSAWxphJAHJFxw7OWTU5EM,2053
11
+ ffdata_common/__init__.py,sha256=jD-XcZhKLg8hIt4lGBf3s-ZFZi6xplYWs8BLsS0DwDc,190
12
+ ffdata_common/errors.py,sha256=yG4OVtMkvAC3kVobzcm96v4K7GUP0_X6yuyjsMQ840E,649
13
+ ffdata_common/protocol.py,sha256=kqM528ONKX8EDOA0kACt5_k822OO3StqDH26A2dmuBc,466
14
+ ffdata_common/codecs/__init__.py,sha256=Kimr8uanvW0bpb39t9sgKD-chISBmKiSEBKM0hKL42s,189
15
+ ffdata_common/codecs/arrow.py,sha256=cDD0rKhkHhr8Z3X8UlzMu7o0j_7XiX51gxozH4MFHaY,850
16
+ ffdata_common/schemas/__init__.py,sha256=6qLqx4wIFPAiitdwAAnKcl6cCRFl-rVOiTYmJ4P_m4M,222
17
+ ffdata_common/schemas/manifest.py,sha256=3YYmiomaLbt0xQ31hB2pDOyNrZ3BJZhkmcxqdCxe-TA,1248
18
+ ffdata-0.2.0.dist-info/METADATA,sha256=b3UqbZ445FoXc0LTjK_TIl02qNm0jSiU7AUtbMXN_Ng,1285
19
+ ffdata-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
20
+ ffdata-0.2.0.dist-info/entry_points.txt,sha256=y7Zq6cjvgRMfHG-Zg4ai84Eb5Jh1hLXkiAiyj-oVxKo,43
21
+ ffdata-0.2.0.dist-info/top_level.txt,sha256=S5PVYKegE0abWMnxlOdG3ba2Wh0yjdNCjbYa77wyvoU,21
22
+ ffdata-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ffdata = ffdata.cli:main
@@ -0,0 +1,2 @@
1
+ ffdata
2
+ ffdata_common
@@ -0,0 +1,6 @@
1
+ """客户端、服务端与数据任务共享的协议契约。"""
2
+
3
+ from .errors import FfdataError
4
+ from .protocol import LOCAL_DATASET_APIS
5
+
6
+ __all__ = ["FfdataError", "LOCAL_DATASET_APIS"]
@@ -0,0 +1,5 @@
1
+ """跨进程数据编码与解码工具。"""
2
+
3
+ from .arrow import arrow_stream_to_dataframe, dataframe_to_arrow_stream
4
+
5
+ __all__ = ["arrow_stream_to_dataframe", "dataframe_to_arrow_stream"]
@@ -0,0 +1,26 @@
1
+ """DataFrame 与 Arrow IPC Stream 之间的转换工具。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+
7
+ import pandas as pd
8
+ import pyarrow as pa
9
+
10
+
11
+ def dataframe_to_arrow_stream(frame: pd.DataFrame) -> bytes:
12
+ """将 DataFrame 编码为不包含索引的 Arrow IPC 字节流。"""
13
+ table = pa.Table.from_pandas(frame, preserve_index=False)
14
+ sink = pa.BufferOutputStream()
15
+ with pa.ipc.new_stream(sink, table.schema) as writer:
16
+ writer.write_table(table, max_chunksize=50_000)
17
+ return sink.getvalue().to_pybytes()
18
+
19
+
20
+ def arrow_stream_to_dataframe(content: bytes) -> pd.DataFrame:
21
+ """将 Arrow IPC 字节流完整还原为 pandas DataFrame。"""
22
+ with pa.ipc.open_stream(io.BytesIO(content)) as reader:
23
+ return reader.read_all().to_pandas()
24
+
25
+
26
+ __all__ = ["arrow_stream_to_dataframe", "dataframe_to_arrow_stream"]
@@ -0,0 +1,30 @@
1
+ """客户端和服务端共享的公共异常层次。"""
2
+
3
+
4
+ class FfdataError(Exception):
5
+ """所有 ffdata 公共异常的基类。"""
6
+
7
+
8
+ class AuthenticationError(FfdataError):
9
+ """API Token 缺失或无效。"""
10
+
11
+
12
+ class AuthorizationError(FfdataError):
13
+ """当前用户没有访问目标 API 或数据集的权限。"""
14
+
15
+
16
+ class QueryError(FfdataError):
17
+ """远程服务拒绝查询或查询执行失败。"""
18
+
19
+
20
+ class DatasetError(FfdataError):
21
+ """本地数据集不存在、不完整或已经损坏。"""
22
+
23
+
24
+ __all__ = [
25
+ "AuthenticationError",
26
+ "AuthorizationError",
27
+ "DatasetError",
28
+ "FfdataError",
29
+ "QueryError",
30
+ ]
@@ -0,0 +1,18 @@
1
+ """跨进程共享的媒体类型、协议版本和本地数据集映射常量。"""
2
+
3
+ ARROW_STREAM_MEDIA_TYPE = "application/vnd.apache.arrow.stream"
4
+ PARQUET_MEDIA_TYPE = "application/vnd.apache.parquet"
5
+
6
+ LOCAL_DATASET_APIS = {
7
+ "stock_daily": "stock_daily",
8
+ "stock_adjusting_factor": "stock_adjusting_factor",
9
+ }
10
+
11
+ PROTOCOL_VERSION = "1"
12
+
13
+ __all__ = [
14
+ "ARROW_STREAM_MEDIA_TYPE",
15
+ "LOCAL_DATASET_APIS",
16
+ "PARQUET_MEDIA_TYPE",
17
+ "PROTOCOL_VERSION",
18
+ ]
@@ -0,0 +1,5 @@
1
+ """跨客户端、服务端和任务共享的数据结构。"""
2
+
3
+ from .manifest import DatasetFile, DatasetManifest, DownloadFile, DownloadPlan
4
+
5
+ __all__ = ["DatasetFile", "DatasetManifest", "DownloadFile", "DownloadPlan"]
@@ -0,0 +1,52 @@
1
+ """版本化本地数据集的 manifest 与下载计划契约。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+
10
+ class DatasetFile(BaseModel):
11
+ """manifest 中单个 Parquet 分片的路径、大小、哈希和日期范围。"""
12
+
13
+ model_config = ConfigDict(extra="forbid")
14
+
15
+ path: str
16
+ size: int = Field(ge=0)
17
+ sha256: str = Field(min_length=64, max_length=64)
18
+ min_trade_date: str | None = None
19
+ max_trade_date: str | None = None
20
+
21
+
22
+ class DatasetManifest(BaseModel):
23
+ """描述一个已发布数据集版本及其全部受控文件。"""
24
+
25
+ model_config = ConfigDict(extra="forbid")
26
+
27
+ dataset: str
28
+ version: str
29
+ schema_version: int = 1
30
+ published_at: datetime
31
+ latest_trade_date: str | None = None
32
+ files: list[DatasetFile]
33
+
34
+
35
+ class DownloadFile(BaseModel):
36
+ """下载计划中的文件元数据和临时访问地址。"""
37
+
38
+ path: str
39
+ size: int
40
+ sha256: str
41
+ url: str
42
+
43
+
44
+ class DownloadPlan(BaseModel):
45
+ """服务端针对指定版本生成的文件下载计划。"""
46
+
47
+ dataset: str
48
+ version: str
49
+ files: list[DownloadFile]
50
+
51
+
52
+ __all__ = ["DatasetFile", "DatasetManifest", "DownloadFile", "DownloadPlan"]