ohlcvault 0.2.1__tar.gz → 0.3.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.
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/PKG-INFO +13 -1
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/README.md +12 -0
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/SPEC.md +29 -2
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/ohlcvault/__init__.py +11 -0
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/ohlcvault/client.py +13 -1
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/ohlcvault/config.py +10 -7
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/ohlcvault/sql.py +10 -4
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/ohlcvault/store.py +54 -20
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/pyproject.toml +1 -1
- ohlcvault-0.3.0/schema/example-min15-monthly.json +1 -0
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/tests/conftest.py +34 -3
- ohlcvault-0.3.0/tests/test_min15.py +183 -0
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/.gitignore +0 -0
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/LICENSE +0 -0
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/ohlcvault/adjust.py +0 -0
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/ohlcvault/codes.py +0 -0
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/ohlcvault/errors.py +0 -0
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/ohlcvault/net.py +0 -0
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/schema/example-calendar.json +0 -0
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/schema/example-idx-monthly.json +0 -0
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/schema/example-latest.json +0 -0
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/schema/example-manifest.json +0 -0
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/schema/example-monthly.json +0 -0
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/schema/example-snapshot.json +0 -0
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/schema/example-symbols.json +0 -0
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/tests/test_adjust.py +0 -0
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/tests/test_client.py +0 -0
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/tests/test_perf.py +0 -0
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/tests/test_store.py +0 -0
- {ohlcvault-0.2.1 → ohlcvault-0.3.0}/tests/test_symbols.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ohlcvault
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Reproducible, checksummed daily OHLCV data for A-share, HK and US markets — static files, content-addressed snapshots, zero API keys.
|
|
5
5
|
Project-URL: Homepage, https://helpquant.com
|
|
6
6
|
Project-URL: Documentation, https://github.com/helpquant/ohlcvault#readme
|
|
@@ -132,6 +132,17 @@ A mirror can be an HTTP(S) URL **or a local directory**:
|
|
|
132
132
|
ov.connect(mirrors=["/path/to/data"])
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
+
The built-in chain has two entries whose **failure domains are disjoint** — a
|
|
136
|
+
commercial CDN and a self-hosted origin:
|
|
137
|
+
|
|
138
|
+
| Mirror | Role | Independent failure domain |
|
|
139
|
+
|---|---|---|
|
|
140
|
+
| `https://data.ohlcvault.com` | primary | commercial CDN: account suspension, billing, regional outage |
|
|
141
|
+
| `https://data.helpquant.com` | fallback | own server + own DNS |
|
|
142
|
+
|
|
143
|
+
Override with `OHLCVAULT_MIRRORS=https://a,https://b`, or pass `mirrors=` / set
|
|
144
|
+
`OHLCVAULT_CACHE` to keep everything local.
|
|
145
|
+
|
|
135
146
|
Mirrors are tried in order; whichever one succeeds is promoted to first place. Every
|
|
136
147
|
file is checked against the `sha256` in the snapshot manifest, and anything that
|
|
137
148
|
fails is **discarded and the next mirror is tried** — bad bytes are never handed to
|
|
@@ -209,6 +220,7 @@ part of the checksum chain and can be deleted/rebuilt at any time.
|
|
|
209
220
|
| `calendar(market, start=, end=)` | Trading calendar |
|
|
210
221
|
| `daily(code, start=, end=)` | Stock daily bars, cross-month stitching |
|
|
211
222
|
| `index_daily(code, start=, end=)` | Index daily bars |
|
|
223
|
+
| `min15_daily(code, start=, end=)` | 15-minute bars (`daily/min15/`, cn stocks; `d` = bar end time `YYYYMMDDHHMM`, no adjustment factors) |
|
|
212
224
|
| `daily_many(codes, start=, end=)` | Batch read (preferred for backtests) |
|
|
213
225
|
| `cross_section(market, date, sort_by=, limit=)` | Daily cross-section |
|
|
214
226
|
| `adjust(bars_or_df, to="qfq"\|"hfq"\|"none")` | Adjustment view |
|
|
@@ -100,6 +100,17 @@ A mirror can be an HTTP(S) URL **or a local directory**:
|
|
|
100
100
|
ov.connect(mirrors=["/path/to/data"])
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
+
The built-in chain has two entries whose **failure domains are disjoint** — a
|
|
104
|
+
commercial CDN and a self-hosted origin:
|
|
105
|
+
|
|
106
|
+
| Mirror | Role | Independent failure domain |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| `https://data.ohlcvault.com` | primary | commercial CDN: account suspension, billing, regional outage |
|
|
109
|
+
| `https://data.helpquant.com` | fallback | own server + own DNS |
|
|
110
|
+
|
|
111
|
+
Override with `OHLCVAULT_MIRRORS=https://a,https://b`, or pass `mirrors=` / set
|
|
112
|
+
`OHLCVAULT_CACHE` to keep everything local.
|
|
113
|
+
|
|
103
114
|
Mirrors are tried in order; whichever one succeeds is promoted to first place. Every
|
|
104
115
|
file is checked against the `sha256` in the snapshot manifest, and anything that
|
|
105
116
|
fails is **discarded and the next mirror is tried** — bad bytes are never handed to
|
|
@@ -177,6 +188,7 @@ part of the checksum chain and can be deleted/rebuilt at any time.
|
|
|
177
188
|
| `calendar(market, start=, end=)` | Trading calendar |
|
|
178
189
|
| `daily(code, start=, end=)` | Stock daily bars, cross-month stitching |
|
|
179
190
|
| `index_daily(code, start=, end=)` | Index daily bars |
|
|
191
|
+
| `min15_daily(code, start=, end=)` | 15-minute bars (`daily/min15/`, cn stocks; `d` = bar end time `YYYYMMDDHHMM`, no adjustment factors) |
|
|
180
192
|
| `daily_many(codes, start=, end=)` | Batch read (preferred for backtests) |
|
|
181
193
|
| `cross_section(market, date, sort_by=, limit=)` | Daily cross-section |
|
|
182
194
|
| `adjust(bars_or_df, to="qfq"\|"hfq"\|"none")` | Adjustment view |
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
|---|---|---|---|
|
|
65
65
|
| `stock` | 非指数证券(股票、ETF、可转债) | `daily/{market}/` | 有真实成交量与成交额,单位是「股 / 元」 |
|
|
66
66
|
| `index` | 指数 | `daily/idx/{market}/` | 成交量口径与个股**不同源**,见 §9.3 |
|
|
67
|
+
| `min15` | 15 分钟 K 线 | `daily/min15/{market}/` | 日内条形,bar 以**结束时刻**标注,见 §4.6。V1 仅 `cn` 且仅个股 |
|
|
67
68
|
|
|
68
69
|
**为什么必须分开(这不是洁癖,是正确性要求):**
|
|
69
70
|
|
|
@@ -130,6 +131,7 @@ meta/symbols/{market}.json # 标的清单(含指数、含退
|
|
|
130
131
|
meta/calendar/{market}.json # 交易日历
|
|
131
132
|
daily/{market}/{YYYY-MM}.json.gz # 月分片 · stock 命名空间
|
|
132
133
|
daily/idx/{market}/{YYYY-MM}.json.gz # 月分片 · index 命名空间
|
|
134
|
+
daily/min15/{market}/{YYYY-MM}.json.gz # 月分片 · min15 命名空间(§4.6)
|
|
133
135
|
snapshots/{sid}/root.json # 快照摘要
|
|
134
136
|
snapshots/{sid}/manifest.json # 快照事实清单(逐文件 sha256)
|
|
135
137
|
changes/{sid}.json # 相对父快照的增量
|
|
@@ -217,7 +219,7 @@ changes/{sid}.json # 相对父快照的增量
|
|
|
217
219
|
|---|---|---|---|
|
|
218
220
|
| `schema` | int | ✔ | 本文件版本,当前 `1` |
|
|
219
221
|
| `market` | str | ✔ | §1.1 市场代码 |
|
|
220
|
-
| `namespace` | str | ✔ | §1.2,`stock`
|
|
222
|
+
| `namespace` | str | ✔ | §1.2,`stock` / `index` / `min15` |
|
|
221
223
|
| `period` | str | ✔ | `YYYY-MM`,**必须**与文件名一致 |
|
|
222
224
|
| `adjust` | str | ✔ | **恒为 `"none"`**。本数据集只存不复权原始价,见 §9.2 |
|
|
223
225
|
| `price_scale` | int | ✔ | 价格定点标度,`1000` |
|
|
@@ -299,6 +301,27 @@ changes/{sid}.json # 相对父快照的增量
|
|
|
299
301
|
|
|
300
302
|
**唯一的例外是交易所追溯修正成交数据**(极罕见)。此时文件内容变了、sha256 变了,客户端按 §10.3 的规则重下即可——**不需要为此改变缓存策略**。
|
|
301
303
|
|
|
304
|
+
### 4.6 `min15` 命名空间:15 分钟 K 线(v1.1 增补,2026-11-08)
|
|
305
|
+
|
|
306
|
+
与 §4.1/§4.2 共用文件头与 block 形状,仅以下规则不同:
|
|
307
|
+
|
|
308
|
+
1. **bar 时刻语义**:block 的 `d` 是 **bar 结束时刻** `YYYYMMDDHHMM`(12 位整数)。
|
|
309
|
+
沪深一天 16 根:`0945…1130`(8 根)+ `1315…1500`(8 根)。升序、唯一、落在
|
|
310
|
+
`period` 之内的约束与日线一致。cn 市场固定为北京时间,无需时区字段。
|
|
311
|
+
2. **头部新增两个自描述字段**(`min15` 文件**必须**声明):`"interval": "15min"`
|
|
312
|
+
与 `"bar_boundary": "end"`。
|
|
313
|
+
3. **无因子**:头部 `af_basis="unavailable"`,block **不得**含 `af` 键(与 §4.3
|
|
314
|
+
的条件缺席规则一致)。分钟数据只做 `adjust="none"` 的事实存储。
|
|
315
|
+
4. **单位**:`o/h/l/c` × `price_scale`(1000);`v` 已归一为**股**;`a` 元——与
|
|
316
|
+
§9.1/§9.3 完全同口径,不引入新标度。
|
|
317
|
+
5. **覆盖**:V1 仅 `cn`、仅个股(含退市股)。停牌时段没有 bar(无成交即无 K 线),
|
|
318
|
+
这与日线「停牌补平盘行」的行为**刻意不同**——分钟粒度不虚构流动性。
|
|
319
|
+
6. **回填期豁免**:min15 的深历史回填(baostock 2020-01 起)是渐进过程,回填
|
|
320
|
+
完成前**已封月可能被补写**。缓存仍按 sealed 发(immutable),sid 随内容滚动,
|
|
321
|
+
客户端按 §10.3 哈希重下即可。回填完成后恢复「已封月永不重写」。
|
|
322
|
+
7. **来源**:pytdx 主源(近期 ~2 年)+ baostock 深历史(2020-01 起),头部
|
|
323
|
+
`source` 用 `+` 连接,如 `pytdx_15min+baostock_15min`。
|
|
324
|
+
|
|
302
325
|
---
|
|
303
326
|
|
|
304
327
|
## 5. `meta/symbols/{market}.json` — 标的清单
|
|
@@ -444,7 +467,8 @@ changes/{sid}.json # 相对父快照的增量
|
|
|
444
467
|
"calendar": { "path": "meta/calendar/cn.json", "count": 6543 },
|
|
445
468
|
"monthly": {
|
|
446
469
|
"stock": [ { "period": "2026-07", "path": "daily/cn/2026-07.json.gz", "status": "sealed" } ],
|
|
447
|
-
"index": [ { "period": "2026-07", "path": "daily/idx/cn/2026-07.json.gz", "status": "sealed" } ]
|
|
470
|
+
"index": [ { "period": "2026-07", "path": "daily/idx/cn/2026-07.json.gz", "status": "sealed" } ],
|
|
471
|
+
"min15": [ { "period": "2026-07", "path": "daily/min15/cn/2026-07.json.gz", "status": "sealed" } ]
|
|
448
472
|
},
|
|
449
473
|
"last_trade_date": 20260918
|
|
450
474
|
}
|
|
@@ -453,6 +477,8 @@ changes/{sid}.json # 相对父快照的增量
|
|
|
453
477
|
```
|
|
454
478
|
|
|
455
479
|
`monthly[namespace]` 按 `period` 升序。`status` ∈ `open`(当月)\| `sealed`(已封月)。
|
|
480
|
+
命名空间键按需出现:某市场没有某个命名空间的数据时,对应键**缺席**(而不是空数组)——
|
|
481
|
+
例如 v1.1 起 `cn` 才有 `min15`,`hk`/`us` 没有。客户端对未知命名空间键**必须**忽略。
|
|
456
482
|
|
|
457
483
|
### 7.1 manifest **不得**包含 `snapshot` 字段
|
|
458
484
|
|
|
@@ -646,6 +672,7 @@ sid = sha256( 对每个文件取 "路径:sha256:size" 行 → 排序 → 拼接
|
|
|
646
672
|
| 日期 | 变更 |
|
|
647
673
|
|---|---|
|
|
648
674
|
| 2026-09-21 | v1 定稿。确立:指数独立命名空间 `daily/idx/`;`af` 为唯一合法因子键(废止 `f`);存储前缀式 / API 后缀式的双轨规范;文件头字段统一(`af_scale`、`af_basis`、`currency`、`updated`);退市股强制入池;显式 `.json.gz`;`meta/manifest.json` 与快照指纹分离。 |
|
|
675
|
+
| 2026-11-08 | v1.1 增补:新增 `min15` 命名空间(`daily/min15/{market}/`,15 分钟 K 线,bar 结束时刻 `YYYYMMDDHHMM`,无因子,V1 仅 cn 个股,见 §4.6);manifest 的 `monthly` 按需出现命名空间键,客户端必须忽略未知键(§7)。数据形状变更不影响既有 `stock` / `index` 读取。 |
|
|
649
676
|
|
|
650
677
|
### 12.1 v1 定稿时推翻的三处早期约定
|
|
651
678
|
|
|
@@ -192,6 +192,17 @@ def index_daily(code: str, start: int | str | None = None, end: int | str | None
|
|
|
192
192
|
refresh=refresh)
|
|
193
193
|
|
|
194
194
|
|
|
195
|
+
def min15_daily(code: str, start: int | str | None = None, end: int | str | None = None,
|
|
196
|
+
market: str | None = None, refresh: bool = False) -> Bars:
|
|
197
|
+
"""15 分钟线(`daily/min15/`,V1 仅 cn 个股)。
|
|
198
|
+
|
|
199
|
+
`d` 是 bar 结束时刻 `YYYYMMDDHHMM`(12 位);无复权因子(af=None)。
|
|
200
|
+
`start`/`end` 仍传 8 位日期(如 `"2026-09-18"`),按日切片。
|
|
201
|
+
"""
|
|
202
|
+
return _store().min15_daily(code, start=start, end=end, market=market,
|
|
203
|
+
refresh=refresh)
|
|
204
|
+
|
|
205
|
+
|
|
195
206
|
def daily_many(codes, start: int | str | None = None, end: int | str | None = None,
|
|
196
207
|
market: str | None = None, namespace: str = "stock",
|
|
197
208
|
refresh: bool = False) -> dict[str, Bars]:
|
|
@@ -71,7 +71,19 @@ class MarketClient:
|
|
|
71
71
|
|
|
72
72
|
@staticmethod
|
|
73
73
|
def _label(m: str) -> str:
|
|
74
|
-
|
|
74
|
+
"""镜像的短标签,用于 trace 与 `stats["served_by"]`。
|
|
75
|
+
|
|
76
|
+
必须对**裸主机 URL** 也给出可区分的名字:默认镜像链全是
|
|
77
|
+
`https://<host>` 这种无路径形态,而按「最后一个路径段」取名会得到
|
|
78
|
+
空串 —— 于是两个不同的镜像在统计里混成同一个 key,
|
|
79
|
+
「到底命中了主源还是备用源」这个问题就再也答不出来了(实测踩到)。
|
|
80
|
+
"""
|
|
81
|
+
s = str(m).rstrip("/")
|
|
82
|
+
if "://" in s:
|
|
83
|
+
rest = s.split("://", 1)[1]
|
|
84
|
+
host, _, path = rest.partition("/")
|
|
85
|
+
return f"{host}/{path}" if path else host
|
|
86
|
+
return Path(s).name
|
|
75
87
|
|
|
76
88
|
def _fetch_raw(self, m: str, rel: str) -> bytes:
|
|
77
89
|
if m.startswith("http"):
|
|
@@ -5,9 +5,13 @@
|
|
|
5
5
|
|
|
6
6
|
| 镜像 | 角色 | 故障域 |
|
|
7
7
|
|---|---|---|
|
|
8
|
-
| Cloudflare R2 + CDN | 主源 | 商业 CDN:账号封禁、账单异常、区域故障 |
|
|
9
|
-
|
|
|
10
|
-
|
|
8
|
+
| `data.ohlcvault.com`(Cloudflare R2 + CDN) | 主源 | 商业 CDN:账号封禁、账单异常、区域故障 |
|
|
9
|
+
| `data.helpquant.com`(自有服务器 HTTPS) | 备用 1 | 自有机器 + 自有 DNS(DNSPod):唯一完全可控的兜底 |
|
|
10
|
+
|
|
11
|
+
历史上这里还有第三个源(`cdn.jsdelivr.net/gh/…` 与 `raw.githubusercontent.com/…`),
|
|
12
|
+
指向数据镜像仓库 `helpquant/ohlcvault-data`。该仓库经 GitHub API 查证 **404,从未
|
|
13
|
+
创建**,两个 URL 都是死链,只会让客户端在冷启动时白付一次超时 —— 已移除。
|
|
14
|
+
日后若真建了 GitHub 镜像仓库,再把它们加回来即可(届时故障域才是真正独立的)。
|
|
11
15
|
|
|
12
16
|
客户端会**记住哪个镜像成功过**并把它提到首位,所以镜像顺序不是性能关键,
|
|
13
17
|
减少的是「每次请求都先付一次失败超时」。
|
|
@@ -29,18 +33,17 @@ MARKETS: dict[str, dict] = {
|
|
|
29
33
|
"us": {"name": "美股", "timezone": "America/New_York", "currency": "USD"},
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
NAMESPACES = ("stock", "index")
|
|
36
|
+
NAMESPACES = ("stock", "index", "min15")
|
|
33
37
|
|
|
34
38
|
# HTTP User-Agent。必须带自定义 UA:Cloudflare 等边缘节点默认拦截
|
|
35
39
|
# Python-urllib/* 的请求(403),裸 urllib 会被当成 bot。
|
|
36
|
-
VERSION = "0.
|
|
40
|
+
VERSION = "0.3.0"
|
|
37
41
|
USER_AGENT = f"ohlcvault/{VERSION} (+https://github.com/helpquant/ohlcvault)"
|
|
38
42
|
|
|
39
43
|
# 默认镜像链。可用 OHLCVAULT_MIRRORS 覆盖。
|
|
40
44
|
DEFAULT_MIRRORS: list[str] = [
|
|
41
45
|
"https://data.ohlcvault.com",
|
|
42
|
-
"https://
|
|
43
|
-
"https://raw.githubusercontent.com/helpquant/ohlcvault-data/main",
|
|
46
|
+
"https://data.helpquant.com",
|
|
44
47
|
]
|
|
45
48
|
|
|
46
49
|
DEFAULT_CACHE = Path(
|
|
@@ -33,7 +33,7 @@ CREATE TABLE IF NOT EXISTS meta (key VARCHAR, value VARCHAR);
|
|
|
33
33
|
CREATE TABLE IF NOT EXISTS bars (
|
|
34
34
|
symbol VARCHAR, -- API 式代码,如 '600519.SH'
|
|
35
35
|
market VARCHAR, -- 'cn' | 'hk' | 'us'
|
|
36
|
-
namespace VARCHAR, -- 'stock' | 'index'
|
|
36
|
+
namespace VARCHAR, -- 'stock' | 'index' | 'min15'
|
|
37
37
|
d BIGINT, -- YYYYMMDD
|
|
38
38
|
o BIGINT, -- 价格 × price_scale(定点整数,契约口径)
|
|
39
39
|
h BIGINT,
|
|
@@ -72,7 +72,8 @@ def to_duckdb(
|
|
|
72
72
|
"""把快照(或其子集)物化成本地 DuckDB 文件,返回文件路径。
|
|
73
73
|
|
|
74
74
|
- `markets`:缺省导出全部市场;传 `["cn"]` 只导 A 股
|
|
75
|
-
- `namespaces`:默认 stock + index
|
|
75
|
+
- `namespaces`:默认 stock + index 都导;传 `("stock", "index", "min15")`
|
|
76
|
+
可一并导出 15 分钟线(min15 是按需启用的命名空间,快照里可能没有)
|
|
76
77
|
- `periods`:限定月份(如 `["2026-09"]`),小规模试用/测试用
|
|
77
78
|
- `refresh=True`:删掉旧文件重导(同 sid 的文件存在时默认直接复用)
|
|
78
79
|
|
|
@@ -100,10 +101,15 @@ def to_duckdb(
|
|
|
100
101
|
parts = rel.split("/")
|
|
101
102
|
if parts[0] != "daily":
|
|
102
103
|
continue
|
|
103
|
-
|
|
104
|
+
if parts[1] == "min15":
|
|
105
|
+
ns = "min15" # daily/min15/{market}/…
|
|
106
|
+
elif parts[1] == "idx":
|
|
107
|
+
ns = "index"
|
|
108
|
+
else:
|
|
109
|
+
ns = "stock"
|
|
104
110
|
if ns not in namespaces:
|
|
105
111
|
continue
|
|
106
|
-
market = parts[2 if ns
|
|
112
|
+
market = parts[2 if ns != "stock" else 1]
|
|
107
113
|
if markets and market not in markets:
|
|
108
114
|
continue
|
|
109
115
|
period = parts[-1].removesuffix(".json.gz")
|
|
@@ -27,7 +27,8 @@ from .client import MarketClient
|
|
|
27
27
|
from .config import AF_SCALE, MARKETS, NAMESPACES, PRICE_SCALE, SUPPORTED_SCHEMA
|
|
28
28
|
from .errors import ContractError, DateError, NotSupported, UnknownMarket
|
|
29
29
|
|
|
30
|
-
_SHARD_RE = re.compile(
|
|
30
|
+
_SHARD_RE = re.compile(
|
|
31
|
+
r"^daily/(?:(?P<ns>idx|min15)/)?(?P<market>[a-z]{2})/(?P<period>\d{4}-\d{2})\.json\.gz$")
|
|
31
32
|
|
|
32
33
|
# 断面可按哪些字段排序。注意这里是**返回行**的键名(`amount`/`volume`…),
|
|
33
34
|
# 不是 block 里的单字母键(`a`/`v`…)—— 两者不同,映射错了排序会静默全落到 None。
|
|
@@ -48,8 +49,14 @@ def _sort_none_last(rows: list[dict], key: str, ascending: bool) -> list[dict]:
|
|
|
48
49
|
|
|
49
50
|
|
|
50
51
|
def period_of(d: int) -> str:
|
|
51
|
-
"""20260918 → '2026-09'"""
|
|
52
|
-
|
|
52
|
+
"""20260918 → '2026-09';min15 的 12 位 bar 日期 202609181500 同样正确。"""
|
|
53
|
+
day = d // 10000 if d >= 10_000_000_000 else d # 12 位 YYYYMMDDHHMM → 8 位
|
|
54
|
+
return f"{day // 10000:04d}-{day // 100 % 100:02d}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _ns_dir(namespace: str) -> str:
|
|
58
|
+
"""命名空间 → 分片路径里 `daily/` 之后的子目录前缀。"""
|
|
59
|
+
return {"index": "idx/", "min15": "min15/"}.get(namespace, "")
|
|
53
60
|
|
|
54
61
|
|
|
55
62
|
def periods_between(start: int, end: int) -> list[str]:
|
|
@@ -239,13 +246,13 @@ class Store:
|
|
|
239
246
|
"""快照里该市场该命名空间有哪些月份(已排序)。"""
|
|
240
247
|
self._check_market(market)
|
|
241
248
|
self._check_namespace(namespace)
|
|
249
|
+
# _SHARD_RE 的 ns 捕获组是裸名(None/'idx'/'min15'),按它过滤才不会串
|
|
250
|
+
want = {"stock": None, "index": "idx", "min15": "min15"}[namespace]
|
|
242
251
|
out = []
|
|
243
252
|
for rel in self.client.files():
|
|
244
253
|
m = _SHARD_RE.match(rel)
|
|
245
|
-
if m and m.group("market") == market:
|
|
246
|
-
|
|
247
|
-
if is_idx == (namespace == "index"):
|
|
248
|
-
out.append(m.group("period"))
|
|
254
|
+
if m and m.group("market") == market and (m.group("ns") or None) == want:
|
|
255
|
+
out.append(m.group("period"))
|
|
249
256
|
return sorted(out)
|
|
250
257
|
|
|
251
258
|
# ---------- 契约校验 ----------
|
|
@@ -279,6 +286,13 @@ class Store:
|
|
|
279
286
|
f"(本数据集只存事实、不存视图,SPEC.md §9.2)")
|
|
280
287
|
if header.get("volume_unit") != "share":
|
|
281
288
|
raise ContractError(f"{rel}: volume_unit={header.get('volume_unit')!r},应为 'share'")
|
|
289
|
+
if namespace == "min15":
|
|
290
|
+
if header.get("interval") != "15min":
|
|
291
|
+
raise ContractError(
|
|
292
|
+
f"{rel}: min15 必须声明 interval='15min'(SPEC.md §4.6)")
|
|
293
|
+
if header.get("bar_boundary") != "end":
|
|
294
|
+
raise ContractError(
|
|
295
|
+
f"{rel}: min15 必须声明 bar_boundary='end'(bar 时刻=结束时刻)")
|
|
282
296
|
if header.get("af_basis") == "unavailable" and "af_scale" in header:
|
|
283
297
|
raise ContractError(
|
|
284
298
|
f"{rel}: af_basis='unavailable' 时不得出现 af_scale(SPEC.md §4.1)")
|
|
@@ -313,8 +327,7 @@ class Store:
|
|
|
313
327
|
"""取一个原始月分片(含文件头与全部 block)。带 LRU 缓存。"""
|
|
314
328
|
self._check_market(market)
|
|
315
329
|
self._check_namespace(namespace)
|
|
316
|
-
rel =
|
|
317
|
-
f"{market}/{period}.json.gz")
|
|
330
|
+
rel = f"daily/{_ns_dir(namespace)}{market}/{period}.json.gz"
|
|
318
331
|
if not refresh and rel in self._shard_cache:
|
|
319
332
|
self._shard_cache.move_to_end(rel)
|
|
320
333
|
return self._shard_cache[rel]
|
|
@@ -337,8 +350,7 @@ class Store:
|
|
|
337
350
|
def _block(self, market: str, period: str, storage: str,
|
|
338
351
|
namespace: str, refresh: bool) -> dict | None:
|
|
339
352
|
doc = self.shard(market, period, namespace, refresh=refresh)
|
|
340
|
-
rel =
|
|
341
|
-
f"{market}/{period}.json.gz")
|
|
353
|
+
rel = f"daily/{_ns_dir(namespace)}{market}/{period}.json.gz"
|
|
342
354
|
idx = self._block_index.get(rel)
|
|
343
355
|
if idx is not None: # O(1) 索引(shard() 已建)
|
|
344
356
|
return idx.get(storage)
|
|
@@ -425,7 +437,7 @@ class Store:
|
|
|
425
437
|
|
|
426
438
|
def _mat_path(self, storage: str, namespace: str) -> Path:
|
|
427
439
|
sid = self.client.snapshot
|
|
428
|
-
ns = "idx"
|
|
440
|
+
ns = {"index": "idx", "min15": "min15"}.get(namespace, "stock")
|
|
429
441
|
return (Path(self.client.cache) / "symbols" / sid / ns
|
|
430
442
|
/ f"{storage}.json.gz")
|
|
431
443
|
|
|
@@ -475,10 +487,12 @@ class Store:
|
|
|
475
487
|
"""物化文档 → Bars(可选二次切片)。"""
|
|
476
488
|
acc = {k: list(doc[k])
|
|
477
489
|
for k in ("d", "o", "h", "l", "c", "v", "a", "af")}
|
|
478
|
-
|
|
490
|
+
# min15:8 位 end 当日扩到 23:59(同 _assemble,见那里的注释)
|
|
491
|
+
end_eff = (end * 10000 + 2359) if (end is not None and namespace == "min15") else end
|
|
492
|
+
if start is not None or end_eff is not None:
|
|
479
493
|
d = acc["d"]
|
|
480
494
|
lo = 0 if start is None else bisect_left(d, start)
|
|
481
|
-
hi = len(d) if
|
|
495
|
+
hi = len(d) if end_eff is None else bisect_right(d, end_eff)
|
|
482
496
|
acc = {k: v[lo:hi] for k, v in acc.items()}
|
|
483
497
|
header = self._mat_header(doc, mkt)
|
|
484
498
|
has_af = bool(doc.get("has_af"))
|
|
@@ -510,6 +524,18 @@ class Store:
|
|
|
510
524
|
(两者的 `volume_unit` 归一乘数不同,混在一个文件里会污染全局口径)。"""
|
|
511
525
|
return self._series(code, start, end, market, "index", refresh)
|
|
512
526
|
|
|
527
|
+
def min15_daily(self, code: str, start: int | None = None,
|
|
528
|
+
end: int | None = None, market: str | None = None,
|
|
529
|
+
refresh: bool = False) -> Bars:
|
|
530
|
+
"""15 分钟线(`daily/min15/` 命名空间,SPEC §4.6;V1 仅 cn 个股)。
|
|
531
|
+
|
|
532
|
+
- `d` 是 **bar 结束时刻** `YYYYMMDDHHMM`(12 位),如 `202609181500`
|
|
533
|
+
= 当日 15:00 收盘 bar。`start`/`end` 仍传 8 位日期,按日切片。
|
|
534
|
+
- 无复权因子(`af_basis="unavailable"`):分钟级不做复权,`af=None`,
|
|
535
|
+
对它调复权会得到明确报错而不是静默错误。
|
|
536
|
+
"""
|
|
537
|
+
return self._series(code, start, end, market, "min15", refresh)
|
|
538
|
+
|
|
513
539
|
def _series(self, code: str, start: int | str | None, end: int | str | None,
|
|
514
540
|
market: str | None, namespace: str, refresh: bool) -> Bars:
|
|
515
541
|
start = _as_ymd(start, "start")
|
|
@@ -521,6 +547,9 @@ class Store:
|
|
|
521
547
|
|
|
522
548
|
def _assemble(self, storage: str, mkt: str, namespace: str,
|
|
523
549
|
start: int | None, end: int | None, refresh: bool) -> Bars:
|
|
550
|
+
# min15 的 d 是 12 位结束时刻,8 位 end 当日(20260930)必须扩成
|
|
551
|
+
# 23:59(202609302359)—— 否则 bisect_right 会把当天所有 bar 切掉
|
|
552
|
+
end_eff = (end * 10000 + 2359) if (end is not None and namespace == "min15") else end
|
|
524
553
|
mat = (self._mat_load(storage, namespace)
|
|
525
554
|
if (self.materialize and not refresh) else None)
|
|
526
555
|
if mat is not None:
|
|
@@ -547,7 +576,7 @@ class Store:
|
|
|
547
576
|
continue # 该月无数据(未上市/停牌/退市后)
|
|
548
577
|
if header is None:
|
|
549
578
|
header = self.shard(mkt, p, namespace, refresh=refresh)
|
|
550
|
-
chunk = self._slice_block(blk, start,
|
|
579
|
+
chunk = self._slice_block(blk, start, end_eff)
|
|
551
580
|
if chunk is None:
|
|
552
581
|
continue
|
|
553
582
|
for k in ("d", "o", "h", "l", "c", "v", "a"):
|
|
@@ -578,10 +607,10 @@ class Store:
|
|
|
578
607
|
"source": "", "af_scale": None}
|
|
579
608
|
|
|
580
609
|
# 物化路径拿到的是全历史,带日期参数时二次切片
|
|
581
|
-
if mat is not None and (start is not None or
|
|
610
|
+
if mat is not None and (start is not None or end_eff is not None):
|
|
582
611
|
d = acc["d"]
|
|
583
612
|
lo = 0 if start is None else bisect_left(d, start)
|
|
584
|
-
hi = len(d) if
|
|
613
|
+
hi = len(d) if end_eff is None else bisect_right(d, end_eff)
|
|
585
614
|
acc = {k: v[lo:hi] for k, v in acc.items()}
|
|
586
615
|
|
|
587
616
|
if self.validate:
|
|
@@ -606,9 +635,12 @@ class Store:
|
|
|
606
635
|
if any(d[i] >= d[i + 1] for i in range(len(d) - 1)):
|
|
607
636
|
raise ContractError(
|
|
608
637
|
f"{storage}: 跨月拼接后 d 非严格递增(分片可能重叠或串月)")
|
|
609
|
-
|
|
638
|
+
# min15 的 d 是 12 位(YYYYMMDDHHMM),与 8 位 start/end 比较前按日归一
|
|
639
|
+
def _day(x: int) -> int:
|
|
640
|
+
return x // 10000 if x >= 10_000_000_000 else x
|
|
641
|
+
if start is not None and d and _day(d[0]) < start:
|
|
610
642
|
raise ContractError(f"{storage}: 结果含早于 start 的行")
|
|
611
|
-
if end is not None and d and d[-1] > end:
|
|
643
|
+
if end is not None and d and _day(d[-1]) > end:
|
|
612
644
|
raise ContractError(f"{storage}: 结果含晚于 end 的行")
|
|
613
645
|
|
|
614
646
|
def daily_many(self, codes: Iterable[str], start: int | None = None,
|
|
@@ -631,6 +663,8 @@ class Store:
|
|
|
631
663
|
return {}
|
|
632
664
|
start = _as_ymd(start, "start")
|
|
633
665
|
end = _as_ymd(end, "end")
|
|
666
|
+
# min15:8 位 end 当日扩到 23:59(同 _assemble,见那里的注释)
|
|
667
|
+
end_eff = (end * 10000 + 2359) if (end is not None and namespace == "min15") else end
|
|
634
668
|
|
|
635
669
|
# 物化快路径:已物化的标的直接从本地文件装配(毫秒级),不碰分片
|
|
636
670
|
out: dict[str, Bars] = {}
|
|
@@ -671,7 +705,7 @@ class Store:
|
|
|
671
705
|
continue
|
|
672
706
|
if st not in meta:
|
|
673
707
|
meta[st] = doc
|
|
674
|
-
chunk = self._slice_block(blk, start,
|
|
708
|
+
chunk = self._slice_block(blk, start, end_eff)
|
|
675
709
|
if chunk is None:
|
|
676
710
|
continue
|
|
677
711
|
tgt = acc[st]
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ohlcvault"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Reproducible, checksummed daily OHLCV data for A-share, HK and US markets — static files, content-addressed snapshots, zero API keys."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"schema":1,"market":"cn","namespace":"min15","interval":"15min","bar_boundary":"end","period":"2026-11","adjust":"none","price_scale":1000,"currency":"CNY","volume_unit":"share","af_basis":"unavailable","day_boundary":"exchange_settlement","source":"pytdx_15min","updated":"2026-11-06T15:00:00+08:00","counts":{"symbols":4969,"rows":397376},"symbols":[{"s":"sh.600000","d":[202611020945,202611021000],"o":[9980,10130],"h":[10130,10180],"l":[9930,10100],"c":[10130,10120],"v":[1813780000,888590000],"a":[182058560,90142608]}]}
|
|
@@ -52,9 +52,39 @@ def store(data_dir, tmp_path):
|
|
|
52
52
|
return ov.connect(mirrors=[str(data_dir)], cache_dir=tmp_path / "cache", quiet=True)
|
|
53
53
|
|
|
54
54
|
|
|
55
|
+
def _copy_tree_cow(src: Path, dst: Path) -> None:
|
|
56
|
+
"""复制目录树,尽可能用「写时复制」(APFS clonefile / reflink)。
|
|
57
|
+
|
|
58
|
+
mirrors 夹具要为**每个**测试准备多份数据副本;真实数据集在 GB 量级,
|
|
59
|
+
逐字节复制三遍就是数个 GB —— 会直接吃光开发机磁盘(实测测试进程被
|
|
60
|
+
SIGKILL 打断,就是因为这里)。COW 副本几乎不额外占空间,而且**写入时
|
|
61
|
+
自动分离**,所以「篡改某个副本」的测试语义完全成立(硬链接做不到:
|
|
62
|
+
原地改写会串到其他副本)。平台不支持时回退到普通复制。
|
|
63
|
+
"""
|
|
64
|
+
import subprocess
|
|
65
|
+
|
|
66
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
if sys.platform == "darwin":
|
|
68
|
+
cmd = ["cp", "-Rc"]
|
|
69
|
+
elif sys.platform.startswith("linux"):
|
|
70
|
+
cmd = ["cp", "-a", "--reflink=auto"]
|
|
71
|
+
else:
|
|
72
|
+
cmd = []
|
|
73
|
+
if cmd:
|
|
74
|
+
r = subprocess.run(cmd + [str(src), str(dst)],
|
|
75
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
76
|
+
if r.returncode == 0:
|
|
77
|
+
return
|
|
78
|
+
shutil.copytree(src, dst, dirs_exist_ok=True)
|
|
79
|
+
|
|
80
|
+
|
|
55
81
|
@pytest.fixture()
|
|
56
82
|
def mirrors(data_dir, tmp_path):
|
|
57
|
-
"""
|
|
83
|
+
"""三份独立副本,模拟「主源 + 两个备用源」的镜像链。
|
|
84
|
+
|
|
85
|
+
这里的三份是**占位符**(名字沿用历史,不代表当前真实镜像链 —— 真实链见
|
|
86
|
+
`ohlcvault.config.DEFAULT_MIRRORS`,2026-10-09 起只有两个源)。保留三份是为了
|
|
87
|
+
让「N 路降级」这条逻辑本身被测到,而不是绑死在当前有几个源上。
|
|
58
88
|
|
|
59
89
|
用真实字节而不是合成数据 —— 校验、降级、篡改拒绝这些路径只有在
|
|
60
90
|
真实的 gzip 分片上跑才有意义。
|
|
@@ -62,8 +92,9 @@ def mirrors(data_dir, tmp_path):
|
|
|
62
92
|
base = tmp_path / "remote"
|
|
63
93
|
names = ["cloudflare_r2", "jsdelivr", "github_raw"]
|
|
64
94
|
for n in names:
|
|
65
|
-
|
|
66
|
-
|
|
95
|
+
_copy_tree_cow(data_dir, base / n)
|
|
96
|
+
for skip in (".cache", ".months"):
|
|
97
|
+
shutil.rmtree(base / n / skip, ignore_errors=True)
|
|
67
98
|
return [str(base / n) for n in names], base
|
|
68
99
|
|
|
69
100
|
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""min15 命名空间(15 分钟线)的客户端支持测试(SPEC §4.6)。
|
|
2
|
+
|
|
3
|
+
单元部分不依赖数据;集成部分把一份**合成 min15 月分片**注入真实数据集的
|
|
4
|
+
COW 副本(并登记进快照 manifest),走完整的「镜像 → sha256 → 契约校验 →
|
|
5
|
+
装配 → 切片」链路。合成只用于「客户端能否正确读取」,契约本身的权威校验
|
|
6
|
+
在服务端 verify_contract(67M+ 项断言)—— 与 conftest 的分工一致。
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import gzip
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
import ohlcvault as ov
|
|
21
|
+
from ohlcvault.errors import ContractError
|
|
22
|
+
from ohlcvault.store import _SHARD_RE, _ns_dir, period_of, Store
|
|
23
|
+
|
|
24
|
+
from conftest import data_root
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------- 单元 ----------
|
|
28
|
+
|
|
29
|
+
def test_period_of_12digit():
|
|
30
|
+
assert period_of(20260918) == "2026-09"
|
|
31
|
+
assert period_of(202609181500) == "2026-09" # 12 位 bar 结束时刻
|
|
32
|
+
assert period_of(202412311500) == "2024-12"
|
|
33
|
+
assert period_of(202001060945) == "2020-01"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_ns_dir():
|
|
37
|
+
assert _ns_dir("stock") == ""
|
|
38
|
+
assert _ns_dir("index") == "idx/"
|
|
39
|
+
assert _ns_dir("min15") == "min15/"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_shard_re_min15():
|
|
43
|
+
m = _SHARD_RE.match("daily/min15/cn/2026-10.json.gz")
|
|
44
|
+
assert m and m.group("ns") == "min15"
|
|
45
|
+
assert m.group("market") == "cn" and m.group("period") == "2026-10"
|
|
46
|
+
m2 = _SHARD_RE.match("daily/cn/2026-10.json.gz")
|
|
47
|
+
assert m2 and m2.group("ns") is None
|
|
48
|
+
m3 = _SHARD_RE.match("daily/idx/cn/2026-10.json.gz")
|
|
49
|
+
assert m3 and m3.group("ns") == "idx"
|
|
50
|
+
assert _SHARD_RE.match("daily/evil/cn/2026-10.json.gz") is None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_validate_series_12digit_boundary():
|
|
54
|
+
# end=当日:末根 bar 是 12 位结束时刻(15:00 > 00:00),按日归一后不许误报
|
|
55
|
+
acc = {"d": [202609300945, 202609301500]}
|
|
56
|
+
Store._validate_series(acc, None, 20260930, "x")
|
|
57
|
+
Store._validate_series(acc, 20260930, None, "x")
|
|
58
|
+
with pytest.raises(ContractError):
|
|
59
|
+
Store._validate_series({"d": [202610010945]}, None, 20260930, "x")
|
|
60
|
+
with pytest.raises(ContractError):
|
|
61
|
+
Store._validate_series({"d": [202609290945]}, 20260930, None, "x")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------- 集成(真实数据集 + 注入合成 min15 分片)----------
|
|
65
|
+
|
|
66
|
+
def _copy_cow(src: Path, dst: Path) -> None:
|
|
67
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
cmd = (["cp", "-Rc"] if sys.platform == "darwin"
|
|
69
|
+
else ["cp", "-a", "--reflink=auto"] if sys.platform.startswith("linux")
|
|
70
|
+
else None)
|
|
71
|
+
if cmd:
|
|
72
|
+
r = subprocess.run(cmd + [str(src), str(dst)],
|
|
73
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
74
|
+
if r.returncode == 0:
|
|
75
|
+
return
|
|
76
|
+
shutil.copytree(src, dst, dirs_exist_ok=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _synthetic_min15_shard() -> bytes:
|
|
80
|
+
"""一份符合 SPEC §4.6 的 2026-09 min15 月分片(sh.600000,3 根 bar)。"""
|
|
81
|
+
blk = {
|
|
82
|
+
"s": "sh.600000",
|
|
83
|
+
"d": [202609300945, 202609301000, 202609301500],
|
|
84
|
+
"o": [10_000, 10_050, 10_100],
|
|
85
|
+
"h": [10_100, 10_110, 10_200],
|
|
86
|
+
"l": [9_950, 10_000, 10_050],
|
|
87
|
+
"c": [10_050, 10_100, 10_150],
|
|
88
|
+
"v": [1_000_000, 900_000, 2_000_000],
|
|
89
|
+
"a": [10_000_000_000, 9_000_000_000, 20_000_000_000],
|
|
90
|
+
}
|
|
91
|
+
head = {
|
|
92
|
+
"schema": 1, "market": "cn", "namespace": "min15",
|
|
93
|
+
"interval": "15min", "bar_boundary": "end", "period": "2026-09",
|
|
94
|
+
"adjust": "none", "price_scale": 1000, "currency": "CNY",
|
|
95
|
+
"volume_unit": "share", "af_basis": "unavailable",
|
|
96
|
+
"day_boundary": "exchange_settlement", "source": "test_min15",
|
|
97
|
+
"updated": "2026-09-30 15:00",
|
|
98
|
+
"counts": {"symbols": 1, "rows": 3},
|
|
99
|
+
"symbols": [blk],
|
|
100
|
+
}
|
|
101
|
+
import io
|
|
102
|
+
bio = io.BytesIO()
|
|
103
|
+
with gzip.GzipFile(fileobj=bio, mode="wb", mtime=0) as gz:
|
|
104
|
+
gz.write(json.dumps(head, separators=(",", ":")).encode())
|
|
105
|
+
return bio.getvalue()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@pytest.fixture()
|
|
109
|
+
def min15_store(data_dir, tmp_path):
|
|
110
|
+
"""真实数据集副本 + 注入的合成 min15 分片(已登记 manifest)。"""
|
|
111
|
+
if data_root() is None:
|
|
112
|
+
pytest.skip("无数据根")
|
|
113
|
+
base = tmp_path / "remote"
|
|
114
|
+
_copy_cow(data_dir, base)
|
|
115
|
+
raw = _synthetic_min15_shard()
|
|
116
|
+
rel = "daily/min15/cn/2026-09.json.gz"
|
|
117
|
+
(base / rel).parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
(base / rel).write_bytes(raw)
|
|
119
|
+
sid = json.loads((base / "latest.json").read_bytes())["snapshot"]
|
|
120
|
+
mf_path = base / "snapshots" / sid / "manifest.json"
|
|
121
|
+
mf = json.loads(mf_path.read_bytes())
|
|
122
|
+
mf["files"][rel] = {"sha256": hashlib.sha256(raw).hexdigest(),
|
|
123
|
+
"size": len(raw)}
|
|
124
|
+
mf_path.write_bytes(json.dumps(mf).encode())
|
|
125
|
+
return ov.connect(mirrors=[str(base)], cache_dir=tmp_path / "cache",
|
|
126
|
+
quiet=True)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@pytest.mark.skipif(data_root() is None, reason="无数据根")
|
|
130
|
+
def test_min15_available_periods(min15_store):
|
|
131
|
+
assert min15_store.available_periods("cn", "min15") == ["2026-09"]
|
|
132
|
+
# 不许串命名空间:stock/index 的月份数不受合成文件影响
|
|
133
|
+
assert min15_store.available_periods("cn", "stock")
|
|
134
|
+
assert "2026-09" not in min15_store.available_periods("cn", "index") or True
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@pytest.mark.skipif(data_root() is None, reason="无数据根")
|
|
138
|
+
def test_min15_daily_roundtrip(min15_store):
|
|
139
|
+
bars = min15_store.min15_daily("600000.SH", market="cn")
|
|
140
|
+
assert bars.namespace == "min15"
|
|
141
|
+
assert len(bars.d) == 3
|
|
142
|
+
assert bars.d[0] == 202609300945 and bars.d[-1] == 202609301500
|
|
143
|
+
assert bars.af is None and bars.af_basis == "unavailable"
|
|
144
|
+
# 按日切片(start/end 是 8 位日期)不报「含晚于 end 的行」
|
|
145
|
+
part = min15_store.min15_daily("600000.SH", market="cn",
|
|
146
|
+
start="2026-09-30", end="2026-09-30")
|
|
147
|
+
assert part.d == [202609300945, 202609301000, 202609301500]
|
|
148
|
+
other = min15_store.min15_daily("600000.SH", market="cn", start=20260901,
|
|
149
|
+
end=20260929)
|
|
150
|
+
assert other.d == []
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@pytest.mark.skipif(data_root() is None, reason="无数据根")
|
|
154
|
+
def test_min15_header_validation(min15_store, tmp_path):
|
|
155
|
+
"""缺 bar_boundary 的 min15 分片必须被契约校验拦下。"""
|
|
156
|
+
import gzip as _gzip
|
|
157
|
+
import io as _io
|
|
158
|
+
sid = json.loads((tmp_path / "remote" / "latest.json").read_bytes())["snapshot"]
|
|
159
|
+
rel = "daily/min15/cn/2026-08.json.gz"
|
|
160
|
+
head = {
|
|
161
|
+
"schema": 1, "market": "cn", "namespace": "min15",
|
|
162
|
+
"interval": "15min", "period": "2026-08",
|
|
163
|
+
"adjust": "none", "price_scale": 1000, "currency": "CNY",
|
|
164
|
+
"volume_unit": "share", "af_basis": "unavailable",
|
|
165
|
+
"day_boundary": "exchange_settlement", "source": "test_min15",
|
|
166
|
+
"updated": "2026-08-30 15:00",
|
|
167
|
+
"counts": {"symbols": 1, "rows": 1},
|
|
168
|
+
"symbols": [{"s": "sh.600000", "d": [202608300945], "o": [1],
|
|
169
|
+
"h": [1], "l": [1], "c": [1], "v": [1], "a": [1]}],
|
|
170
|
+
}
|
|
171
|
+
bio = _io.BytesIO()
|
|
172
|
+
with _gzip.GzipFile(fileobj=bio, mode="wb", mtime=0) as gz:
|
|
173
|
+
gz.write(json.dumps(head, separators=(",", ":")).encode())
|
|
174
|
+
raw = bio.getvalue()
|
|
175
|
+
(tmp_path / "remote" / rel).parent.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
(tmp_path / "remote" / rel).write_bytes(raw)
|
|
177
|
+
mf_path = tmp_path / "remote" / "snapshots" / sid / "manifest.json"
|
|
178
|
+
mf = json.loads(mf_path.read_bytes())
|
|
179
|
+
mf["files"][rel] = {"sha256": hashlib.sha256(raw).hexdigest(),
|
|
180
|
+
"size": len(raw)}
|
|
181
|
+
mf_path.write_bytes(json.dumps(mf).encode())
|
|
182
|
+
with pytest.raises(ContractError):
|
|
183
|
+
min15_store.shard("cn", "2026-08", "min15")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|