Metadata-Version: 2.5
Name: tyzarr
Version: 0.1.2
Summary: 把 xarray.Dataset 存成不可变 zarr v3 快照，支持本地目录与 MinIO，用带 CAS 的 metadata.json 做原子发布
Author: Rex
Keywords: dataset,minio,s3,snapshot,timeseries,xarray,zarr
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Database
Classifier: Topic :: Scientific/Engineering
Requires-Python: >=3.11
Requires-Dist: aiobotocore>=2.0
Requires-Dist: botocore>=1.31
Requires-Dist: certifi>=2024.2.2
Requires-Dist: dask[array]>=2024.1.0
Requires-Dist: minio>=7.2
Requires-Dist: numpy>=1.26
Requires-Dist: pandas>=2.2
Requires-Dist: s3fs>=2024.2.0
Requires-Dist: urllib3>=2.0
Requires-Dist: xarray>=2024.3.0
Requires-Dist: zarr>=3.0
Description-Content-Type: text/markdown

# tyzarr 使用说明（v1）

tyzarr 把 `xarray.Dataset` 存成 zarr v3 快照，放在本地目录或 MinIO 上，并用一个 `metadata.json`
记录"哪一天的数据在哪个快照里"。

- 每次 `write()` 生成 **新的、不可变的**快照文件，不在原文件上修改；
- 写完快照后再发布 `metadata.json`（带 etag 的 CAS）。读者看到的要么是旧版本，要么是新版本，不会读到写了一半的数据；
- 被替换下来的快照留作历史版本（每个日期最多保留 3 个），超出且不再被引用的快照会自动删除。

> v1 不保证向后兼容：以后的版本可能改动 `metadata.json` 格式，届时需要重写数据。

## 安装

```bash
pip install tyzarr
# 或
uv add tyzarr
```

需要 Python 3.11 及以上。

---

## 1. 两种数据集类型

|                  | 时序数据集                                         | 截面（快照）数据集                 |
|------------------|----------------------------------------------------|------------------------------------|
| 创建方式         | `write(data, time_axis="Date")`                    | `write(data, time_axis=None)`      |
| 存储单位         | 按**日期**登记，每个日期指向一个快照               | 整个数据集只有一个当前快照         |
| 再次写入         | 追加新日期；已有日期必须传 `replace=True` 才能覆盖 | 每次写入都**整体替换**旧数据       |
| 读取             | `read(dstr=...)` 按日期读                          | `read()`，不能传 `dstr`            |
| 合并 `compact()` | 支持                                               | 不支持（只有一个快照，没必要）     |
| 典型数据         | 日频行情、因子、分钟线宽表                         | 合约信息表、行业映射等"最新状态"表 |

`time_axis` 在第一次写入时确定，之后每次写入都必须传同一个值，否则报错。

时序数据集的时间轴按 **自然日**登记：每个值会被规整成 `YYYY-MM-DD`，所以 **一天只能有一个时间轴取值**（日内时间请放到另一个维度，比如
`Timestamp` / `Slot`）。时间轴坐标可以是 `"2026-10-08"` 这样的字符串。

截面数据集里也可以有名为 `Date` 的维度，此时它只是普通维度：用 `indexers` 筛选，不用 `dstr`。

---

## 2. 使用限制（v1）

### 2.1 同一个数据集不能并发写

同一个数据集同一时刻只能有 **一个**写入方，包括 `write()`、`compact()`、`rename()`、`set_attrs()`、`del_attrs()`。v1
只承诺串行写入的正确性。

- 请在调度层保证串行：例如每个数据集只由一个定时任务写，`compact()` 安排在写入窗口之外执行；
- 写入发布失败后，已经写出且未被引用的快照不会自动清理，可能留在 `data/` 目录里成为孤儿文件。

**不同的数据集**之间互不影响，可以并行写。

### 2.2 读写并发的注意点

读和写可以同时进行，读者总是看到一个完整的版本。但有一个例外：

- `read(load=False)` 返回的是延迟（dask）数据，只有真正计算时才去读文件；
- 如果在你计算之前，另一方执行了 `compact()` 或多次覆盖写，把你引用的旧快照删除了，计算会报文件不存在；
- 需要长期持有结果时，请用 `load=True`，或者尽快 `.load()`。

### 2.3 辅助坐标

支持 **一维静态辅助坐标**：挂在一根非时间维度上、取值不随时间变化的属性，例如合约的交易所、类型：

```python
data = data.assign_coords(
    ExchangeID=("InstrumentID", ["SHFE", "DCE", "CZCE"]),
    ContractKind=("InstrumentID", ["Future", "Future", "Option"]),
)
store.write(data, time_axis="Date")
```

- 只能绑定 **一根非时间维度**；绑定时间轴、多维或标量坐标都会报 `MetaError`；
- 同一合约的值由使用者保证不变；合约集合可以每天不同（新上市、到期都没问题）；
- 首次写入时登记，之后每次写入都必须带上全部已登记的辅助坐标；时序数据集写入后 **不能再新增** 辅助坐标；截面数据集每次写入整体替换，可以增减；
- 登记后绑定轴和类型不能改（`<U`、object 字符串都登记为 `string`，可以混写），不能有缺失值；
- 读取时无论跨几个快照都保持一维（按标签取并集）；可以用 `where` 按它筛选（见 4.2），也可以重命名（见 7）。

一次写入多天、且每天合约不同时，先去掉辅助坐标再拼接，再按合约并集挂回；否则 xarray 会把它沿日期叠成二维而被拒绝：

```python
# frames：每天一个 Dataset；exch_map：pd.Series，合约 -> 交易所
batch = xr.concat([f.drop_vars(["ExchangeID", "ContractKind"]) for f in frames], "Date", join="outer")
batch = batch.assign_coords(ExchangeID=("InstrumentID", exch_map.reindex(batch.InstrumentID.values).to_numpy()))
```

辅助坐标会让读取稍慢（本地实测：带 2 个辅助坐标时读取约慢 12–16%），逐日写入每天约多 15 ms。

### 2.4 Schema 规则

第一次写入会登记 schema，之后每次写入都要符合它：

| 项目         | 规则                                                                                                    |
|--------------|---------------------------------------------------------------------------------------------------------|
| 维度集合     | 每次写入的维度集合必须和登记的**完全一致**，不能增减维度                                                |
| 坐标类型     | 每个维度坐标的类型固定（字符串坐标统一登记为 `string`；数值坐标必须同一 dtype）                         |
| 坐标值       | 一维、无重复标签、无缺失值；同一个变量里不能出现重复维度（如协方差矩阵请改成 `Industry` × `Industry2`） |
| 指标         | 可以**新增**指标，也可以某次只写一部分指标；但已登记指标的维度和 dtype 不能变                           |
| 指标 dtype   | 严格比对，`int64` 和 `float64` 不同，会报错；建议数值指标统一用 `float64`                               |
| 默认值       | `metric_options` 里设置的默认值一旦登记，不能修改                                                       |
| 名称         | 维度、指标、辅助坐标三者不能重名                                                                        |
| 辅助坐标     | 只能绑定一根非时间维度；登记后绑定轴和类型不能改，每次写入都要带上（见 2.3）                            |
| 坐标标签集合 | 可以每天不同（例如每天的合约列表不同），多日读取时取并集，缺的位置为 NaN                                |

### 2.5 其他限制

- **历史日期默认不可改**：写入已存在的日期会报错，必须显式 `replace=True`；
- **整日替换**：`replace=True` 替换的是该日期的 **整个切片**，不是逐个单元格合并。只想改一个指标时，也要把这一天的完整数据重新写一遍；
- **历史版本只保留 3 个**：每个日期（或截面数据集）最多保留 3 个旧版本，可用 `history()` 查看，更早的会被清理；
- **按日期读取**：`dstr` 是具体的日期列表，不是区间；读区间用 `where={时间轴: slice(起, 止)}`（见 4.2）；
- 读取时不存在的日期只会打一条 logging 警告并跳过；`indexers` 里写了不存在的标签会报 `KeyError`（`where` 不会，只是没命中）；
- 时间轴不能重命名，不能放进 `indexers`（按日期筛选用 `dstr` 或 `where`）；
- 不适用于高频小批量写入（每次写入都会生成新快照并发布元数据），推荐"日频写入 + 定期 compact"。

---

## 3. 初始化

```python
from tyzarr.backend import LocalStorage, MinIOStorage
from tyzarr.dataset import Dataset

# 本地目录
backend = LocalStorage("D:/zarr_root")

# 或 MinIO：url 只写主机和端口，协议由 secure 决定；bucket 只写桶名（全部参数见 11.2）
# backend = MinIOStorage(key, secret, "minio.example.com:9080", "my-bucket", secure=False)

store = Dataset(
    "daily_quote",  # 数据集名，存储在 zarr/daily_quote/ 下
    backend=backend,
    granularity="batch",  # 快照粒度，见第 6 节
    metric_options=None,  # 指标默认值，例如 {"Volume": 0.0}
)
```

存储布局：

```
zarr/<数据集名>/
├── metadata.json                 # 日期 -> 快照路径、schema、重命名、数据集属性
└── data/
    ├── 20261012093000_<uuid>.zarr
    └── ...
```

---

## 4. 时序数据集

下面的例子都用这个构造函数生成数据：

```python
import numpy as np
import pandas as pd
import xarray as xr


def make_daily(dates, instruments=("A", "B", "C")):
    """生成 Date × InstrumentID 的日频数据"""
    shape = (len(dates), len(instruments))
    return xr.Dataset(
        data_vars={
            "Close": (("Date", "InstrumentID"), np.random.rand(*shape)),
            "Volume": (("Date", "InstrumentID"), np.random.rand(*shape) * 1000),
        },
        coords={
            "Date": pd.to_datetime(dates).values,
            "InstrumentID": list(instruments),
        },
    )
```

### 4.1 写入

```python
store = Dataset("daily_quote", backend=backend)

# 首次写入：登记 time_axis 和 schema
store.write(make_daily(["2026-10-08", "2026-10-09"]), time_axis="Date")

# 追加新日期
store.write(make_daily(["2026-10-10"]), time_axis="Date")

# 已有日期再写会报错：ValueError: 禁止修改历史日期 ...
# 需要覆盖时显式 replace=True（整日替换，旧版本进入 history）
store.write(make_daily(["2026-10-10"]), time_axis="Date", replace=True)
```

`write()` 常用参数：

| 参数             | 默认           | 说明                                                                   |
|------------------|----------------|------------------------------------------------------------------------|
| `time_axis`      | 必填           | 时间轴维度名；截面数据集传 `None`                                      |
| `replace`        | `False`        | 是否允许覆盖已存在的日期                                               |
| `granularity`    | 跟随 `Dataset` | 本次写入的快照粒度，见第 6 节                                          |
| `chunks`         | 自动           | 非时间维度的 chunk 大小，例如 `{"InstrumentID": 1000}`；时间维固定为 1 |
| `compress_level` | `3`            | zstd 压缩级别 1–22，`None` 不压缩                                      |
| `shard_days`     | `256`          | 每个分片最多包含的天数（单个分片未压缩不超过 128 MiB）；`None` 不分片  |
| `write_workers`  | `8`            | 一次写多个快照时的并行线程数                                           |

### 4.2 查询后读取

先查有哪些日期，再按需读取：

```python
dates = store.list_dates()
# ['2026-10-08', '2026-10-09', '2026-10-10']

# 读一个日期区间（闭区间，两端都包含）
ds = store.read(where={"Date": slice("2026-10-09", "2026-10-10")}, load=True)

# 读单日 / 指定的几天
ds = store.read("2026-10-10", load=True)
ds = store.read(["2026-10-08", "2026-10-10"], load=True)

# 不传 dstr：读全部日期
ds = store.read(load=True)
```

按指标、维度标签筛选（会下推到存储层，只读需要的块）：

```python
ds = store.read(
    dates,
    metrics=["Close"],  # 字符串或列表
    indexers={"InstrumentID": ["A", "C"]},  # 标签列表 / 元组 / slice；不能包含时间轴
    fill_value={"Close": 0.0},  # 读出后把 NaN 填成默认值
    load=True,
)
```

按条件过滤用 `where`：键可以是时间轴、维度或辅助坐标；值可以是单个值（等于）、列表（属于其中之一）或 `slice`（闭区间，两端可以是
`None`）；多个键之间是"且"：

```python
ds = store.read(
    where={
        "Date": slice("2026-10-01", None),  # 10-01 及之后
        "ExchangeID": ["SHFE", "DCE"],  # 辅助坐标（见 2.3）
        "ContractKind": "Future",
    },
    metrics=["Close"],
    load=True,
)
```

`indexers` 与 `where` 的区别：

|            | `indexers`    | `where`    |
|------------|---------------|------------|
| 语义       | 按标签选取    | 按条件过滤 |
| 标签不存在 | 报 `KeyError` | 视为没命中 |
| 结果顺序   | 按给定顺序    | 按存储顺序 |

- 两者约束同一根轴时取交集：`indexers={"InstrumentID": ["rb2501", "m2501"]}` 加 `where={"ExchangeID": "DCE"}` 只返回
  `m2501`，并保持 `indexers` 的顺序；
- `dstr` 与 `where` 的时间轴条件同时给出时取交集；
- 没有任何命中时返回 `None`；
- compact 后同一快照内各日期共用合约并集，因此只按日期筛选时，结果可能多出在这些日期全为 NaN 的合约（与不带 `where`
  时的行为一致）；
- 按辅助坐标筛选主要减少内存：命中的合约分散在各个存储块里，块仍要读取，耗时与用 `indexers` 选同一批合约相当。

`read()` 参数：

| 参数           | 说明                                                                                         |
|----------------|----------------------------------------------------------------------------------------------|
| `dstr`         | `str` / `list[str]` / `None`。具体日期（不是区间），`None` 表示全部日期；可与 `where` 取交集 |
| `where`        | `{时间轴/维度/辅助坐标: 值、列表或 slice}`，过滤语义，多个条件取"且"                         |
| `metrics`      | 只读这些指标；不存在的指标会被忽略                                                           |
| `indexers`     | `{维度: 标签列表或 slice}`，语义同 `Dataset.sel`                                             |
| `fill_value`   | `{指标: 填充值}`，覆盖 `metric_options` 中的默认值                                           |
| `load`         | `True` 立即读入内存；`False` 返回 dask 延迟数据                                              |
| `open_workers` | 并行打开快照的线程数，默认 8                                                                 |

数据集不存在、没有匹配的日期或 `where` 没有命中时返回 `None`。

#### 延迟读取（大范围数据）

`load=False` 时返回 dask 数据，可以先构建计算再一次性执行。用完需要 `close()` 释放底层文件句柄：

```python
ds = store.read(dates, metrics=["Close", "Volume"])  # 不读数据，只建图
try:
    amount = (ds["Close"] * ds["Volume"]).sum("InstrumentID")
    result = amount.compute()
finally:
    ds.close()
```

### 4.3 查看历史版本

```python
h = store.history("2026-10-10", metrics="Close", load=True)
h["current"]  # 当前版本
h["history"]  # 旧版本列表，最新的在前，最多 3 个
```

---

## 5. 截面（快照）数据集

`time_axis=None`。每次写入都是一份完整的新数据，整体替换上一份；不需要 `replace=True`。

```python
inst = Dataset("instrument_info", backend=backend)

data = xr.Dataset(
    data_vars={
        "Multiplier": ("InstrumentID", [10.0, 5.0, 20.0]),
        "TickSize": ("InstrumentID", [1.0, 0.5, 0.2]),
    },
    coords={"InstrumentID": ["A", "B", "C"]},
)
inst.write(data, time_axis=None)

# 读取：不能传 dstr
ds = inst.read(load=True)
ds = inst.read(metrics="Multiplier", indexers={"InstrumentID": ["A"]}, load=True)

# 再写一次即整体替换；上一份进入历史
inst.write(data.sel(InstrumentID=["A", "B"]), time_axis=None)
inst.history()["history"]  # 被替换下来的旧版本
```

截面数据集里的 `Date` 若是维度，就是普通维度，用 `indexers={"Date": [...]}` 筛选。

---

## 6. 快照粒度与 compact（写后压缩）

### 6.1 快照粒度 `granularity`

决定一次 `write()` 产生几个快照文件：

| 取值                       | 含义                            |
|----------------------------|---------------------------------|
| `"batch"`（默认）/ `"all"` | 本次写入的全部日期放进一个快照  |
| `"day"`                    | 每天一个快照                    |
| `"month"` / `"year"`       | 按自然月 / 年分组，每组一个快照 |
| 正整数 `n`                 | 从 1970-01-01 起每 `n` 天一组   |

可以在 `Dataset(..., granularity=...)` 上设默认值，也可以在 `write()` / `compact()` 上单独指定。

### 6.2 为什么要 compact

每天写一次会每天产生一个快照。快照越多，大范围读取要打开的文件越多，速度越慢（MinIO 上尤其明显）。`compact()`
把同一个分组（例如同一个月）里的多个小快照合并成一个大快照：

- 数据内容不变；坐标取各快照的 **并集**，缺失位置补 NaN；
- 某些 `int` 指标因补 NaN 会变成 `float`，结果会在 `upcast` 字段中报告；
- 合并后旧的小快照不再被引用，会被删除（`keep_history=True` 时旧快照保留在历史中）；
- 只支持时序数据集。

推荐用法： **每天写入 + 每月（或每周）执行一次 `compact(granularity="month")`**。在 5000 标的 × 40 时点 × 5
年的测试中，月快照比日快照的全量读取快很多。

### 6.3 用法

```python
# 先 dry_run 看计划，不写任何数据
report = store.compact(granularity="month", dry_run=True)
print(report["planned"])

# 合并全部日期，按月
report = store.compact(granularity="month")

# 只合并指定日期（同样是日期列表，不是区间）
dates = [d for d in store.list_dates() if d.startswith("2026-10")]
report = store.compact(dates, granularity="month")
```

返回的报告：

```python
{
    "granularity": "month",
    "dates": 3,  # 参与的日期数
    "buckets": 1,  # 分组数
    "dry_run": False,
    "planned": [...],  # 需要合并的分组
    "skipped": [...],  # 已经是单个快照，跳过
    "rewritten": [  # 实际合并的分组
        {"bucket": "2026-10", "dates": 3, "stores": 3, "path": "...zarr"}
    ],
    "removed": [...],  # 被清理的旧快照
}
```

`compact()` 其他参数 `chunks` / `compress_level` / `shard_days` / `open_workers` 与 `write()` 同义。

**注意**：`compact()` 也是写操作，不能和同一数据集的 `write()` 同时执行（见 2.1）。

---

## 7. 重命名

指标、非时间维度和辅助坐标都可以重命名，只改元数据，不重写数据：

```python
store.rename({"Close": "ClosePrice"})  # 当前名 -> 新名
store.read("2026-10-10", metrics="ClosePrice", load=True)
store.list_renames()  # {存储名: 当前名}
```

- 读取时参数（包括 `where` 的键）和结果都使用当前名称；
- 写入时当前名称和存储名称都接受，但同一份数据里不能同时出现新旧两个名字；
- 新名字不能和已有的维度、指标、辅助坐标重名；
- 时间轴不能重命名。

---

## 8. 数据集概览与属性

### 8.1 `info()`：查看数据集结构

只读 `metadata.json`，不打开任何快照，本地和 MinIO 上都很快；数据集不存在时返回 `None`。

```python
info = store.info()
print(info)
# Dataset: daily_quote  (时序, time_axis=Date)
# Dates:   1210 天, 2021-01-04 ~ 2026-10-23
# Dims:
#     InstrumentID  string
# Metrics:
#     Close   (Date, InstrumentID)  float64  default=None
#     Volume  (Date, InstrumentID)  float64  default=0.0
# Aux coords:
#     ExchangeID  (InstrumentID)  string
# Attrs:   source, unit
```

程序里使用时，直接取字段或转成 dict：

```python
info.dims  # {'InstrumentID': 'string'}
info.metrics  # {'Close': {'dims': ['Date', 'InstrumentID'], 'dtype': 'float64', 'default': None}, ...}
info.aux_coords  # {'ExchangeID': {'dims': ['InstrumentID'], 'dtype': 'string'}}
info.to_dict()  # 全部字段：name、time_axis、dates、start、end、dims、metrics、aux_coords、attrs
```

- 名称都是当前名称（rename 之后的）；
- 只展示类型，不展示坐标取值；
- 指标的维度按名称排序显示，不代表数组的轴顺序；
- 截面数据集显示为"截面"，没有 `Dates` 行，`dates` / `start` / `end` 为 `None`；完整日期列表仍然用 `list_dates()`；
- `Attrs` 只列出属性名，值用 `get_attrs()` 查看。

### 8.2 数据集属性

数据集级别的一个字典，存放在 `metadata.json` 里，存什么、怎么用由使用者决定，例如数据来源、单位、负责人、配置：

```python
store.set_attrs({"source": "ctp", "unit": "CNY", "conf": {"lag": 1}})  # 新增 / 修改，按 key 合并
store.get_attrs()  # {'source': 'ctp', 'unit': 'CNY', 'conf': {'lag': 1}}

store.del_attrs("unit")  # 删除一个
store.del_attrs(["conf", "x"])  # 删除多个；不存在的属性名（x）打一条警告后跳过
store.get_attrs()  # {'source': 'ctp'}

store.set_attrs({"owner": "wrj"}, reset=True)  # 整体替换：只剩 owner
store.set_attrs({}, reset=True)  # 清空全部属性
```

- 属性名必须是非空字符串；
- 值只能是 `str`、`int`、`float`、`bool`、`None`，以及由它们组成的 `list` / `dict`；numpy 标量会自动转成 Python 类型；其他类型（
  `tuple`、`Timestamp`、数组等）报 `MetaError`，避免存进去和读出来不一样；
- `set_attrs` 先校验全部值再写入，校验失败时原有属性不变（`reset=True` 也一样）；
- `get_attrs()` 返回拷贝，修改它不影响已存的属性；数据集不存在时返回 `{}`；
- 数据集写入数据之前不能设置或删除属性（报 `DatasetError`）；
- 修改属性也是一次元数据发布， **不能和同一数据集的其他写操作并发**（见 2.1）；
- `write()`、`compact()`、`rename()` 会原样保留属性；属性没有历史版本；
- `read()`、`history()` 返回的数据不带这些属性，需要时从 `store.get_attrs()` 获取。

---

## 9. 异常

tyzarr 自己的异常都定义在 `tyzarr.error` 中，也可以直接 `from tyzarr import ...`，它们都是 `ValueError` 的子类：

| 异常                      | 父类           | 场景                                                                                                        |
|---------------------------|----------------|-------------------------------------------------------------------------------------------------------------|
| `MetaError`               | `ValueError`   | schema 不符（维度、类型、默认值、辅助坐标）、`time_axis` 不一致、重命名冲突、属性名或属性值不合法等         |
| `MetaConflictError`       | `MetaError`    | 发布元数据时发现被别人改过（并发写）——本次写入失败，可重试                                                  |
| `MetaPublishUnknownError` | `MetaError`    | 网络等原因无法确认是否发布成功，见下方说明                                                                  |
| `DatasetError`            | `ValueError`   | 参数错误（日期、指标、indexers、where、chunks 等格式不对）、数据集未写入就修改属性或重命名                  |
| `BackendError`            | `ValueError`   | 存储参数不合法（如 `MinIOStorage` 的 url 带协议、桶名带目录）、MinIO 响应异常                               |
| `MetaWriteUncertainError` | `BackendError` | 单次写元数据的结果不确定；tyzarr 会自动回读确认，确认不了时转成 `MetaPublishUnknownError`，一般不会直接抛出 |

`MetaPublishUnknownError` 带有 `publish_id` 属性：如果 `metadata.json` 里的 `publish_id` 与它相同，说明其实已经发布成功；否则本次没有生效，可以重试。

其他可能遇到的异常：

| 异常            | 场景                                                                              |
|-----------------|-----------------------------------------------------------------------------------|
| `ValueError`    | 写入已存在日期但没有 `replace=True`、数据缺少时间轴维度、`data` 不是 `xr.Dataset` |
| `KeyError`      | `indexers` 里写了不存在的标签                                                     |
| `minio.S3Error` | MinIO 认证失败、桶不存在等                                                        |

---

## 10. 完整示例

```python
import numpy as np
import pandas as pd
import xarray as xr

from tyzarr.backend import LocalStorage
from tyzarr.dataset import Dataset

backend = LocalStorage("D:/zarr_root")
# 使用独立名称，避免前面章节的重命名影响本例
store = Dataset("daily_quote_demo", backend=backend, granularity="day")


def make_day(day, instruments=("A", "B", "C")):
    n = len(instruments)
    return xr.Dataset(
        data_vars={
            "Close": (("Date", "InstrumentID"), np.random.rand(1, n)),
            "Volume": (("Date", "InstrumentID"), np.random.rand(1, n) * 1000),
        },
        coords={"Date": pd.to_datetime([day]).values, "InstrumentID": list(instruments)},
    )


# 1. 每天写一次（同一时间只有这一个写入方）
# 示例允许重复执行：replace=True 会整日替换已有日期的数据
for day in ["2026-10-08", "2026-10-09", "2026-10-12"]:
    store.write(make_day(day), time_axis="Date", replace=True)

# 某天合约列表变化也可以，读出时取并集
store.write(make_day("2026-10-13", ("A", "B", "D")), time_axis="Date", replace=True)

# 2. 月底合并
print(store.compact(granularity="month")["rewritten"])

# 3. 按日期区间读取
ds = store.read(where={"Date": slice("2026-10-09", None)}, metrics=["Close"],
                indexers={"InstrumentID": ["A", "D"]}, load=True)
print(ds["Close"].to_pandas())  # 2026-10-09/12 的 D 为 NaN

# 4. 记录属性、查看结构
store.set_attrs({"source": "demo", "unit": "CNY"})
print(store.info())
print(store.get_attrs())
```

---

## 11. API 参考

本节列出对外使用的类和函数。签名中 `*` 之后的参数只能用关键字传入。

### 11.1 导入

```python
from tyzarr import Dataset, LocalStorage, MinIOStorage  # 常用类可以直接从包导入
from tyzarr import MetaError, DatasetError, MetaConflictError, MetaPublishUnknownError  # 异常，见第 9 节
from tyzarr.backend import MinIOFactory  # 未在包顶层导出
from tyzarr.dataset import DatasetInfo  # info() 的返回类型，未在包顶层导出

import tyzarr

tyzarr.__version__  # 版本号
```

### 11.2 存储后端

#### `LocalStorage(root)`

本地目录存储。

| 参数   | 说明                                                                            |
|--------|---------------------------------------------------------------------------------|
| `root` | 根目录（`str` 或 `Path`），相对路径会转成绝对路径；目录不存在时首次写入自动创建 |

数据集存放在 `<root>/zarr/<数据集名>/`，同一个根目录可以放多个数据集。

#### `MinIOStorage(key, secret, url, bucket, *, secure=False, max_concurrency=10, max_pool_connections=10)`

MinIO（S3 兼容）存储。

| 参数                   | 默认    | 说明                                                                   |
|------------------------|---------|------------------------------------------------------------------------|
| `key` / `secret`       | 必填    | 访问密钥                                                               |
| `url`                  | 必填    | 只写 `主机:端口`，例如 `"minio.example.com:9080"`；不能带 `http://`    |
| `bucket`               | 必填    | 桶名，不能包含 `/`、`\`、`.`、`:`；桶需要事先建好，tyzarr 不会自动创建 |
| `secure`               | `False` | `True` 时使用 https                                                    |
| `max_concurrency`      | `10`    | 传给 s3fs 的并发数，一般不用改                                         |
| `max_pool_connections` | `10`    | 连接池大小，一般不用改                                                 |

- 参数不合法时报 `BackendError`；
- 数据集存放在桶内的 `zarr/<数据集名>/`；
- 始终直连 MinIO，不使用系统代理设置；
- `secure=True` 时，CA 证书优先使用环境变量 `SSL_CERT_FILE` 指定的文件，否则使用 certifi 自带的证书。

#### `MinIOFactory(key, secret, url)`

同一套账号访问多个桶时使用：

```python
factory = MinIOFactory(key, secret, "minio.example.com:9080")
backend = factory.new("my-bucket")  # 返回 MinIOStorage
```

`new()` 创建的 `MinIOStorage` 使用默认参数（`secure=False` 等）；需要 https 或调整并发时，直接构造 `MinIOStorage`。

### 11.3 `Dataset`

#### 构造

`Dataset(name, *, backend, metric_options=None, granularity="batch")`

| 参数             | 默认      | 说明                                                                                                |
|------------------|-----------|-----------------------------------------------------------------------------------------------------|
| `name`           | 必填      | 数据集名，存放在 `zarr/<name>/`                                                                     |
| `backend`        | 必填      | `LocalStorage` 或 `MinIOStorage`                                                                    |
| `metric_options` | `None`    | 指标默认值 `{指标名: 默认值}`，写入时登记，读取时用来填充 NaN；值只能是数字、布尔或字符串（见 2.4） |
| `granularity`    | `"batch"` | `write()` / `compact()` 默认的快照粒度（见 6.1）；不合法时报 `DatasetError`                         |

构造时不读写存储，数据集在第一次 `write()` 时才真正创建。

#### 方法一览

| 方法                             | 作用                                   | 返回                                 | 详见   |
|----------------------------------|----------------------------------------|--------------------------------------|--------|
| `write(data, *, time_axis, ...)` | 写入数据                               | `None`                               | 4.1、5 |
| `read(dstr=None, *, ...)`        | 读取数据                               | `xr.Dataset`；没有数据时 `None`      | 4.2    |
| `history(dstr=None, *, ...)`     | 某个日期（或截面）的当前版本和历史版本 | `dict`；没有数据时 `None`            | 4.3    |
| `list_dates()`                   | 全部日期                               | `list[str]`                          | 4.2    |
| `compact(dstr=None, *, ...)`     | 合并小快照                             | 报告 `dict`                          | 6      |
| `rename(config)`                 | 重命名指标、维度、辅助坐标             | `None`                               | 7      |
| `list_renames()`                 | 查看重命名                             | `{存储名: 当前名}`                   | 7      |
| `info()`                         | 查看结构                               | `DatasetInfo`；数据集不存在时 `None` | 8.1    |
| `get_attrs()`                    | 读取数据集属性                         | `dict`                               | 8.2    |
| `set_attrs(attrs, reset=False)`  | 新增、修改或整体替换属性               | `None`                               | 8.2    |
| `del_attrs(keys)`                | 删除属性                               | `None`                               | 8.2    |

其中 `write`、`compact`、`rename`、`set_attrs`、`del_attrs` 会发布元数据，同一数据集不能并发执行（见 2.1）。

#### `write`

```python
write(data, *, time_axis, chunks=None, compress_level=3, replace=False,
      write_workers=8, shard_days=256, granularity=None) -> None
```

参数见 4.1 的表格。补充：

- `time_axis` 没有默认值，每次都要传；截面数据集传 `None`；
- 时序数据的时间轴长度为 0 时，不写入任何数据；
- `data` 不是 `xr.Dataset`、缺少时间轴维度、写入已存在日期但没有 `replace=True` 时报 `ValueError`；schema 不符报
  `MetaError`；参数格式不对报 `DatasetError`。

#### `read`

```python
read(dstr=None, *, metrics=None, indexers=None, where=None, fill_value=None,
     load=False, open_workers=8) -> xr.Dataset | None
```

参数见 4.2 的表格。补充：

- 截面数据集不能传 `dstr`，否则报 `DatasetError`；
- `load=False` 返回延迟数据，用完调用 `ds.close()`；
- 数据集不存在、没有匹配的日期或 `where` 没有命中时返回 `None`。

#### `history`

```python
history(dstr=None, *, metrics=None, indexers=None, fill_value=None, load=False) -> dict | None
```

| 参数                                           | 说明                                                   |
|------------------------------------------------|--------------------------------------------------------|
| `dstr`                                         | 时序数据集：必须是**一个**日期字符串；截面数据集：不传 |
| `metrics` / `indexers` / `fill_value` / `load` | 同 `read()`                                            |

- 返回 `{"current": 当前版本, "history": [旧版本, ...]}`，旧版本最新的在前，最多 3 个；
- 数据集不存在或该日期没有数据时返回 `None`；
- 不支持 `where`；
- `load=False` 时，`current` 和 `history` 里的每个结果都要各自 `close()`。

#### `list_dates`

`list_dates() -> list[str]`：返回全部日期（`YYYY-MM-DD`，升序）；数据集不存在时返回 `[]`；截面数据集报 `DatasetError`。

#### `compact`

```python
compact(dstr=None, *, granularity=None, chunks=None, compress_level=3, shard_days=256,
        keep_history=False, dry_run=False, open_workers=8) -> dict
```

| 参数                                       | 默认           | 说明                                                                                     |
|--------------------------------------------|----------------|------------------------------------------------------------------------------------------|
| `dstr`                                     | `None`         | 要合并的日期（字符串或列表，不是区间）；`None` 表示全部日期；没有数据的日期打警告后跳过  |
| `granularity`                              | 跟随 `Dataset` | 按什么粒度分组合并，见 6.1                                                               |
| `chunks` / `compress_level` / `shard_days` | 同 `write()`   | 新快照的分块、压缩、分片设置                                                             |
| `keep_history`                             | `False`        | `True` 时被合并的旧快照进入历史版本（占用 3 个历史名额）；否则不再被引用的旧快照会被删除 |
| `dry_run`                                  | `False`        | 只返回计划，不写任何数据                                                                 |
| `open_workers`                             | `8`            | 并行打开快照的线程数                                                                     |

- 返回报告，结构见 6.3；
- 数据集不存在时返回空报告；截面数据集报 `DatasetError`。

#### `rename` / `list_renames`

- `rename(config) -> None`：`config` 为 `{当前名: 新名}`，可以一次改多个；空字典什么也不做；数据集还没写入时报
  `DatasetError`；名字不存在、冲突或重命名时间轴时报 `MetaError`；
- `list_renames() -> dict[str, str]`：返回 `{存储名: 当前名}`；没有重命名或数据集不存在时返回 `{}`。

#### `info` / `get_attrs` / `set_attrs` / `del_attrs`

见 8.1、8.2：

- `info() -> DatasetInfo | None`
- `get_attrs() -> dict`
- `set_attrs(attrs, reset=False) -> None`
- `del_attrs(keys) -> None`：`keys` 为一个属性名或属性名列表

#### 工具函数（静态方法）

不需要创建实例，直接用类调用：

| 函数                                     | 说明                                                                                                                             |
|------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------|
| `Dataset.pass_dstr(value) -> str`        | 把日期（字符串、`date`、`datetime`、`pd.Timestamp` 等）规整成 `YYYY-MM-DD`，与 `list_dates()` 的格式一致；带时间的只保留日期部分 |
| `Dataset.date_keys(values) -> list[str]` | `pass_dstr` 的批量版，接受数组或列表，例如 `ds["Date"].values`                                                                   |
| `Dataset.verify_granularity(value)`      | 检查快照粒度是否合法，返回规整后的值；不合法时报 `DatasetError`                                                                  |

```python
from datetime import datetime

Dataset.pass_dstr("20261027")  # '2026-10-27'
Dataset.pass_dstr(datetime(2026, 10, 27, 15, 30))  # '2026-10-27'
Dataset.date_keys(ds["Date"].values)  # ['2026-10-08', '2026-10-09', ...]
```

#### 不建议直接使用

以下名字虽然没有下划线，但属于内部实现，接口可能随版本变化：

- `Dataset.gen_filepath()`、`Dataset.gen_schema(...)`、`tyzarr.dataset.NArray`；
- `Meta`、`MetaData`、`Metric`、`Schema`：`metadata.json` 的内部结构；需要了解数据集结构时用 `info()`；
- 存储后端的 `to_zarr`、`read`、`get_meta`、`update_meta`、`clean` 等方法：它们由 `Dataset` 调用；直接调用会绕过元数据发布和并发保护，可能破坏数据集。

### 11.4 `DatasetInfo`

`info()` 的返回值，所有名称都是当前名称：

| 字段           | 类型              | 说明                                                   |
|----------------|-------------------|--------------------------------------------------------|
| `name`         | `str`             | 数据集名                                               |
| `time_axis`    | `str` 或 `None`   | 时间轴名；截面数据集为 `None`                          |
| `dates`        | `int` 或 `None`   | 日期数；截面数据集为 `None`                            |
| `start`、`end` | `str` 或 `None`   | 最早、最晚日期；截面数据集或没有日期时为 `None`        |
| `dims`         | `dict[str, str]`  | 非时间维度 → 坐标类型                                  |
| `metrics`      | `dict[str, dict]` | 指标 → `{"dims": [...], "dtype": ..., "default": ...}` |
| `aux_coords`   | `dict[str, dict]` | 辅助坐标 → `{"dims": [...], "dtype": ...}`             |
| `attrs`        | `list[str]`       | 属性名                                                 |

`to_dict()` 返回同样内容的 dict；`print(info)` 输出可读文本（见 8.1）。
