ohlcvault 0.1.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.
ohlcvault/__init__.py ADDED
@@ -0,0 +1,282 @@
1
+ """OHLCVault —— 可复现、带校验和的公开行情数据集客户端。
2
+
3
+ OHLCV = Open/High/Low/Close/Volume, the universal bar format.
4
+ Vault = immutable, checksummed snapshots.
5
+
6
+ 设计立场(决定了这个库为什么长这样):
7
+
8
+ - **数据是事实,复权是视图。** 盘上只存不复权原始价 + 官方复权因子,
9
+ 前/后复权由客户端算(`adjust()`)。所以历史文件永远不会因分红送转而改变,
10
+ `immutable` 缓存才成立,用户才有可能「一生下载一次」。
11
+ - **没有可信校验的数据不算数据。** 每个文件都对快照清单里的 sha256 校验,
12
+ 不匹配即丢弃并降级到下一个镜像 —— 绝不把脏数据交给调用方。
13
+ - **可复现是一等公民。** 每次读取都锚定在一个快照 id 上(`snapshot()`),
14
+ 把它记下来就能在任何机器、任何时间重建同一份输入。
15
+
16
+ 快速开始:
17
+
18
+ import ohlcvault as ov
19
+
20
+ ov.connect() # 默认镜像链
21
+ ov.daily("600519.SH", start=20260701, end=20260918)
22
+ ov.cross_section("cn", 20260918, limit=50) # 按成交额排序的日截面
23
+ ov.symbols("cn", type="stock", status="delisted") # 退市股也在池内
24
+ ov.calendar("cn", start=20260101)
25
+
26
+ 离线 / 自建镜像:
27
+
28
+ ov.connect(mirrors=["/path/to/data"]) # 本地目录也是合法镜像
29
+ ov.connect(mirrors=[...], snapshot="be592f7a7fb5e4e3") # 冻结复现
30
+ """
31
+ from __future__ import annotations
32
+
33
+ from pathlib import Path
34
+
35
+ from .adjust import ADJUSTMENTS, apply_adjust
36
+ from .client import MarketClient, sha256_bytes, sha256_file
37
+ from .codes import market_of as market_of
38
+ from .codes import normalize as normalize_symbol
39
+ from .codes import split as split_symbol
40
+ from .codes import to_api, to_storage
41
+ from .config import DEFAULT_CACHE, DEFAULT_MIRRORS, MARKETS, TIMEOUT, mirrors_from_env
42
+ from .errors import (
43
+ ConfigError,
44
+ ContractError,
45
+ DateError,
46
+ HashMismatch,
47
+ NotSupported,
48
+ OhlcvaultError,
49
+ SnapshotUnavailable,
50
+ SymbolError,
51
+ UnknownMarket,
52
+ )
53
+ from .store import Bars, Store, period_of, periods_between
54
+
55
+ __version__ = "0.1.0"
56
+
57
+ __all__ = [
58
+ "Bars", "Store", "MarketClient", "connect", "use", "reset",
59
+ "symbols", "symbol", "calendar", "daily", "index_daily", "daily_many",
60
+ "cross_section", "adjust", "snapshot", "coverage", "coverage_note",
61
+ "period_of", "periods_between", "sha256_bytes", "sha256_file",
62
+ "normalize_symbol", "to_api", "to_storage", "market_of", "split_symbol",
63
+ "__version__",
64
+ # errors
65
+ "OhlcvaultError", "ConfigError", "ContractError", "DateError", "HashMismatch",
66
+ "NotSupported", "SnapshotUnavailable", "SymbolError", "UnknownMarket",
67
+ ]
68
+
69
+ _default: Store | None = None
70
+
71
+
72
+ # ── 连接 ────────────────────────────────────────────────────────────────
73
+
74
+ def connect(
75
+ mirrors: list[str] | None = None,
76
+ cache_dir: str | Path | None = None,
77
+ snapshot: str | None = None,
78
+ timeout: int = TIMEOUT,
79
+ validate: bool = True,
80
+ shard_cache: int = 4,
81
+ quiet: bool = False,
82
+ ) -> Store:
83
+ """建立(并记住)默认 Store。
84
+
85
+ `mirrors` 缺省时依次读环境变量 `OHLCVAULT_MIRRORS` 与内置默认链。
86
+ 镜像既可以是 http(s) URL,也可以是**本地目录** —— 后者让离线复现和
87
+ 自建镜像站都不需要额外代码。
88
+ """
89
+ global _default
90
+ ms = mirrors or mirrors_from_env()
91
+ if not ms:
92
+ raise ConfigError("没有可用镜像:显式传入 mirrors 或设置 OHLCVAULT_MIRRORS")
93
+ cli = MarketClient(ms, cache_dir or DEFAULT_CACHE, snapshot=snapshot, timeout=timeout)
94
+ st = Store(cli, validate=validate, shard_cache=shard_cache)
95
+ _default = st
96
+ if not quiet:
97
+ _announce(st)
98
+ return st
99
+
100
+
101
+ def _announce(st: Store) -> None:
102
+ """首次连接时把「你拿到的是哪一份数据、它缺什么」说出来。
103
+
104
+ 静默地返回一份有已知缺口的数据,是这个项目最不想犯的错误。
105
+ """
106
+ sid = st.snapshot
107
+ print(f"[ohlcvault] 快照 {sid} 镜像 {len(st.client.mirrors)} 个")
108
+ for mkt in MARKETS:
109
+ try:
110
+ note = st.coverage_note(mkt)
111
+ except OhlcvaultError:
112
+ continue
113
+ if note:
114
+ print(f"[ohlcvault] {mkt} 覆盖说明:{note}")
115
+
116
+
117
+ def use(store: Store) -> Store:
118
+ """指定默认 Store(例如已 pinned 的实例)。"""
119
+ global _default
120
+ _default = store
121
+ return store
122
+
123
+
124
+ def reset() -> None:
125
+ """清掉默认 Store(测试用)。"""
126
+ global _default
127
+ _default = None
128
+
129
+
130
+ def _store() -> Store:
131
+ global _default
132
+ if _default is None:
133
+ _default = connect()
134
+ return _default
135
+
136
+
137
+ # ── 五项需求的模块级入口(委托给默认 Store)────────────────────────────
138
+
139
+ def symbols(market: str, type: str | None = None, # noqa: A002
140
+ status: str | None = None, board: str | None = None,
141
+ include_indices: bool = True) -> list[dict]:
142
+ """标的清单:代码 / 名称 / 上市日 / 退市状态 / 类型 / 板块。
143
+
144
+ **含退市股**(`status="delisted"`)。回测池若只含今天还活着的股票,
145
+ 历史收益会被系统性高估 —— 这是本数据集相对爬虫类源的结构性优势。
146
+ """
147
+ return _store().symbols(market, type=type, status=status, board=board,
148
+ include_indices=include_indices)
149
+
150
+
151
+ def symbol(code: str, market: str | None = None) -> dict:
152
+ """查单个标的的清单条目。"""
153
+ return _store().symbol(code, market=market)
154
+
155
+
156
+ def calendar(market: str, start: int | str | None = None,
157
+ end: int | str | None = None) -> list[int]:
158
+ """交易日历(升序 `YYYYMMDD`)。权威区间只到今天,不承诺未来。
159
+
160
+ `start` / `end` 接受 `20260918` 或 `"2026-09-18"`。
161
+ """
162
+ return _store().calendar(market, start=start, end=end)
163
+
164
+
165
+ def daily(code: str, start: int | str | None = None, end: int | str | None = None,
166
+ market: str | None = None, refresh: bool = False) -> Bars:
167
+ """股票日线(跨月自动拼接)。
168
+
169
+ `start` / `end` 接受 `20260918` 或 `"2026-09-18"`;非法形态抛 `DateError`。
170
+ """
171
+ return _store().daily(code, start=start, end=end, market=market, refresh=refresh)
172
+
173
+
174
+ def index_daily(code: str, start: int | str | None = None, end: int | str | None = None,
175
+ market: str | None = None, refresh: bool = False) -> Bars:
176
+ """指数日线(独立命名空间,不与个股混装)。"""
177
+ return _store().index_daily(code, start=start, end=end, market=market,
178
+ refresh=refresh)
179
+
180
+
181
+ def daily_many(codes, start: int | str | None = None, end: int | str | None = None,
182
+ market: str | None = None, namespace: str = "stock",
183
+ refresh: bool = False) -> dict[str, Bars]:
184
+ """批量取多个标的时间序列。**回测请优先用这个** —— 每个分片只装载一次。"""
185
+ return _store().daily_many(codes, start=start, end=end, market=market,
186
+ namespace=namespace, refresh=refresh)
187
+
188
+
189
+ def cross_section(market: str, date: int | str, sort_by: str = "amount",
190
+ limit: int | None = None, ascending: bool = False,
191
+ namespace: str = "stock") -> list[dict]:
192
+ """某一交易日的全市场截面,默认按成交额降序。
193
+
194
+ `date` 接受 `20260918` 或 `"2026-09-18"`。
195
+ """
196
+ return _store().cross_section(market, date, sort_by=sort_by, limit=limit,
197
+ ascending=ascending, namespace=namespace)
198
+
199
+
200
+ def snapshot() -> str:
201
+ """当前快照 id。记下它 = 记下这次读取的确切输入,随时可复现。"""
202
+ return _store().snapshot
203
+
204
+
205
+ def coverage(market: str) -> dict:
206
+ """该市场的覆盖度声明。`complete=False` 表示这份数据明确知道自己是残缺的。"""
207
+ return _store().coverage(market)
208
+
209
+
210
+ def coverage_note(market: str) -> str:
211
+ return _store().coverage_note(market)
212
+
213
+
214
+ # ── 复权视图 ────────────────────────────────────────────────────────────
215
+
216
+ def adjust(obj, to: str = "qfq"):
217
+ """把不复权数据换算成复权**视图**。`to` ∈ {`"none"`, `"hfq"`, `"qfq"`}。
218
+
219
+ 接受 `Bars` 或 pandas DataFrame(后者需含 `af` 列与 `open/high/low/close`)。
220
+
221
+ qfq_i = price_i × af_i / af_latest (锚点 = 最后一个有数据的交易日)
222
+ hfq_i = price_i × af_i / af_scale (锚点 = 上市首日,af=1)
223
+
224
+ **`af_latest` 取「该标的最后一个有数据的交易日的因子」,不是「当前日期的因子」**
225
+ —— 停牌期间不产生新因子。
226
+
227
+ 港美 V1 没有官方因子源(`af_basis="unavailable"`),此时请求复权**会抛
228
+ `NotSupported`**,而不是退回未复权数据:宁可报错,不给不可信的数字。
229
+ """
230
+ if to not in ADJUSTMENTS:
231
+ raise ValueError(f"to={to!r} 不支持,合法值 {ADJUSTMENTS}")
232
+ if isinstance(obj, Bars):
233
+ return _adjust_bars(obj, to)
234
+ return _adjust_frame(obj, to)
235
+
236
+
237
+ def _adjust_bars(bars: Bars, to: str) -> Bars:
238
+ if to == "none":
239
+ return bars
240
+ if not bars.has_factor or bars.af is None:
241
+ raise NotSupported(
242
+ f"{bars.symbol}: af_basis={bars.af_basis!r},该市场没有官方复权因子,"
243
+ f"无法给出可信的 {to} 视图(SPEC.md §9.5)")
244
+ af_latest = next((x for x in reversed(bars.af) if x is not None), None)
245
+ scale = bars.af_scale or 1
246
+ out = Bars(
247
+ symbol=bars.symbol, market=bars.market, namespace=bars.namespace,
248
+ price_scale=bars.price_scale, currency=bars.currency,
249
+ volume_unit=bars.volume_unit, af_basis=bars.af_basis,
250
+ af_scale=bars.af_scale, source=bars.source, adjust=to,
251
+ d=list(bars.d), v=list(bars.v), a=list(bars.a), af=list(bars.af),
252
+ )
253
+ for name in ("o", "h", "l", "c"):
254
+ setattr(out, name,
255
+ apply_adjust(getattr(bars, name), bars.af, scale, af_latest, to))
256
+ return out
257
+
258
+
259
+ def _adjust_frame(df, to: str):
260
+ """DataFrame 版本:直接乘因子比例。
261
+
262
+ 约定 `af` 列是**已归一的因子**(即 `Bars.to_pandas()` 产出的形态:
263
+ 上市首日为 1.0),而不是盘上的定点整数。于是:
264
+
265
+ hfq: ratio = af (锚点 = 上市首日)
266
+ qfq: ratio = af / af_last (锚点 = 最后一个有值的因子)
267
+ """
268
+ if to == "none":
269
+ return df.copy()
270
+ if "af" not in df.columns:
271
+ raise NotSupported(
272
+ "DataFrame 缺少 'af' 列 —— 该市场没有官方复权因子,"
273
+ "无法给出可信的复权视图(SPEC.md §9.5)")
274
+ af = df["af"]
275
+ if af.isna().all():
276
+ raise NotSupported("'af' 列全为空,无法复权")
277
+ ratio = af if to == "hfq" else af / af.dropna().iloc[-1]
278
+ out = df.copy()
279
+ for col in ("open", "high", "low", "close"):
280
+ if col in out.columns:
281
+ out[col] = out[col] * ratio
282
+ return out
ohlcvault/adjust.py ADDED
@@ -0,0 +1,61 @@
1
+ """复权视图 = 纯函数(SPEC.md §9.2)。
2
+
3
+ **这是本数据集的第一原则的落地点:只存事实,不存视图。**
4
+
5
+ 复权价不是事实,是「相对于某个锚点重算出来的视图」。前复权的锚点是**最新价**,
6
+ 所以每次分红送转都会导致**全历史价格被重算** —— 若把前复权价存进静态文件,
7
+ 历史文件就会天天变,`immutable` 缓存全部失效,整套免费分发的收益归零。
8
+
9
+ 因此:盘上只存不复权原始价 + 官方后复权累计因子 `af`,
10
+ 前/后复权都在客户端由下面这两个纯函数算出来。
11
+
12
+ 后复权 hfq_i = c_i × af_i / af_scale (锚点 = 上市首日,af=1)
13
+ 前复权 qfq_i = c_i × af_i / af_latest (锚点 = 最后一个有数据的交易日)
14
+
15
+ **全部用整数运算**(`price_scale` 与 `af_scale` 都是整数标度)。
16
+ 引入浮点会让同一份数据在不同实现/架构上产生不同的末位,破坏可复现性 ——
17
+ 这与「数据集里不放浮点数」是同一条纪律。
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from .errors import NotSupported
22
+
23
+ ADJUSTMENTS = ("none", "hfq", "qfq")
24
+
25
+
26
+ def _div_round(num: int, den: int) -> int:
27
+ """整数除法,四舍五入(half-up)。只用整数,结果与平台无关。"""
28
+ return (2 * num + den) // (2 * den)
29
+
30
+
31
+ def adjust_factor(af: int, af_scale: int, af_latest: int | None, to: str) -> float:
32
+ """给定某日的因子,返回该日价格的乘数(浮点,仅供外部参考)。"""
33
+ if to == "hfq":
34
+ return af / af_scale
35
+ if to == "qfq":
36
+ if not af_latest:
37
+ raise NotSupported("qfq 需要 af_latest,但序列没有可用因子")
38
+ return af / af_latest
39
+ return 1.0
40
+
41
+
42
+ def apply_adjust(
43
+ values: list[int],
44
+ af: list[int],
45
+ af_scale: int,
46
+ af_latest: int | None,
47
+ to: str,
48
+ ) -> list[int]:
49
+ """把一组定点整数价格按因子换算成复权视图,仍返回同标度的定点整数。
50
+
51
+ `to="none"` 原样返回。`af` 与 `values` 必须等长(契约 §4.2 硬性约束 1)。
52
+ """
53
+ if to == "none":
54
+ return list(values)
55
+ if to == "hfq":
56
+ return [_div_round(v * f, af_scale) for v, f in zip(values, af)]
57
+ if to == "qfq":
58
+ if not af_latest:
59
+ raise NotSupported("qfq 需要 af_latest,但序列没有可用因子")
60
+ return [_div_round(v * f, af_latest) for v, f in zip(values, af)]
61
+ raise ValueError(f"未知复权类型 {to!r},合法值 {ADJUSTMENTS}")
ohlcvault/client.py ADDED
@@ -0,0 +1,203 @@
1
+ """传输层:多镜像读取 + sha256 校验 + 内容寻址本地缓存。
2
+
3
+ 这是「数据一定能用」的全部实现:
4
+
5
+ 1. 任意一个镜像可达 → 数据拿得到
6
+ 2. sha256 与快照清单不符 → 该镜像的这份数据被丢弃并降级到下一个
7
+ (**绝不把脏数据交给调用方** —— 这是校验存在的唯一理由)
8
+ 3. 全部镜像不可达 → 从本地缓存取(已拉过的数据永久可用,可离线复现)
9
+ 4. 缓存有效性由清单里的 sha256 决定,**不靠时间戳** —— 内容寻址的威力所在
10
+
11
+ 两种模式:
12
+
13
+ - floating(默认)读 `latest.json`,跟随最新快照
14
+ - pinned 指定快照 id,冻结历史状态,用于复现研究
15
+
16
+ 镜像可以是 http(s) URL,也可以是本地目录(便于离线测试)。
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import json
22
+ import threading
23
+ import urllib.request
24
+ from pathlib import Path
25
+
26
+ from .errors import HashMismatch, SnapshotUnavailable # noqa: F401 (对外保留此名)
27
+
28
+ CHUNK = 1 << 20
29
+
30
+
31
+ def sha256_bytes(b: bytes) -> str:
32
+ return hashlib.sha256(b).hexdigest()
33
+
34
+
35
+ def sha256_file(path: str | Path) -> str:
36
+ h = hashlib.sha256()
37
+ with open(path, "rb") as f:
38
+ for chunk in iter(lambda: f.read(CHUNK), b""):
39
+ h.update(chunk)
40
+ return h.hexdigest()
41
+
42
+
43
+ class MarketClient:
44
+ """读一个内容寻址的快照数据服务。"""
45
+
46
+ def __init__(
47
+ self,
48
+ mirrors: list[str],
49
+ cache_dir: str | Path,
50
+ snapshot: str | None = None,
51
+ timeout: int = 15,
52
+ ):
53
+ if not mirrors:
54
+ raise ValueError("至少需要一个镜像")
55
+ self.mirrors = [str(m).rstrip("/") for m in mirrors]
56
+ self.cache = Path(cache_dir)
57
+ self.cache.mkdir(parents=True, exist_ok=True)
58
+ self.pinned = snapshot
59
+ self.timeout = timeout
60
+ self.trace: list[str] = []
61
+ self.stats: dict = {
62
+ "net": 0, "cache_hit": 0, "hash_reject": 0, "mirror_fail": 0,
63
+ "served_by": {}, "offline": False,
64
+ }
65
+ self._mf: dict | None = None
66
+ self._sid: str | None = None
67
+ self._lock = threading.Lock()
68
+
69
+ # ---------- 底层 IO ----------
70
+
71
+ @staticmethod
72
+ def _label(m: str) -> str:
73
+ return m.rsplit("/", 2)[-2] if m.startswith("http") else Path(m).name
74
+
75
+ def _fetch_raw(self, m: str, rel: str) -> bytes:
76
+ if m.startswith("http"):
77
+ self.stats["net"] += 1
78
+ with urllib.request.urlopen(f"{m}/{rel}", timeout=self.timeout) as r:
79
+ return r.read()
80
+ p = Path(m) / rel
81
+ if not p.is_file():
82
+ raise FileNotFoundError(rel)
83
+ return p.read_bytes()
84
+
85
+ def _promote(self, m: str) -> None:
86
+ """故障转移记忆:成功过的镜像提到首选位,后续请求不再重复探测死镜像。"""
87
+ if self.mirrors and self.mirrors[0] != m:
88
+ self.mirrors.remove(m)
89
+ self.mirrors.insert(0, m)
90
+ self.trace.append(f" 亲和切换 → {self._label(m)} 提为首选镜像")
91
+
92
+ def _read(self, rel: str) -> bytes:
93
+ """按镜像顺序取一个相对路径,任一成功即返回。"""
94
+ for m in list(self.mirrors):
95
+ label = self._label(m)
96
+ try:
97
+ body = self._fetch_raw(m, rel)
98
+ except Exception as e:
99
+ self.stats["mirror_fail"] += 1
100
+ self.trace.append(f" 镜像 {label} 不可达({type(e).__name__})")
101
+ continue
102
+ self.stats["served_by"][label] = self.stats["served_by"].get(label, 0) + 1
103
+ self.trace.append(f" 镜像 {label} 命中 → {rel}")
104
+ self._promote(m)
105
+ return body
106
+ raise SnapshotUnavailable(f"全部镜像均不可达: {rel}")
107
+
108
+ def _manifest_cache_file(self) -> Path:
109
+ return self.cache / "_manifest.json"
110
+
111
+ # ---------- 快照解析 ----------
112
+
113
+ def manifest(self, refresh: bool = False) -> dict:
114
+ with self._lock:
115
+ if self._mf is not None and not refresh:
116
+ return self._mf
117
+
118
+ want = self.pinned
119
+ rel = f"snapshots/{want}/manifest.json" if want else "latest.json"
120
+ try:
121
+ idx = json.loads(self._read(rel))
122
+ sid = idx["snapshot"]
123
+ if rel != f"snapshots/{sid}/manifest.json":
124
+ mf = json.loads(self._read(f"snapshots/{sid}/manifest.json"))
125
+ else:
126
+ mf = idx
127
+ self._mf, self._sid = mf, sid
128
+ self._manifest_cache_file().write_bytes(json.dumps(mf).encode())
129
+ return mf
130
+ except SnapshotUnavailable:
131
+ cached = self._manifest_cache_file()
132
+ if cached.is_file():
133
+ mf = json.loads(cached.read_bytes())
134
+ self._mf, self._sid = mf, mf["snapshot"]
135
+ self.stats["offline"] = True
136
+ self.trace.append(
137
+ f" 全部镜像失败 → 使用本地缓存清单(快照 {self._sid})")
138
+ return mf
139
+ raise SnapshotUnavailable(
140
+ "无镜像可达,且本地无缓存清单 —— 首次使用需联网") from None
141
+
142
+ @property
143
+ def snapshot(self) -> str:
144
+ if self._mf is None:
145
+ self.manifest()
146
+ return self._sid # type: ignore[return-value]
147
+
148
+ def files(self) -> list[str]:
149
+ """本快照包含的全部相对路径(排序后)。"""
150
+ return sorted(self.manifest()["files"])
151
+
152
+ # ---------- 取数 ----------
153
+
154
+ def raw(self, rel: str, refresh: bool = False) -> bytes:
155
+ """取一个文件的**原始字节**(已通过 sha256 校验)。
156
+
157
+ 调用方若要自己解压/解析就用这个 —— 校验的对象始终是传输中的字节,
158
+ 不是解压后的明文。这个区别很重要:若分发端错误地设了
159
+ `Content-Encoding: gzip`,客户端拿到的就是被自动解压过的明文,
160
+ 哈希必然对不上,表现为「数据被篡改」的假警报(SPEC.md §3.3)。
161
+ """
162
+ mf = self.manifest(refresh=refresh)
163
+ meta = mf["files"].get(rel)
164
+ if meta is None:
165
+ raise KeyError(f"快照中不存在: {rel}")
166
+ want_hash = meta["sha256"]
167
+ cache_file = self.cache / self.snapshot / rel
168
+
169
+ # 1) 本地缓存 —— 有效性由 hash 决定,与时间无关
170
+ if cache_file.is_file() and sha256_file(cache_file) == want_hash:
171
+ self.stats["cache_hit"] += 1
172
+ self.trace.append(f" 本地缓存命中(hash 一致)→ {rel}")
173
+ return cache_file.read_bytes()
174
+
175
+ # 2) 逐镜像请求 + 逐镜像校验
176
+ for m in list(self.mirrors):
177
+ label = self._label(m)
178
+ try:
179
+ body = self._fetch_raw(m, rel)
180
+ except Exception as e:
181
+ self.stats["mirror_fail"] += 1
182
+ self.trace.append(f" 镜像 {label} 不可达({type(e).__name__})")
183
+ continue
184
+
185
+ if sha256_bytes(body) != want_hash:
186
+ self.stats["hash_reject"] += 1
187
+ self.trace.append(f" 镜像 {label} 内容与清单不符 → 拒绝,继续降级")
188
+ continue
189
+
190
+ self.stats["served_by"][label] = self.stats["served_by"].get(label, 0) + 1
191
+ self.trace.append(f" 镜像 {label} 取回并通过校验 → {rel}")
192
+ cache_file.parent.mkdir(parents=True, exist_ok=True)
193
+ cache_file.write_bytes(body)
194
+ self._promote(m)
195
+ return body
196
+
197
+ raise SnapshotUnavailable(
198
+ f"{rel}: 无可信来源(镜像全部不可达或校验失败,"
199
+ f"已拒绝 {self.stats['hash_reject']} 份脏数据)")
200
+
201
+ def get(self, rel: str, refresh: bool = False) -> dict:
202
+ """取一个明文 JSON 文件。"""
203
+ return json.loads(self.raw(rel, refresh=refresh))
ohlcvault/codes.py ADDED
@@ -0,0 +1,127 @@
1
+ """证券代码双轨规范(SPEC.md §2)。
2
+
3
+ | 场合 | 形态 |
4
+ |---|---|
5
+ | 存储层(文件、清单、路径) | 前缀式小写 `sh.600519` / `us.AAPL` |
6
+ | 对外 API(入参、返回值) | 后缀式大写 `600519.SH` / `AAPL.US` |
7
+
8
+ **模块名叫 `codes` 而不是 `symbols`**:包门面里有一个同名的
9
+ `ohlcvault.symbols(market=...)` 函数(返回标的清单),
10
+ 同名模块与同名函数在 `__init__` 里必然互相遮蔽,改掉模块名是最省事的解法。
11
+ 顶层已把常用函数直接导出(`ov.to_api` / `ov.to_storage` / `ov.normalize_symbol`),
12
+ 所以使用者通常不需要直接导入本模块。
13
+
14
+ **用户永远不需要记规则。** 本模块接受任意常见写法并归一化;但遇到
15
+ 真正有歧义的输入(如裸 `000001`,沪市是上证指数、深市是平安银行)
16
+ **必须报错而非猜测** —— 猜错一个交易所,拿回来的是一份看起来完全正常的
17
+ 错误数据,比报错危险得多。
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from .errors import SymbolError
22
+
23
+ # 交易所代码(SPEC.md §2.2)
24
+ EXCHANGES = ("sh", "sz", "bj", "hk", "us")
25
+
26
+ MARKET_EXCHANGES: dict[str, tuple[str, ...]] = {
27
+ "cn": ("sh", "sz", "bj"),
28
+ "hk": ("hk",),
29
+ "us": ("us",),
30
+ }
31
+
32
+ # 仅当 market 上下文已知时可推断交易所的市场(SPEC.md §2.4 的例外)
33
+ _CONTEXT_INFERABLE = {"hk": "hk", "us": "us"}
34
+
35
+
36
+ def to_api(storage: str) -> str:
37
+ """存储式 → API 式。`sh.600519` → `600519.SH`,`us.AAPL` → `AAPL.US`。"""
38
+ ex, code = storage.split(".", 1)
39
+ return f"{code.upper() if ex == 'us' else code}.{ex.upper()}"
40
+
41
+
42
+ def to_storage(api: str) -> str:
43
+ """API 式 → 存储式。`600519.SH` → `sh.600519`,`AAPL.US` → `us.AAPL`。
44
+
45
+ 美股代码在存储层是**大写**(SPEC.md §2.1),所以 `aapl.us` 也要归一成 `us.AAPL`。
46
+ """
47
+ code, ex = api.rsplit(".", 1)
48
+ exl = ex.lower()
49
+ if exl not in EXCHANGES:
50
+ raise SymbolError(f"无法识别的交易所后缀:{api!r}(合法值 {EXCHANGES})")
51
+ return f"{exl}.{code.upper() if exl == 'us' else code}"
52
+
53
+
54
+ def normalize(raw: str, market: str | None = None) -> str:
55
+ """把用户可能写出的任意形态归一化为**存储式**(SPEC.md §2.4)。
56
+
57
+ >>> normalize("600519.SH")
58
+ 'sh.600519'
59
+ >>> normalize("sh600519")
60
+ 'sh.600519'
61
+ >>> normalize("00700.HK")
62
+ 'hk.00700'
63
+ >>> normalize("AAPL")
64
+ Traceback (most recent call last):
65
+ ...
66
+ ohlcvault.errors.SymbolError: ...
67
+
68
+ `market` 只在 `hk` / `us` 下提供消歧能力(这两个市场各只有一家交易所);
69
+ 对 `cn` 提供 `market="cn"` 也**不会**让裸 `000001` 通过 —— 它分属沪深两市。
70
+ """
71
+ if not isinstance(raw, str):
72
+ raise SymbolError(f"symbol 必须是字符串,收到 {type(raw).__name__}")
73
+ s = raw.strip()
74
+ if not s:
75
+ raise SymbolError("空的 symbol")
76
+
77
+ low = s.lower()
78
+
79
+ # ① 前缀式:sh.600519 / sh600519
80
+ for ex in EXCHANGES:
81
+ if low.startswith(ex + "."):
82
+ return _canonical(ex, s[len(ex) + 1:], raw)
83
+ if low.startswith(ex) and "." not in s and low[len(ex):].isalnum():
84
+ return _canonical(ex, s[len(ex):], raw)
85
+
86
+ # ② 后缀式:600519.SH
87
+ if "." in s:
88
+ code, ex = s.rsplit(".", 1)
89
+ exl = ex.lower()
90
+ if exl in EXCHANGES:
91
+ return _canonical(exl, code, raw)
92
+ raise SymbolError(f"无法识别的交易所后缀:{raw!r}(合法值 {EXCHANGES})")
93
+
94
+ # ③ 裸代码:仅在市场上下文唯一确定交易所时才允许
95
+ infer = _CONTEXT_INFERABLE.get(market or "")
96
+ if infer:
97
+ return _canonical(infer, s, raw)
98
+
99
+ raise SymbolError(
100
+ f"symbol 缺少交易所信息:{raw!r}。请写成 '600519.SH' 或 'sh.600519'。"
101
+ f"(契约禁止猜测交易所 —— 000001 在沪市是上证指数、深市是平安银行)"
102
+ )
103
+
104
+
105
+ def _canonical(exchange: str, code: str, raw: str) -> str:
106
+ if not code:
107
+ raise SymbolError(f"symbol 缺少代码部分:{raw!r}")
108
+ # 港股代码必须零填充到 5 位(港交所规范,也是 akshare / yfinance 的共同要求)
109
+ if exchange == "hk":
110
+ code = code.zfill(5)
111
+ code = code.upper() if exchange == "us" else code
112
+ return f"{exchange}.{code}"
113
+
114
+
115
+ def split(storage: str) -> tuple[str, str]:
116
+ """存储式 → (交易所小写, 代码)。"""
117
+ ex, code = storage.split(".", 1)
118
+ return ex, code
119
+
120
+
121
+ def market_of(storage: str) -> str:
122
+ """存储式 → 市场代码。"""
123
+ ex = storage.split(".", 1)[0]
124
+ for mkt, exs in MARKET_EXCHANGES.items():
125
+ if ex in exs:
126
+ return mkt
127
+ raise SymbolError(f"未知交易所:{storage!r}")