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/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)