qlib-sync 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.
- qlib_sync/__init__.py +13 -0
- qlib_sync/api.py +67 -0
- qlib_sync/cli.py +81 -0
- qlib_sync/cn/__init__.py +1 -0
- qlib_sync/cn/market.py +381 -0
- qlib_sync/cn/update.py +609 -0
- qlib_sync/mcp_server.py +249 -0
- qlib_sync/txn.py +71 -0
- qlib_sync-0.1.0.dist-info/METADATA +219 -0
- qlib_sync-0.1.0.dist-info/RECORD +13 -0
- qlib_sync-0.1.0.dist-info/WHEEL +4 -0
- qlib_sync-0.1.0.dist-info/entry_points.txt +2 -0
- qlib_sync-0.1.0.dist-info/licenses/LICENSE +21 -0
qlib_sync/cn/update.py
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
"""Install or update Qlib CN daily data from the community release."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import sys
|
|
12
|
+
import tarfile
|
|
13
|
+
import time
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path, PurePosixPath
|
|
18
|
+
from typing import Callable
|
|
19
|
+
|
|
20
|
+
from ..txn import LockedError, exclusive_lock, replace_directory
|
|
21
|
+
|
|
22
|
+
DEFAULT_SOURCE_URL = "https://github.com/chenditc/investment_data/releases/latest/download/qlib_bin.tar.gz"
|
|
23
|
+
MANIFEST_NAME = "qlib_bin.manifest.json"
|
|
24
|
+
BACKUP_PREFIX = "cn_data_backup_before_latest_"
|
|
25
|
+
DOWNLOAD_NAME = "qlib_bin.tar.gz.part"
|
|
26
|
+
DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
27
|
+
CHUNK_SIZE = 1 << 20
|
|
28
|
+
USER_AGENT = "qlib-sync"
|
|
29
|
+
MIRROR_ENV = "QLIB_SYNC_MIRROR"
|
|
30
|
+
INSTALL_RECORD = ".qlib-sync.json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UpdateError(RuntimeError):
|
|
34
|
+
"""The update could not be completed; the active dataset is unchanged."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Manifest:
|
|
39
|
+
"""What the community release says about its package, read before downloading it."""
|
|
40
|
+
|
|
41
|
+
trade_date: str
|
|
42
|
+
sha256: str | None = None
|
|
43
|
+
size: int | None = None
|
|
44
|
+
future_start: str | None = None
|
|
45
|
+
future_end: str | None = None
|
|
46
|
+
# Upstream marks a release publishable by recording its build image digest; None means it
|
|
47
|
+
# recorded the field as empty, and absent means an older manifest format.
|
|
48
|
+
publishable: bool | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class UpdateOptions:
|
|
53
|
+
data_dir: Path
|
|
54
|
+
protected_dir: Path
|
|
55
|
+
source_url: str = DEFAULT_SOURCE_URL
|
|
56
|
+
mirror: str | None = None
|
|
57
|
+
protected: list[str] = field(default_factory=list)
|
|
58
|
+
refresh_protected: bool = False
|
|
59
|
+
keep_backup: bool = False
|
|
60
|
+
force: bool = False
|
|
61
|
+
download_cache_dir: Path | None = None
|
|
62
|
+
retries: int = 3
|
|
63
|
+
retry_delay: float = 3.0
|
|
64
|
+
timeout: int = 60
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class UpdateResult:
|
|
69
|
+
"""What an update did, for people (`summary_lines`) and for programs (`asdict`)."""
|
|
70
|
+
|
|
71
|
+
status: str # "installed", "updated", "republished", or "up_to_date"
|
|
72
|
+
latest_date: str
|
|
73
|
+
previous_date: str | None = None
|
|
74
|
+
new_trading_days: list[str] = field(default_factory=list)
|
|
75
|
+
added_symbols: list[str] = field(default_factory=list)
|
|
76
|
+
delisted_symbols: list[str] = field(default_factory=list)
|
|
77
|
+
backup: str | None = None
|
|
78
|
+
|
|
79
|
+
def summary_lines(self) -> list[str]:
|
|
80
|
+
if self.status == "up_to_date":
|
|
81
|
+
return [f"Already up to date: data ends at {self.latest_date}."]
|
|
82
|
+
if self.status == "installed":
|
|
83
|
+
return [f"Installed CN data through {self.latest_date}."]
|
|
84
|
+
if self.status == "republished":
|
|
85
|
+
return [f"Replaced CN data through {self.latest_date} with a corrected upstream release."]
|
|
86
|
+
days = self.new_trading_days
|
|
87
|
+
span = f"{days[0]} to {days[-1]}" if len(days) > 1 else (days[0] if days else "none")
|
|
88
|
+
lines = [
|
|
89
|
+
f"Updated CN data from {self.previous_date} to {self.latest_date}.",
|
|
90
|
+
f"New trading days: {len(days)} ({span})",
|
|
91
|
+
f"New symbols: {len(self.added_symbols)}{_preview(self.added_symbols)}",
|
|
92
|
+
f"Delisted symbols: {len(self.delisted_symbols)}{_preview(self.delisted_symbols)}",
|
|
93
|
+
]
|
|
94
|
+
if self.backup:
|
|
95
|
+
lines.append(f"Rollback backup kept at {self.backup}")
|
|
96
|
+
return lines
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _preview(symbols: list[str], limit: int = 8) -> str:
|
|
100
|
+
if not symbols:
|
|
101
|
+
return ""
|
|
102
|
+
more = f", +{len(symbols) - limit} more" if len(symbols) > limit else ""
|
|
103
|
+
return f" ({', '.join(symbols[:limit])}{more})"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _stamp() -> str:
|
|
107
|
+
return dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# Progress and diagnostics go to stderr so stdout stays clean for JSON output and
|
|
111
|
+
# for the MCP stdio transport.
|
|
112
|
+
def log(message: str) -> None:
|
|
113
|
+
print(f"[INFO] {_stamp()} {message}", file=sys.stderr, flush=True)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def warn(message: str) -> None:
|
|
117
|
+
print(f"[WARN] {_stamp()} {message}", file=sys.stderr, flush=True)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def tilde(path: Path | str) -> str:
|
|
121
|
+
"""Show paths under the home folder as ~/..., shorter and without the account name."""
|
|
122
|
+
text, home = str(path), str(Path.home())
|
|
123
|
+
return "~" + text[len(home):] if text == home or text.startswith(home + os.sep) else text
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def read_last_line(path: Path) -> str:
|
|
127
|
+
lines = [line.strip() for line in path.read_text().splitlines() if line.strip()]
|
|
128
|
+
return lines[-1] if lines else ""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def lock_dir_for(data_dir: Path) -> Path:
|
|
132
|
+
return data_dir.parent / ".cn_data_update.lock"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def read_install_record(data_dir: Path) -> dict:
|
|
136
|
+
"""The release this dataset was installed from, written by qlib-sync; {} if unknown."""
|
|
137
|
+
try:
|
|
138
|
+
return json.loads((data_dir / INSTALL_RECORD).read_text())
|
|
139
|
+
except (OSError, json.JSONDecodeError):
|
|
140
|
+
return {}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def write_install_record(data_dir: Path, trade_date: str, manifest: Manifest | None) -> None:
|
|
144
|
+
record = {
|
|
145
|
+
"trade_date": trade_date,
|
|
146
|
+
"archive_sha256": manifest.sha256 if manifest else None,
|
|
147
|
+
"installed_at": dt.datetime.now().astimezone().isoformat(timespec="seconds"),
|
|
148
|
+
}
|
|
149
|
+
(data_dir / INSTALL_RECORD).write_text(json.dumps(record, indent=2) + "\n")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def is_republished(manifest: Manifest, old_latest: str, record: dict) -> bool:
|
|
153
|
+
"""Upstream sometimes republishes the same trade date after fixing data; detect it by checksum."""
|
|
154
|
+
installed = record.get("archive_sha256")
|
|
155
|
+
return bool(
|
|
156
|
+
manifest.trade_date == old_latest
|
|
157
|
+
and manifest.sha256
|
|
158
|
+
and installed
|
|
159
|
+
and record.get("trade_date") == old_latest
|
|
160
|
+
and installed != manifest.sha256
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def resolve_mirror(mirror: str | None) -> str | None:
|
|
165
|
+
value = mirror if mirror is not None else os.environ.get(MIRROR_ENV, "")
|
|
166
|
+
return value.strip().rstrip("/") or None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def mirrored(url: str, mirror: str | None) -> str:
|
|
170
|
+
"""Route a URL through a prefix-style GitHub proxy, e.g. https://ghfast.top/<url>."""
|
|
171
|
+
return f"{mirror}/{url}" if mirror else url
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def manifest_url(source_url: str) -> str:
|
|
175
|
+
return f"{source_url.rsplit('/', 1)[0]}/{MANIFEST_NAME}"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def parse_manifest(payload: dict) -> Manifest:
|
|
179
|
+
trade_date = str(payload.get("target_trade_date") or "")
|
|
180
|
+
if not DATE_PATTERN.match(trade_date):
|
|
181
|
+
raise ValueError(f"target_trade_date is missing or invalid: {trade_date!r}")
|
|
182
|
+
digest = payload.get("archive_sha256")
|
|
183
|
+
if digest is not None:
|
|
184
|
+
digest = str(digest).removeprefix("sha256:").lower()
|
|
185
|
+
if not re.fullmatch(r"[0-9a-f]{64}", digest):
|
|
186
|
+
raise ValueError(f"archive_sha256 is not a sha256 digest: {payload.get('archive_sha256')!r}")
|
|
187
|
+
size = payload.get("archive_size_bytes")
|
|
188
|
+
future = {}
|
|
189
|
+
for key in ("future_start_date", "future_end_date"):
|
|
190
|
+
value = payload.get(key)
|
|
191
|
+
if value is not None and not DATE_PATTERN.match(str(value)):
|
|
192
|
+
raise ValueError(f"{key} is not a date: {value!r}")
|
|
193
|
+
future[key] = str(value) if value is not None else None
|
|
194
|
+
return Manifest(
|
|
195
|
+
trade_date=trade_date,
|
|
196
|
+
sha256=digest,
|
|
197
|
+
size=int(size) if size is not None else None,
|
|
198
|
+
future_start=future["future_start_date"],
|
|
199
|
+
future_end=future["future_end_date"],
|
|
200
|
+
publishable=(payload["image_digest"] is not None) if "image_digest" in payload else None,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# The files upstream's own validate_archive.py requires in a publishable release.
|
|
205
|
+
REQUIRED_POOLS = ("all", "csi300", "csi500", "csi800", "csi1000", "csiall")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def read_dates(path: Path) -> list[str]:
|
|
209
|
+
return [line.strip() for line in path.read_text().splitlines() if line.strip()]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def check_release_contents(stage_dir: Path, manifest: Manifest) -> None:
|
|
213
|
+
"""Apply the checks chenditc/investment_data's validate_archive.py applies to a release.
|
|
214
|
+
|
|
215
|
+
Every index pool must reach the target trade date, and the future calendar must extend
|
|
216
|
+
the trading calendar and match the manifest. Only used when the manifest is available,
|
|
217
|
+
since that is what pins down the upstream release format.
|
|
218
|
+
"""
|
|
219
|
+
if manifest.publishable is False:
|
|
220
|
+
raise UpdateError("the release manifest marks this package as not publishable; refusing to install it")
|
|
221
|
+
target = manifest.trade_date
|
|
222
|
+
for pool in REQUIRED_POOLS:
|
|
223
|
+
path = stage_dir / "instruments" / f"{pool}.txt"
|
|
224
|
+
if not path.is_file():
|
|
225
|
+
raise UpdateError(f"package is missing the {pool} index pool")
|
|
226
|
+
ends = [line.split("\t")[2] for line in path.read_text().splitlines() if line.count("\t") == 2]
|
|
227
|
+
if not ends or max(ends) != target:
|
|
228
|
+
raise UpdateError(f"the {pool} pool ends at {max(ends) if ends else 'nothing'}, not at {target}")
|
|
229
|
+
future_path = stage_dir / "calendars" / "day_future.txt"
|
|
230
|
+
if not future_path.is_file():
|
|
231
|
+
raise UpdateError("package is missing calendars/day_future.txt")
|
|
232
|
+
day, future = read_dates(stage_dir / "calendars" / "day.txt"), read_dates(future_path)
|
|
233
|
+
if len(future) <= len(day) or future[: len(day)] != day:
|
|
234
|
+
raise UpdateError("the future calendar does not extend the trading calendar")
|
|
235
|
+
if (manifest.future_start and future[len(day)] != manifest.future_start) or (
|
|
236
|
+
manifest.future_end and future[-1] != manifest.future_end
|
|
237
|
+
):
|
|
238
|
+
raise UpdateError("the future calendar does not match the manifest")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _read_manifest(url: str, timeout: int) -> Manifest:
|
|
242
|
+
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
243
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
244
|
+
return parse_manifest(json.load(response))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def fetch_manifest(source_url: str, timeout: int = 15, mirror: str | None = None) -> Manifest | None:
|
|
248
|
+
"""Return the release manifest, or None (with a warning) when it cannot be used.
|
|
249
|
+
|
|
250
|
+
The manifest is read from the source directly when possible, so its checksum stays
|
|
251
|
+
independent of any mirror that serves the package. It is an optimization and an
|
|
252
|
+
integrity check, not a requirement: when it is missing or its format changes, the
|
|
253
|
+
update falls back to validating the package itself after download.
|
|
254
|
+
"""
|
|
255
|
+
url = manifest_url(source_url)
|
|
256
|
+
errors = (OSError, urllib.error.URLError, json.JSONDecodeError, ValueError)
|
|
257
|
+
try:
|
|
258
|
+
return _read_manifest(url, min(timeout, 10) if mirror else timeout)
|
|
259
|
+
except errors as exc:
|
|
260
|
+
if not mirror:
|
|
261
|
+
warn(f"release manifest unavailable ({exc}); the package will be validated after download")
|
|
262
|
+
return None
|
|
263
|
+
direct_error = exc
|
|
264
|
+
try:
|
|
265
|
+
manifest = _read_manifest(mirrored(url, mirror), timeout)
|
|
266
|
+
except errors as exc:
|
|
267
|
+
warn(f"release manifest unavailable directly ({direct_error}) or via the mirror ({exc}); "
|
|
268
|
+
"the package will be validated after download")
|
|
269
|
+
return None
|
|
270
|
+
warn("release manifest was read through the mirror; its checksum is only as trustworthy as the mirror")
|
|
271
|
+
return manifest
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def qlib_calendar_smoke_test(data_dir: Path) -> None:
|
|
275
|
+
# Keep Qlib imports lazy so the module stays importable without initializing Qlib.
|
|
276
|
+
# pylint: disable=import-outside-toplevel
|
|
277
|
+
import qlib
|
|
278
|
+
from qlib.config import REG_CN
|
|
279
|
+
from qlib.data import D
|
|
280
|
+
|
|
281
|
+
qlib.init(provider_uri=str(data_dir), region=REG_CN, logging_level="ERROR")
|
|
282
|
+
if len(D.calendar(start_time="2020-01-01", end_time=None, freq="day")) == 0:
|
|
283
|
+
raise RuntimeError("Qlib calendar is empty")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class _Progress:
|
|
287
|
+
"""Report download progress: a live line on a terminal, quarter milestones otherwise."""
|
|
288
|
+
|
|
289
|
+
def __init__(self, total: int | None, done: int):
|
|
290
|
+
self.total = total
|
|
291
|
+
self.done = done
|
|
292
|
+
self.start_done = done
|
|
293
|
+
self.started = time.monotonic()
|
|
294
|
+
self.last_draw = 0.0
|
|
295
|
+
self.next_milestone = 25
|
|
296
|
+
self.live = sys.stderr.isatty()
|
|
297
|
+
|
|
298
|
+
def advance(self, size: int) -> None:
|
|
299
|
+
self.done += size
|
|
300
|
+
now = time.monotonic()
|
|
301
|
+
if self.live and now - self.last_draw >= 0.25:
|
|
302
|
+
self.last_draw = now
|
|
303
|
+
sys.stderr.write(f"\r {self._line(now)} ")
|
|
304
|
+
sys.stderr.flush()
|
|
305
|
+
elif not self.live and self.total:
|
|
306
|
+
percent = self.done * 100 // self.total
|
|
307
|
+
if percent >= self.next_milestone and self.next_milestone < 100:
|
|
308
|
+
log(f"downloaded {self._line(now)}")
|
|
309
|
+
self.next_milestone = (percent // 25 + 1) * 25
|
|
310
|
+
|
|
311
|
+
def finish(self) -> None:
|
|
312
|
+
now = time.monotonic()
|
|
313
|
+
if self.live:
|
|
314
|
+
sys.stderr.write(f"\r {self._line(now)} \n")
|
|
315
|
+
sys.stderr.flush()
|
|
316
|
+
log(f"download finished: {_mib(self.done)} in {now - self.started:.0f}s")
|
|
317
|
+
|
|
318
|
+
def _line(self, now: float) -> str:
|
|
319
|
+
elapsed = max(now - self.started, 1e-6)
|
|
320
|
+
speed = (self.done - self.start_done) / elapsed
|
|
321
|
+
if not self.total:
|
|
322
|
+
return f"{_mib(self.done)} at {_mib(speed)}/s"
|
|
323
|
+
percent = self.done * 100 / self.total
|
|
324
|
+
eta = (self.total - self.done) / speed if speed > 0 else 0
|
|
325
|
+
return f"{_mib(self.done)} / {_mib(self.total)} ({percent:.0f}%) at {_mib(speed)}/s, {eta:.0f}s left"
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _mib(size: float) -> str:
|
|
329
|
+
return f"{size / (1 << 20):.1f} MiB"
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _download_once(url: str, target: Path, timeout: int, expected_size: int | None) -> None:
|
|
333
|
+
offset = target.stat().st_size if target.exists() else 0
|
|
334
|
+
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
335
|
+
if offset and url.startswith(("http://", "https://")):
|
|
336
|
+
request.add_header("Range", f"bytes={offset}-")
|
|
337
|
+
else:
|
|
338
|
+
offset = 0
|
|
339
|
+
try:
|
|
340
|
+
response = urllib.request.urlopen(request, timeout=timeout)
|
|
341
|
+
except urllib.error.HTTPError as exc:
|
|
342
|
+
if exc.code == 416 and offset:
|
|
343
|
+
# The cached part no longer matches the remote package; start over.
|
|
344
|
+
target.unlink()
|
|
345
|
+
return _download_once(url, target, timeout, expected_size)
|
|
346
|
+
raise
|
|
347
|
+
with response:
|
|
348
|
+
resumed = bool(offset) and getattr(response, "status", None) == 206
|
|
349
|
+
length = response.headers.get("Content-Length")
|
|
350
|
+
start = offset if resumed else 0
|
|
351
|
+
total = expected_size or (start + int(length) if length and length.isdigit() else None)
|
|
352
|
+
progress = _Progress(total, start)
|
|
353
|
+
with target.open("ab" if resumed else "wb") as handle:
|
|
354
|
+
for chunk in iter(lambda: response.read(CHUNK_SIZE), b""):
|
|
355
|
+
handle.write(chunk)
|
|
356
|
+
progress.advance(len(chunk))
|
|
357
|
+
progress.finish()
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def prepare_download_cache(target: Path, manifest: Manifest | None) -> None:
|
|
361
|
+
"""Discard a cached partial download that belongs to a different package."""
|
|
362
|
+
marker = target.with_name(f"{target.name}.sha256")
|
|
363
|
+
expected = manifest.sha256 if manifest else None
|
|
364
|
+
if target.exists() and expected and (not marker.is_file() or marker.read_text().strip() != expected):
|
|
365
|
+
log("discarding a cached partial download from a different package")
|
|
366
|
+
target.unlink()
|
|
367
|
+
if expected:
|
|
368
|
+
marker.write_text(expected)
|
|
369
|
+
else:
|
|
370
|
+
marker.unlink(missing_ok=True)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def clear_download_cache(target: Path) -> None:
|
|
374
|
+
target.unlink(missing_ok=True)
|
|
375
|
+
target.with_name(f"{target.name}.sha256").unlink(missing_ok=True)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def download_package(options: UpdateOptions, target: Path, manifest: Manifest | None) -> None:
|
|
379
|
+
if manifest and manifest.size and target.exists() and target.stat().st_size == manifest.size:
|
|
380
|
+
log("cached package is complete; skipping download")
|
|
381
|
+
return
|
|
382
|
+
if target.exists():
|
|
383
|
+
log(f"resuming incomplete package download: {tilde(target)}")
|
|
384
|
+
url = mirrored(options.source_url, resolve_mirror(options.mirror))
|
|
385
|
+
if url != options.source_url:
|
|
386
|
+
log(f"downloading through mirror: {url}")
|
|
387
|
+
last_error: Exception | None = None
|
|
388
|
+
for attempt in range(1, options.retries + 1):
|
|
389
|
+
try:
|
|
390
|
+
_download_once(url, target, options.timeout, manifest.size if manifest else None)
|
|
391
|
+
return
|
|
392
|
+
except (OSError, urllib.error.URLError) as exc:
|
|
393
|
+
last_error = exc
|
|
394
|
+
warn(f"download attempt {attempt}/{options.retries} failed: {exc}")
|
|
395
|
+
if attempt < options.retries:
|
|
396
|
+
time.sleep(options.retry_delay)
|
|
397
|
+
hint = "" if resolve_mirror(options.mirror) else f"; behind a slow connection to GitHub, try --mirror or {MIRROR_ENV}"
|
|
398
|
+
raise UpdateError(f"could not download the community package: {last_error}{hint}")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def verify_package(target: Path, manifest: Manifest | None) -> None:
|
|
402
|
+
if manifest is None:
|
|
403
|
+
return
|
|
404
|
+
if manifest.size is not None and target.stat().st_size != manifest.size:
|
|
405
|
+
actual = target.stat().st_size
|
|
406
|
+
clear_download_cache(target)
|
|
407
|
+
raise UpdateError(f"package size {actual} does not match the manifest ({manifest.size})")
|
|
408
|
+
if manifest.sha256 is not None:
|
|
409
|
+
digest = hashlib.sha256()
|
|
410
|
+
with target.open("rb") as handle:
|
|
411
|
+
for chunk in iter(lambda: handle.read(CHUNK_SIZE), b""):
|
|
412
|
+
digest.update(chunk)
|
|
413
|
+
if digest.hexdigest() != manifest.sha256:
|
|
414
|
+
clear_download_cache(target)
|
|
415
|
+
raise UpdateError("package sha256 does not match the release manifest; the download was discarded")
|
|
416
|
+
log("package sha256 matches the release manifest")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def extract_package(archive: Path, extract_dir: Path) -> Path:
|
|
420
|
+
"""Extract the archive after rejecting unsafe members; return the staged Qlib data directory."""
|
|
421
|
+
try:
|
|
422
|
+
with tarfile.open(archive, "r:gz") as handle:
|
|
423
|
+
for member in handle.getmembers():
|
|
424
|
+
path = PurePosixPath(member.name)
|
|
425
|
+
if path.is_absolute() or ".." in path.parts:
|
|
426
|
+
raise UpdateError(f"unsafe archive member path: {member.name}")
|
|
427
|
+
if member.issym() or member.islnk():
|
|
428
|
+
raise UpdateError(f"archive links are not permitted: {member.name}")
|
|
429
|
+
# The member checks above already enforce this; the filter adds a second
|
|
430
|
+
# layer on Python versions that provide it.
|
|
431
|
+
extra = {"filter": "data"} if hasattr(tarfile, "data_filter") else {}
|
|
432
|
+
handle.extractall(extract_dir, **extra)
|
|
433
|
+
except (tarfile.TarError, EOFError) as exc:
|
|
434
|
+
# A corrupt or mismatched download must not be resumed on the next run.
|
|
435
|
+
clear_download_cache(archive)
|
|
436
|
+
raise UpdateError(f"downloaded archive is not a valid package: {exc}") from exc
|
|
437
|
+
calendar_file = next((path for path in sorted(extract_dir.rglob("calendars/day.txt")) if path.is_file()), None)
|
|
438
|
+
if calendar_file is None:
|
|
439
|
+
raise UpdateError("downloaded archive does not contain Qlib daily calendar data")
|
|
440
|
+
stage_dir = calendar_file.parent.parent
|
|
441
|
+
if not (stage_dir / "features").is_dir() or not (stage_dir / "instruments").is_dir():
|
|
442
|
+
raise UpdateError("downloaded archive does not contain a complete Qlib data directory")
|
|
443
|
+
return stage_dir
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def read_listing_ends(data_dir: Path) -> dict[str, str]:
|
|
447
|
+
"""Map each symbol in `instruments/all.txt` to the last date it is listed."""
|
|
448
|
+
path = data_dir / "instruments" / "all.txt"
|
|
449
|
+
if not path.is_file():
|
|
450
|
+
return {}
|
|
451
|
+
ends: dict[str, str] = {}
|
|
452
|
+
for line in path.read_text().splitlines():
|
|
453
|
+
parts = line.split()
|
|
454
|
+
if len(parts) >= 3:
|
|
455
|
+
ends[parts[0]] = max(ends.get(parts[0], ""), parts[2])
|
|
456
|
+
return ends
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def describe_change(old_dir: Path, old_latest: str, stage_dir: Path, new_latest: str) -> UpdateResult:
|
|
460
|
+
"""Compare the active dataset with the staged one before they are swapped."""
|
|
461
|
+
calendar = [line.strip() for line in (stage_dir / "calendars" / "day.txt").read_text().splitlines()]
|
|
462
|
+
new_days = [day for day in calendar if day and day > old_latest]
|
|
463
|
+
old_ends = read_listing_ends(old_dir)
|
|
464
|
+
new_ends = read_listing_ends(stage_dir)
|
|
465
|
+
added = sorted(set(new_ends) - set(old_ends))
|
|
466
|
+
# Listed through the old latest date, but no longer listed through the new one.
|
|
467
|
+
delisted = sorted(
|
|
468
|
+
symbol for symbol, end in new_ends.items() if old_ends.get(symbol, "") >= old_latest and end < new_latest
|
|
469
|
+
)
|
|
470
|
+
return UpdateResult(
|
|
471
|
+
status="updated",
|
|
472
|
+
latest_date=new_latest,
|
|
473
|
+
previous_date=old_latest,
|
|
474
|
+
new_trading_days=new_days,
|
|
475
|
+
added_symbols=added,
|
|
476
|
+
delisted_symbols=delisted,
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def apply_protected_instruments(options: UpdateOptions, stage_dir: Path) -> None:
|
|
481
|
+
for instrument in options.protected:
|
|
482
|
+
if "/" in instrument or instrument.startswith("."):
|
|
483
|
+
raise UpdateError(f"protected instrument must be a plain filename: {instrument}")
|
|
484
|
+
protected_file = options.protected_dir / instrument
|
|
485
|
+
active_file = options.data_dir / "instruments" / instrument
|
|
486
|
+
if active_file.is_file() and (not protected_file.is_file() or options.refresh_protected):
|
|
487
|
+
options.protected_dir.mkdir(parents=True, exist_ok=True)
|
|
488
|
+
shutil.copy2(active_file, protected_file)
|
|
489
|
+
log(f"saved protected instrument copy: {tilde(protected_file)}")
|
|
490
|
+
if protected_file.is_file():
|
|
491
|
+
shutil.copy2(protected_file, stage_dir / "instruments" / instrument)
|
|
492
|
+
log(f"restoring protected instrument: {instrument}")
|
|
493
|
+
else:
|
|
494
|
+
log(f"protected instrument not found; skipping restoration: {instrument}")
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def remove_backups(base_dir: Path, keep: Path | None, label: str) -> None:
|
|
498
|
+
for backup in sorted(base_dir.glob(f"{BACKUP_PREFIX}*")):
|
|
499
|
+
if backup.is_dir() and backup != keep:
|
|
500
|
+
log(f"removing {label} rollback backup: {tilde(backup)}")
|
|
501
|
+
shutil.rmtree(backup)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def run_update(
|
|
505
|
+
options: UpdateOptions, smoke_test: Callable[[Path], None] = qlib_calendar_smoke_test
|
|
506
|
+
) -> UpdateResult:
|
|
507
|
+
"""Install or update the dataset and describe what changed.
|
|
508
|
+
|
|
509
|
+
The release manifest is read first so an up-to-date dataset costs one small request.
|
|
510
|
+
The package is verified and validated in a staging directory, then swapped in with
|
|
511
|
+
`replace_directory`, which restores the previous dataset if the smoke test fails.
|
|
512
|
+
"""
|
|
513
|
+
options.data_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
514
|
+
try:
|
|
515
|
+
with exclusive_lock(lock_dir_for(options.data_dir)):
|
|
516
|
+
return _run_update_locked(options, smoke_test)
|
|
517
|
+
except LockedError as exc:
|
|
518
|
+
raise UpdateError(str(exc)) from exc
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _run_update_locked(options: UpdateOptions, smoke_test: Callable[[Path], None]) -> UpdateResult:
|
|
522
|
+
data_dir = options.data_dir
|
|
523
|
+
base_dir = data_dir.parent
|
|
524
|
+
stamp = dt.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
|
525
|
+
work_dir = base_dir / f".cn_data_update_{stamp}"
|
|
526
|
+
backup_dir = base_dir / f"{BACKUP_PREFIX}{stamp}"
|
|
527
|
+
download_file = (options.download_cache_dir or base_dir / "downloads") / DOWNLOAD_NAME
|
|
528
|
+
mirror = resolve_mirror(options.mirror)
|
|
529
|
+
|
|
530
|
+
try:
|
|
531
|
+
calendar = data_dir / "calendars" / "day.txt"
|
|
532
|
+
if calendar.is_file():
|
|
533
|
+
old_latest = read_last_line(calendar)
|
|
534
|
+
log(f"starting CN data update; active_dir={tilde(data_dir)} active_latest={old_latest}")
|
|
535
|
+
elif data_dir.exists() and any(data_dir.iterdir()):
|
|
536
|
+
raise UpdateError(f"{data_dir} exists but is not a Qlib daily dataset; refusing to replace it")
|
|
537
|
+
else:
|
|
538
|
+
old_latest = None
|
|
539
|
+
log(f"no existing CN data at {tilde(data_dir)}; installing from the community release")
|
|
540
|
+
|
|
541
|
+
manifest = fetch_manifest(options.source_url, options.timeout, mirror)
|
|
542
|
+
republished = False
|
|
543
|
+
if manifest:
|
|
544
|
+
log(f"release manifest: trade_date={manifest.trade_date}")
|
|
545
|
+
if manifest.publishable is False:
|
|
546
|
+
raise UpdateError("the release manifest marks this package as not publishable; refusing to install it")
|
|
547
|
+
republished = bool(old_latest) and is_republished(manifest, old_latest, read_install_record(data_dir))
|
|
548
|
+
if republished:
|
|
549
|
+
log(f"the {old_latest} release was republished upstream (new checksum); replacing local data")
|
|
550
|
+
elif old_latest and manifest.trade_date <= old_latest and not options.force:
|
|
551
|
+
log(f"already up to date; local data ends at {old_latest}, release ends at {manifest.trade_date}")
|
|
552
|
+
return UpdateResult(status="up_to_date", latest_date=old_latest, previous_date=old_latest)
|
|
553
|
+
|
|
554
|
+
log("downloading community package")
|
|
555
|
+
download_file.parent.mkdir(parents=True, exist_ok=True)
|
|
556
|
+
prepare_download_cache(download_file, manifest)
|
|
557
|
+
download_package(options, download_file, manifest)
|
|
558
|
+
verify_package(download_file, manifest)
|
|
559
|
+
|
|
560
|
+
log("extracting and validating package")
|
|
561
|
+
extract_dir = work_dir / "extract"
|
|
562
|
+
extract_dir.mkdir(parents=True)
|
|
563
|
+
stage_dir = extract_package(download_file, extract_dir)
|
|
564
|
+
new_latest = read_last_line(stage_dir / "calendars" / "day.txt")
|
|
565
|
+
if not DATE_PATTERN.match(new_latest):
|
|
566
|
+
raise UpdateError(f"downloaded calendar has an invalid latest date: {new_latest}")
|
|
567
|
+
if manifest and new_latest != manifest.trade_date:
|
|
568
|
+
raise UpdateError(f"package calendar ends at {new_latest}, but the manifest says {manifest.trade_date}")
|
|
569
|
+
if manifest:
|
|
570
|
+
check_release_contents(stage_dir, manifest)
|
|
571
|
+
if old_latest and new_latest < old_latest:
|
|
572
|
+
raise UpdateError(f"refusing downgrade: package ends at {new_latest}, active data ends at {old_latest}")
|
|
573
|
+
if old_latest and new_latest == old_latest and not (options.force or republished):
|
|
574
|
+
log(f"package ends at {new_latest}, same as active data; no replacement needed")
|
|
575
|
+
clear_download_cache(download_file)
|
|
576
|
+
return UpdateResult(status="up_to_date", latest_date=old_latest, previous_date=old_latest)
|
|
577
|
+
log(f"validated package; active_latest={old_latest or 'none'} package_latest={new_latest}")
|
|
578
|
+
|
|
579
|
+
if old_latest:
|
|
580
|
+
result = describe_change(data_dir, old_latest, stage_dir, new_latest)
|
|
581
|
+
if republished:
|
|
582
|
+
result.status = "republished"
|
|
583
|
+
else:
|
|
584
|
+
result = UpdateResult(status="installed", latest_date=new_latest)
|
|
585
|
+
apply_protected_instruments(options, stage_dir)
|
|
586
|
+
write_install_record(stage_dir, new_latest, manifest)
|
|
587
|
+
|
|
588
|
+
log("switching data directories and running a Qlib read smoke test")
|
|
589
|
+
try:
|
|
590
|
+
previous = replace_directory(data_dir, stage_dir, backup_dir, verify=smoke_test)
|
|
591
|
+
except BaseException:
|
|
592
|
+
log("update did not complete; the previous data directory was restored")
|
|
593
|
+
raise
|
|
594
|
+
|
|
595
|
+
if previous is None:
|
|
596
|
+
log(f"install complete; latest_date={new_latest}")
|
|
597
|
+
elif options.keep_backup:
|
|
598
|
+
remove_backups(base_dir, keep=previous, label="superseded")
|
|
599
|
+
result.backup = str(previous)
|
|
600
|
+
log(f"update complete; latest_date={new_latest} rollback_backup={tilde(previous)}")
|
|
601
|
+
else:
|
|
602
|
+
log(f"removing validated previous data directory: {tilde(previous)}")
|
|
603
|
+
shutil.rmtree(previous)
|
|
604
|
+
remove_backups(base_dir, keep=None, label="legacy")
|
|
605
|
+
log(f"update complete; latest_date={new_latest} rollback_backup=none")
|
|
606
|
+
clear_download_cache(download_file)
|
|
607
|
+
return result
|
|
608
|
+
finally:
|
|
609
|
+
shutil.rmtree(work_dir, ignore_errors=True)
|