Metadata-Version: 2.4
Name: gsdatabase
Version: 0.4.0
Summary: GS 因子数据 SDK：tushare 风格接口，token 鉴权，DataFrame 返回
License: Proprietary
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: requests>=2.28
Requires-Dist: urllib3>=1.26.18
Requires-Dist: pandas>=1.5
Requires-Dist: pyarrow>=12.0
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"

# gsdatabase

GS 因子数据 SDK。tushare 风格接口：token 鉴权，DataFrame 返回，对接门户 `/api/v1` 开放接口。

## 安装

```bash
pip install gsdatabase
```

## 快速开始

```python
import gsdatabase as gs

gs.set_token("gsk_...")        # 门户个人中心生成；一次设置写入 ~/.gsdatabase/token
api = gs.pro_api()

# 我能看哪些因子 / 组合
factors = api.factor_list()
portfolios = api.portfolio_list()

# 回答"最近一年沪深300里表现好的因子有哪些"
top = (api.factor_performance(universe="沪深300", start_date="20250723")
          .sort_values("hedge_annualized_return", ascending=False)
          .head(20))

# 单因子明细：分组回测净值 / 日频 IC
nav = api.factor_backtest_daily(schema_name="order_time_gather_rolling",
                                factor_name="order_time_gather_rolling_001",
                                start_date="20250101")
ic = api.factor_ic_daily(schema_name="order_time_gather_rolling",
                         factor_name="order_time_gather_rolling_001")

# 原始因子值（长表 date/symbol/value，大表建议限定日期区间）
df = api.alpha_factor(schema_name="order_time_gather_rolling",
                      factor_name="order_time_gather_rolling_001",
                      start_date="20250101", end_date="20250630")

# 整个 schema 的因子值宽表：(date, symbol) 索引，列 = 因子名
# 一年 × 30 因子约数千万行，分钟级耗时；单因子失败自动跳过并 warning
wide = api.alpha_factor_all("order_time_gather_rolling",
                            start_date="20250101", end_date="20251231")

# 传统因子：Barra / 基本面 / 量价（宽表：一行一股一日，一列一因子）
# 先查清单挑因子；factors 缺省即取全部列，列越多单页行数越少，务必限定区间
meta = api.factor_meta(factor_table="alpha_financial_descriptors")
print(meta[["factor_name", "direction", "exp"]].head())

# 第一个参数 factor_type 决定取哪一类，也决定返回哪些列
expo = api.legacy_factor("barra", start_date="20250101", end_date="20250331",
                         factors=["size", "beta", "momentum"])
fin = api.legacy_factor("financial", start_date="20250101", end_date="20250331",
                        factors=["bp", "ep", "roe"])
ret = api.barra_factor_return(start_date="20250101")   # 每日一行，全表不足万行

# 数据目录与用量/配额（目录内容随身份变化：不带 token 只有公开数据集）
print(api.catalog())           # 可见数据集的参数与字段说明
usage = api.usage(days=30)     # 近 30 天调用统计
print(usage.attrs["quota"])    # {"daily_row_quota": ..., "used_today": ..., "remaining_today": ...}
```

## 数据集一览

| 方法 | 必填参数 | 说明 |
| --- | --- | --- |
| `factor_list()` | — | 可见特色因子清单（含简述、起止日、方向 side） |
| `fc_list()` | — | 可见特色合成因子清单 |
| `portfolio_list()` | — | 可见组合清单（含净值数据起止日） |
| `factor_performance(universe, start_date, end_date)` | — | 因子绩效汇总（对冲收益/IC/ICIR） |
| `portfolio_performance(universe, start_date, end_date)` | — | 组合绩效汇总 |
| `factor_backtest_daily(schema_name, factor_name, ...)` | schema_name, factor_name | 分组回测日频净值 |
| `factor_ic_daily(schema_name, factor_name, ...)` | schema_name, factor_name | 日频 IC/RankIC |
| `portfolio_backtest_daily(portfolio_name, ...)` | portfolio_name | 组合日频净值 |
| `alpha_factor(schema_name, factor_name, ...)` | schema_name, factor_name | 原始因子值长表（大表推荐 arrow） |
| `alpha_factor_all(schema_name, ...)` | schema_name | 整 schema 因子值宽表（并发拉取） |
| `factor_meta(factor_table)` | — | 传统因子清单：含义、方向、构造方法、所属源表数据起止日 |
| `legacy_factor(factor_type, start_date, end_date, symbols, factors)` | factor_type | 传统因子值宽表：`financial` 基本面 155 个 / `trading` 量价 79 个（均 2025 年起）/ `barra` Barra 暴露 41 列（2006 年末起） |
| `barra_factor_return(start_date, end_date, factors)` | — | 日度纯因子收益率（截面回归，无个股维度） |

后三个是宽表数据集：`factors` 参数点名因子列（缺省取全部），维度列 `date`/`symbol`
自动前置。服务端按投影后的列数反推单页行数，取全部 155 列时单页约 1.2 万行，
所以点名因子比取全表快得多。`alpha_*_descriptor` 目前只覆盖 2025 年以来的日度值，
更早区间请联系平台方获取。

日期参数同时接受 `YYYYMMDD` 与 `YYYY-MM-DD`，输出统一 `YYYYMMDD` 字符串。
方法名与服务端 dataset id 一一对应；服务端新增数据集时可直接
`api.query("new_dataset", **params)` 或 `api.new_dataset(**params)` 使用，无需升级 SDK。

## 服务地址（base_url）

三级优先级，从高到低：

1. 显式入参：`gs.pro_api(base_url="https://...")`
2. 环境变量：`GSDB_BASE_URL`
3. 默认值：`https://gsdatabase.net:21985`（生产公网入口）

```bash
# 本地开发直连开发后端（注意直连 FastAPI 的 8000，不走 Vite）
export GSDB_BASE_URL=http://127.0.0.1:8000
python your_script.py
```

## 文件下载（0.4.0）

部分数据集交付的是**按交易日打包的文件**（L2 逐笔压缩包单包可达数 GB；1 分钟量价
特征每日一个 parquet），不走按行取数，需要单独授权。`download()` 对任何文件型数据集
通用，与 `query()` 同形：给数据集 id 和交易日，SDK 自己查清单、换限时链接、断点续传、
MD5 校验，一次调用走完：

```python
# L2 逐笔：该交易日全部文件（正常 5 个）；trade_date 必填，不限日期会拉保留期内约 70 GB
done = api.download("l2_raw", trade_date="20260911", dest="./raw", workers=2)
print(done[["file_id", "path", "status", "downloaded_bytes"]])

# 1 分钟量价特征：同一写法换个数据集 id
api.download("minute_feature", trade_date="20260911", dest="./raw")

# 只要其中一种：dataset_code 等清单参数原样透传
api.download("l2_raw", trade_date="20260911", dataset_code="6_36", dest="./raw")

# 想先看清单再挑：清单是个普通数据集，file_id 列可直接喂给 file_ids=
files = api.query("l2_raw", trade_date="20260911")
print(files[["file_id", "file_name", "size_bytes", "md5"]])
api.download("l2_raw", file_ids=files["file_id"].tolist(), dest="./raw")
```

- 哪些数据集可下载：`api.catalog()` 里 `delivery` 为 `file` 的那些。对非文件型数据集
  调 `download()` 会抛 `ParamError`（清单缺少 file_id / file_name / size_bytes / md5 列）。

- 清单里没有该日文件（当天尚未上传完）时返回空表并 warning，不报错。

- 落盘到 `dest/<交易日>/<文件名>`；下载途中写 `<文件名>.part`，校验通过才改名成正式文件。
- **MD5 必校**：比对清单里的原厂校验值（数据供应商随包给出的，不是我们转存时重算的）。
  不符即报错并删除本地文件——重跑会完整重下；反复失败请联系平台方，不要拿它当数据用。
- **断点续传**：中途 Ctrl-C、断网、关机之后重新调用同一行代码即可，从 `.part` 的实际
  字节数接着下。判据是本地长度与远端总长度两头对齐，不是「文件在不在」。
- **链接 1 小时有效，且是不透明字符串**：不要保存、不要转发、更不要自己改 URL 里的
  `expires`（签名把它与文件路径绑死了，改完必然 403）。过期时 SDK 自动回后端重新申请。
- 已下载并校验通过的文件重跑会跳过（`status` 为 `skipped`），可以放心用定时任务拉全量。
- `workers` 默认 2、上限 4：瓶颈通常在你这端的带宽，并发只是把同一条管子切成几份，
  还会让每个文件都完成得更晚。
- 解压后的体积远大于压缩包（约 5 倍），批量拉取前先看清单里的 `size_bytes` 备好磁盘。

## 分页与配额

- 服务端按 cursor 分页：json 单页上限 1 万行，arrow 单页上限 10 万行；SDK 自动续拉
  全部分页并 concat（`paginate=False` 关闭，只取一页），arrow 翻页默认走预取流水线
  （见下文）
- 传输层默认 arrow 二进制（更快更省带宽、dtype 保真），`gs.pro_api(fmt="json")` 可切换
- 限频（40201）SDK 按服务端返回的 `retry_after` 自动退避重试；当日行数配额用尽抛
  `QuotaError`，配额与用量见 `api.usage()`

## 传输压缩（0.3.0 起默认开启）

`format=arrow` 的响应默认用 **zstd** 压缩传输，代价约 +1.8ms/页 CPU。公网取数的
瓶颈是链路带宽，所以这一项的收益最直接——2026-08-05 公网实测（单流拉一年因子值，
118 万行 / 12 页）：

| | 传输量 | 墙钟 |
| --- | --- | --- |
| 0.2.x 现状（不压缩） | 34.2 MB | 14.0 s（两轮均值） |
| 0.3.0 默认（zstd） | 11.3 MB | 4.8 s |
| | **3.03×** | **2.95×** |

全量拉取（1039 万行 / 104 页）压缩比一致：301.5 MB → 99.2 MB（3.04×）。

```python
api = gs.pro_api()                      # 默认 zstd
api = gs.pro_api(codec="identity")      # 关闭压缩（0.2.x 的线上形态）
api = gs.pro_api(codec="lz4")           # 压缩比更低、解压更省 CPU
```

解压对使用方透明，返回的 DataFrame 与不压缩时逐值一致。三层自动降级：

- 本机 pyarrow 未编入该压缩 → 构造客户端时就退回不压缩；
- 服务端不认识 `codec` 参数（老版本返回 40101）→ 按同一 cursor 去掉参数重下，
  并在本会话内不再发送该参数；
- 解压失败或页被截断 → 退回不压缩重下同一 cursor（见下节的行数校验）。

## 翻页预取（0.3.0 起默认开启）

`format=arrow` 自动翻页时，SDK 在收到本页**响应头**的那一刻就在第二条连接上发出
下一页的请求，本页 body 的下载与服务端准备下一页因此重叠。翻页游标在响应头
`X-Next-Cursor` 里，不必等 body 收完才知道下一页是谁，这一步才成立。

- 页序与结果与串行逐页拉取**逐行一致**，接口、参数、返回类型都不变，使用方无感；
- 预取深度固定为 1（在途最多一个未消费的响应），客户端内存增量不超过一页；
- 两条连接都是 keep-alive 复用，翻多少页都不会反复握手；
- `paginate=False` 不预取（免得白拉一页还计进当日行数配额），`fmt="json"` 保持串行
  （json 的 `next_cursor` 在 body 里，拿不到就无从提前）；
- 页校验失败、限频退避、`codec` 降级等既有行为一律不变：本页要重下时，已预取的
  下一页会被废弃并重新预取。

**收益随每页传输时间反向变化**，值得说清楚：预取能重叠掉的是「服务端生成下一页 +
一个往返」（约 230ms），拿来盖住的是本页 body 的下载与解析。每页传输越慢，这
230ms 占总时间的比重越小，收益就越薄：

| 可用带宽 | 每页传输 | 实测加速 |
| --- | --- | --- |
| 20 MB/s | 120 ms | **1.48×** |
| 5 MB/s | 480 ms | 1.25× |
| 2 MB/s | 1200 ms | **1.11×** |

（本地注入带宽的敏感曲线。回环链路上另有 2.1× 量级的观测，但那主要来自与
`to_pandas` 解析重叠——把解析耗时设为 0 后只剩 1.02×，不能算作网络流水线的收益。）

2026-08-05 的公网实测正落在 2 MB/s 那一档：配对差 −0.19s ± 0.46s，也就是预期中的
约 1.11× 被链路自身的抖动盖住了（同配置两轮墙钟能差一倍），测不出来。预取机制本身
工作正常（命中率 100%，日志侧可见并发连接），只是这条链路上没多少可省的东西。

所以：公网上真正起作用的是上一节的压缩；预取的价值在带宽更充裕的场景（内网直连、
同机房、出口扩容之后），且受限链路上实测不会拖慢。

## 数据完整性（0.2.1）

`format=arrow` 的每一页在解析后都会与响应头 `X-Row-Count` 核对行数，不一致（或该头
本身损坏）即视为传输被截断，按**同一 cursor** 重下，重试耗尽才抛 `GsdbError`（code `-1`，
消息含 dataset / cursor / 尝试次数）。**不会静默少行。**

- **urllib3 2.x** 会校验 `Content-Length`，截断在传输层就报错——这是第二道防线；
- **urllib3 1.x** 没有这层（`resp.content` 会静默变短），此时完全由上述行数校验兜底，
  功能正常，只是多一次重传；进程内首次构造客户端时会给一条升级提示。

依赖因此声明为 `urllib3>=1.26.18` 而非强制 2.x：客户环境常被其他库（如 botocore）
钉在 1.x，强制升级会直接装不上，而正确性并不依赖它。

## 错误码

所有异常继承 `gsdatabase.GsdbError`（`e.code` / `e.msg`）：

| code | HTTP | 异常 | 含义 |
| --- | --- | --- | --- |
| 40001 | 401 | `AuthError` | token 缺失、无效或已吊销 |
| 40002 | 401 | `AuthError` | token 过期 |
| 40004 | 403 | `PermissionError_` | 无数据权限 |
| 40101 | 422 | `ParamError` | 参数错误 |
| 40102 | 404 | `ParamError` | dataset 不存在 |
| 40201 | 429 | `RateLimitError` | 频率超限（自动退避重试） |
| 40202 | 429 | `QuotaError` | 当日行数配额用尽 |
| 50000 | 500 | `ServerError` | 服务端错误（自动重试） |
| 50001 | 504 | `ServerError` | 查询超时（自动重试） |
| 50003 | 503 | `ServerError` | 依赖暂时不可用，如数据库抖动（按服务端 `retry_after` 自动退避重试） |
| -1 | — | `GsdbError` | 网络错误 / SDK 本地校验失败 |

## 发布（维护者）

- 发新版：同步提升 `pyproject.toml` 的 `version` 与 `gsdatabase/_version.py` 的
  `_FALLBACK` 并提交，再执行：

  ```bash
  scripts/release.sh          # 校验干净 → 打 tag v<version> → 构建 wheel 到 dist/
  ```

- 发给客户的永远是 tag 构建的 wheel，不是工作区代码。
- 发布前先跑单测与手动冒烟（冒烟默认跳过，需显式测试 token）：

  ```bash
  python -m pytest -q
  GSDB_TEST_TOKEN=gsk_xxx python scripts/smoke.py
  ```
