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/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Safely install and update a local Qlib China A-share dataset."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
# Recent mlflow (a Qlib dependency) logs an AI-agent hint on import; it is noise for our users.
|
|
6
|
+
os.environ.setdefault("MLFLOW_DISABLE_AGENT_HINT", "1")
|
|
7
|
+
|
|
8
|
+
from .api import health_check_cn, status_cn, update_cn
|
|
9
|
+
from .cn.update import UpdateError, UpdateResult
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
__all__ = ["UpdateError", "UpdateResult", "health_check_cn", "status_cn", "update_cn", "__version__"]
|
qlib_sync/api.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Python interface for notebooks and scripts.
|
|
2
|
+
|
|
3
|
+
>>> from qlib_sync import update_cn, status_cn, health_check_cn
|
|
4
|
+
>>> result = update_cn()
|
|
5
|
+
>>> print("\\n".join(result.summary_lines()))
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Iterable
|
|
12
|
+
|
|
13
|
+
from .cn import market
|
|
14
|
+
from .cn.update import DEFAULT_SOURCE_URL, UpdateOptions, UpdateResult, run_update
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolve(path: str | Path | None, default: Path) -> Path:
|
|
18
|
+
return Path(path).expanduser().resolve() if path is not None else default
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def update_cn(
|
|
22
|
+
data_dir: str | Path | None = None,
|
|
23
|
+
*,
|
|
24
|
+
mirror: str | None = None,
|
|
25
|
+
protect: Iterable[str] = (),
|
|
26
|
+
keep_backup: bool = False,
|
|
27
|
+
force: bool = False,
|
|
28
|
+
protected_dir: str | Path | None = None,
|
|
29
|
+
source_url: str = DEFAULT_SOURCE_URL,
|
|
30
|
+
) -> UpdateResult:
|
|
31
|
+
"""Install or update the A-share dataset (default `~/.qlib/qlib_data/cn_data`).
|
|
32
|
+
|
|
33
|
+
Raises `qlib_sync.UpdateError` when the update cannot complete; the existing
|
|
34
|
+
dataset is left unchanged in that case.
|
|
35
|
+
"""
|
|
36
|
+
options = UpdateOptions(
|
|
37
|
+
data_dir=_resolve(data_dir, market.DEFAULT_DATA_DIR),
|
|
38
|
+
protected_dir=_resolve(protected_dir, market.DEFAULT_PROTECTED_DIR),
|
|
39
|
+
source_url=source_url,
|
|
40
|
+
mirror=mirror,
|
|
41
|
+
protected=list(protect),
|
|
42
|
+
keep_backup=keep_backup,
|
|
43
|
+
force=force,
|
|
44
|
+
)
|
|
45
|
+
return run_update(options)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def status_cn(
|
|
49
|
+
data_dir: str | Path | None = None,
|
|
50
|
+
*,
|
|
51
|
+
offline: bool = False,
|
|
52
|
+
mirror: str | None = None,
|
|
53
|
+
timeout: int = 15,
|
|
54
|
+
) -> dict:
|
|
55
|
+
"""Local latest date, size, pools, backups, and (unless offline) the remote release."""
|
|
56
|
+
return market.collect_status(
|
|
57
|
+
_resolve(data_dir, market.DEFAULT_DATA_DIR),
|
|
58
|
+
market.DEFAULT_PROTECTED_DIR,
|
|
59
|
+
offline,
|
|
60
|
+
timeout,
|
|
61
|
+
mirror=mirror,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def health_check_cn(data_dir: str | Path | None = None, *, skip_qlib: bool = False) -> dict:
|
|
66
|
+
"""Structure checks plus a real Qlib read; returns {"ok", "errors", "checks"}."""
|
|
67
|
+
return market.health_check(_resolve(data_dir, market.DEFAULT_DATA_DIR), market.DEFAULT_PROTECTED_DIR, skip_qlib)
|
qlib_sync/cli.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""One command-line entry point for the local Qlib datasets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .cn.update import tilde
|
|
10
|
+
|
|
11
|
+
DEFAULT_QLIB_DATA_DIR = Path("~/.qlib/qlib_data").expanduser()
|
|
12
|
+
|
|
13
|
+
USAGE = """usage: qlib-sync <command> [options]
|
|
14
|
+
|
|
15
|
+
commands:
|
|
16
|
+
status Latest date and size of every local dataset
|
|
17
|
+
cn <subcommand> China A-share daily data: status, update, health-check, restore
|
|
18
|
+
mcp Serve the A-share data to AI assistants over MCP (stdio)
|
|
19
|
+
|
|
20
|
+
Run `qlib-sync cn --help` for details.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def read_last_line(path: Path) -> str | None:
|
|
25
|
+
if not path.is_file():
|
|
26
|
+
return None
|
|
27
|
+
lines = [line.strip() for line in path.read_text().splitlines() if line.strip()]
|
|
28
|
+
return lines[-1] if lines else None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def dataset_overview(base_dir: Path = DEFAULT_QLIB_DATA_DIR) -> list[dict[str, str]]:
|
|
32
|
+
rows = []
|
|
33
|
+
for dataset in sorted(path for path in base_dir.iterdir() if path.is_dir()) if base_dir.is_dir() else []:
|
|
34
|
+
# Qlib keeps planned trading days in `*_future.txt`; they are not data.
|
|
35
|
+
calendars = sorted(p for p in (dataset / "calendars").glob("*.txt") if not p.stem.endswith("_future"))
|
|
36
|
+
if not calendars:
|
|
37
|
+
continue
|
|
38
|
+
latest = max(filter(None, (read_last_line(path) for path in calendars)), default="missing")
|
|
39
|
+
freqs = ",".join(path.stem for path in calendars)
|
|
40
|
+
rows.append({"name": dataset.name, "freq": freqs, "latest": latest})
|
|
41
|
+
return rows
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def print_overview(base_dir: Path = DEFAULT_QLIB_DATA_DIR) -> None:
|
|
45
|
+
rows = dataset_overview(base_dir)
|
|
46
|
+
if not rows:
|
|
47
|
+
print(f"no Qlib datasets found under {tilde(base_dir)}")
|
|
48
|
+
return
|
|
49
|
+
width = max(len(row["name"]) for row in rows)
|
|
50
|
+
print(f"Qlib datasets under {tilde(base_dir)}")
|
|
51
|
+
for row in rows:
|
|
52
|
+
print(f" {row['name']:<{width}} latest={row['latest']} freq={row['freq']}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def run_module_main(module_name: str, prog: str, argv: list[str]) -> None:
|
|
56
|
+
# The dataset modules own their argparse parsers; hand them the remaining arguments.
|
|
57
|
+
sys.argv = [prog, *argv]
|
|
58
|
+
importlib.import_module(module_name).main()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main(argv: list[str] | None = None) -> None:
|
|
62
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
63
|
+
if not argv or argv[0] in ("-h", "--help"):
|
|
64
|
+
print(USAGE)
|
|
65
|
+
return
|
|
66
|
+
command, rest = argv[0], argv[1:]
|
|
67
|
+
if command == "status":
|
|
68
|
+
print_overview()
|
|
69
|
+
elif command == "cn":
|
|
70
|
+
run_module_main("qlib_sync.cn.market", "qlib-sync cn", rest)
|
|
71
|
+
elif command == "mcp":
|
|
72
|
+
from .mcp_server import main as mcp_main # pylint: disable=import-outside-toplevel
|
|
73
|
+
|
|
74
|
+
mcp_main(rest)
|
|
75
|
+
else:
|
|
76
|
+
print(USAGE, file=sys.stderr)
|
|
77
|
+
raise SystemExit(2)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
main()
|
qlib_sync/cn/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""China A-share daily dataset operations."""
|
qlib_sync/cn/market.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""Operate the local Qlib China market dataset through one safe command-line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import dataclasses
|
|
7
|
+
import datetime as dt
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ..txn import exclusive_lock, replace_directory
|
|
13
|
+
from .update import (
|
|
14
|
+
BACKUP_PREFIX,
|
|
15
|
+
DEFAULT_SOURCE_URL,
|
|
16
|
+
MIRROR_ENV,
|
|
17
|
+
UpdateError,
|
|
18
|
+
UpdateOptions,
|
|
19
|
+
fetch_manifest,
|
|
20
|
+
lock_dir_for,
|
|
21
|
+
resolve_mirror,
|
|
22
|
+
run_update as run_package_update,
|
|
23
|
+
tilde,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
DEFAULT_DATA_DIR = Path("~/.qlib/qlib_data/cn_data").expanduser()
|
|
27
|
+
DEFAULT_PROTECTED_DIR = Path("~/.qlib/qlib_data/custom_instruments/cn_data").expanduser()
|
|
28
|
+
DEFAULT_PROTECTED_FILES: tuple[str, ...] = ()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def log(message: str) -> None:
|
|
32
|
+
print(f"[INFO] {dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S')} {message}", file=sys.stderr, flush=True)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def read_calendar(calendar_path: Path) -> list[str]:
|
|
36
|
+
if not calendar_path.is_file():
|
|
37
|
+
return []
|
|
38
|
+
return [line.strip() for line in calendar_path.read_text().splitlines() if line.strip()]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def read_latest_date(data_dir: Path) -> str | None:
|
|
42
|
+
calendar = read_calendar(data_dir / "calendars" / "day.txt")
|
|
43
|
+
return calendar[-1] if calendar else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def instrument_stats(path: Path) -> dict[str, int | str]:
|
|
47
|
+
lines = [line for line in path.read_text().splitlines() if line.strip()]
|
|
48
|
+
symbols = {line.split()[0] for line in lines}
|
|
49
|
+
return {"name": path.stem, "records": len(lines), "unique_symbols": len(symbols)}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def list_instrument_stats(data_dir: Path) -> list[dict[str, int | str]]:
|
|
53
|
+
instruments_dir = data_dir / "instruments"
|
|
54
|
+
if not instruments_dir.is_dir():
|
|
55
|
+
return []
|
|
56
|
+
return [instrument_stats(path) for path in sorted(instruments_dir.glob("*.txt"))]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def list_backups(data_dir: Path) -> list[Path]:
|
|
60
|
+
return sorted(data_dir.parent.glob(f"{BACKUP_PREFIX}*"), reverse=True)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def directory_size(path: Path) -> int:
|
|
64
|
+
if not path.exists():
|
|
65
|
+
return 0
|
|
66
|
+
return sum(item.stat().st_size for item in path.rglob("*") if item.is_file())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def human_size(size: int) -> str:
|
|
70
|
+
value = float(size)
|
|
71
|
+
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
|
72
|
+
if value < 1024 or unit == "TiB":
|
|
73
|
+
return f"{value:.1f} {unit}"
|
|
74
|
+
value /= 1024
|
|
75
|
+
raise AssertionError("unreachable")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def related_dataset_status(data_dir: Path) -> list[dict[str, str | None]]:
|
|
79
|
+
output = []
|
|
80
|
+
for name in ("cn_etf_data", "cn_etf_data_hfq"):
|
|
81
|
+
path = data_dir.parent / name
|
|
82
|
+
if path.exists():
|
|
83
|
+
output.append({"name": name, "path": str(path), "latest_date": read_latest_date(path)})
|
|
84
|
+
return output
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def collect_status(
|
|
88
|
+
data_dir: Path,
|
|
89
|
+
protected_dir: Path,
|
|
90
|
+
offline: bool,
|
|
91
|
+
timeout: int,
|
|
92
|
+
source_url: str = DEFAULT_SOURCE_URL,
|
|
93
|
+
mirror: str | None = None,
|
|
94
|
+
) -> dict:
|
|
95
|
+
backups = list_backups(data_dir)
|
|
96
|
+
protected_files = sorted(path.name for path in protected_dir.glob("*.txt")) if protected_dir.is_dir() else []
|
|
97
|
+
result = {
|
|
98
|
+
"data_dir": str(data_dir),
|
|
99
|
+
"local_latest_date": read_latest_date(data_dir),
|
|
100
|
+
"size_bytes": directory_size(data_dir),
|
|
101
|
+
"instrument_pools": list_instrument_stats(data_dir),
|
|
102
|
+
"protected_dir": str(protected_dir),
|
|
103
|
+
"protected_files": protected_files,
|
|
104
|
+
"backups": [str(path) for path in backups],
|
|
105
|
+
"related_datasets": related_dataset_status(data_dir),
|
|
106
|
+
"remote_release": None,
|
|
107
|
+
"update_available": None,
|
|
108
|
+
}
|
|
109
|
+
if not offline:
|
|
110
|
+
manifest = fetch_manifest(source_url, timeout, resolve_mirror(mirror))
|
|
111
|
+
if manifest:
|
|
112
|
+
result["remote_release"] = dataclasses.asdict(manifest)
|
|
113
|
+
local = result["local_latest_date"]
|
|
114
|
+
result["update_available"] = local is None or manifest.trade_date > local
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def print_status(result: dict) -> None:
|
|
119
|
+
print(f"CN data: {tilde(result['data_dir'])}")
|
|
120
|
+
print(f"Local latest date: {result['local_latest_date'] or 'missing'}")
|
|
121
|
+
print(f"Data size: {human_size(result['size_bytes'])}")
|
|
122
|
+
remote = result.get("remote_release")
|
|
123
|
+
if remote:
|
|
124
|
+
size = f", {human_size(remote['size'])}" if remote.get("size") else ""
|
|
125
|
+
state = "update available" if result["update_available"] else "up to date"
|
|
126
|
+
print(f"Community release: trade date {remote['trade_date']}{size} ({state})")
|
|
127
|
+
print("Instrument pools:")
|
|
128
|
+
for pool in result["instrument_pools"]:
|
|
129
|
+
print(f" {pool['name']}: records={pool['records']} unique_symbols={pool['unique_symbols']}")
|
|
130
|
+
protected = ", ".join(result["protected_files"]) or "none"
|
|
131
|
+
print(f"Protected local pools: {protected} ({tilde(result['protected_dir'])})")
|
|
132
|
+
print(f"Rollback backups: {len(result['backups'])}")
|
|
133
|
+
for dataset in result["related_datasets"]:
|
|
134
|
+
print(f"Related dataset {dataset['name']}: latest={dataset['latest_date']} path={tilde(dataset['path'])}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def run_update(args: argparse.Namespace) -> None:
|
|
138
|
+
protected = args.protect or list(DEFAULT_PROTECTED_FILES)
|
|
139
|
+
options = UpdateOptions(
|
|
140
|
+
data_dir=args.data_dir,
|
|
141
|
+
protected_dir=args.protected_dir,
|
|
142
|
+
protected=protected,
|
|
143
|
+
refresh_protected=args.refresh_protected,
|
|
144
|
+
keep_backup=args.keep_backup,
|
|
145
|
+
force=args.force,
|
|
146
|
+
source_url=args.source_url,
|
|
147
|
+
mirror=args.mirror,
|
|
148
|
+
)
|
|
149
|
+
log(f"start update: data_dir={tilde(args.data_dir)} protected={','.join(protected) or 'none'}")
|
|
150
|
+
try:
|
|
151
|
+
result = run_package_update(options)
|
|
152
|
+
except UpdateError as exc:
|
|
153
|
+
print(f"[ERROR] {exc}", file=sys.stderr)
|
|
154
|
+
raise SystemExit(1) from exc
|
|
155
|
+
if args.json:
|
|
156
|
+
print(json.dumps(dataclasses.asdict(result), indent=2))
|
|
157
|
+
else:
|
|
158
|
+
print("\n".join(result.summary_lines()))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def check_structure(data_dir: Path) -> tuple[list[str], list[str]]:
|
|
162
|
+
errors: list[str] = []
|
|
163
|
+
notes: list[str] = []
|
|
164
|
+
required = (data_dir / "calendars" / "day.txt", data_dir / "features", data_dir / "instruments" / "all.txt")
|
|
165
|
+
for path in required:
|
|
166
|
+
if not path.exists():
|
|
167
|
+
errors.append(f"missing required path: {path}")
|
|
168
|
+
calendar = read_calendar(data_dir / "calendars" / "day.txt")
|
|
169
|
+
if calendar:
|
|
170
|
+
if calendar != sorted(set(calendar)):
|
|
171
|
+
errors.append("daily calendar is not strictly increasing and unique")
|
|
172
|
+
try:
|
|
173
|
+
[dt.date.fromisoformat(value) for value in calendar]
|
|
174
|
+
except ValueError as exc:
|
|
175
|
+
errors.append(f"daily calendar contains an invalid date: {exc}")
|
|
176
|
+
notes.append(f"calendar_entries={len(calendar)} latest={calendar[-1]}")
|
|
177
|
+
elif not errors:
|
|
178
|
+
errors.append("daily calendar is empty")
|
|
179
|
+
pools = list_instrument_stats(data_dir)
|
|
180
|
+
if not pools:
|
|
181
|
+
errors.append("no instrument pools found")
|
|
182
|
+
else:
|
|
183
|
+
notes.append(f"instrument_pools={len(pools)}")
|
|
184
|
+
feature_files = list((data_dir / "features").glob("*/*.day.bin")) if (data_dir / "features").is_dir() else []
|
|
185
|
+
if not feature_files:
|
|
186
|
+
errors.append("no daily feature files found")
|
|
187
|
+
else:
|
|
188
|
+
notes.append(f"daily_feature_files={len(feature_files)}")
|
|
189
|
+
return errors, notes
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def check_protected(data_dir: Path, protected_dir: Path) -> tuple[list[str], list[str]]:
|
|
193
|
+
errors: list[str] = []
|
|
194
|
+
notes: list[str] = []
|
|
195
|
+
if not protected_dir.is_dir():
|
|
196
|
+
return errors, notes
|
|
197
|
+
files = sorted(protected_dir.glob("*.txt"))
|
|
198
|
+
if not files:
|
|
199
|
+
return errors, notes
|
|
200
|
+
for protected_file in files:
|
|
201
|
+
active_file = data_dir / "instruments" / protected_file.name
|
|
202
|
+
if not active_file.is_file():
|
|
203
|
+
errors.append(f"protected pool is missing from active data: {protected_file.name}")
|
|
204
|
+
elif protected_file.read_bytes() != active_file.read_bytes():
|
|
205
|
+
errors.append(f"protected pool differs from active data: {protected_file.name}")
|
|
206
|
+
else:
|
|
207
|
+
notes.append(f"protected_pool_ok={protected_file.name}")
|
|
208
|
+
return errors, notes
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def qlib_smoke_test(data_dir: Path) -> tuple[list[str], list[str]]:
|
|
212
|
+
# Keep Qlib imports lazy so status and backup inspection remain lightweight.
|
|
213
|
+
# pylint: disable=import-outside-toplevel
|
|
214
|
+
import qlib
|
|
215
|
+
from qlib.config import REG_CN
|
|
216
|
+
from qlib.data import D
|
|
217
|
+
|
|
218
|
+
errors: list[str] = []
|
|
219
|
+
notes: list[str] = []
|
|
220
|
+
try:
|
|
221
|
+
qlib.init(provider_uri=str(data_dir), region=REG_CN, logging_level="ERROR")
|
|
222
|
+
calendar = D.calendar(start_time="2020-01-01", end_time=None, freq="day")
|
|
223
|
+
if len(calendar) == 0:
|
|
224
|
+
errors.append("Qlib returned an empty daily calendar")
|
|
225
|
+
return errors, notes
|
|
226
|
+
notes.append(f"qlib_latest={calendar[-1].date().isoformat()}")
|
|
227
|
+
frame = D.features(
|
|
228
|
+
["SH600000"],
|
|
229
|
+
["$close", "$change"],
|
|
230
|
+
start_time=str(calendar[max(0, len(calendar) - 20)].date()),
|
|
231
|
+
end_time=str(calendar[-1].date()),
|
|
232
|
+
)
|
|
233
|
+
if frame.dropna(how="all").empty:
|
|
234
|
+
errors.append("Qlib feature read returned no close/change data for SH600000")
|
|
235
|
+
else:
|
|
236
|
+
notes.append(f"qlib_feature_rows={len(frame.dropna(how='all'))}")
|
|
237
|
+
except Exception as exc: # The command must report provider failures instead of hiding them.
|
|
238
|
+
errors.append(f"Qlib read failed: {exc}")
|
|
239
|
+
return errors, notes
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def health_check(data_dir: Path, protected_dir: Path, skip_qlib: bool = False) -> dict:
|
|
243
|
+
errors, notes = check_structure(data_dir)
|
|
244
|
+
protected_errors, protected_notes = check_protected(data_dir, protected_dir)
|
|
245
|
+
errors.extend(protected_errors)
|
|
246
|
+
notes.extend(protected_notes)
|
|
247
|
+
if not skip_qlib and not errors:
|
|
248
|
+
qlib_errors, qlib_notes = qlib_smoke_test(data_dir)
|
|
249
|
+
errors.extend(qlib_errors)
|
|
250
|
+
notes.extend(qlib_notes)
|
|
251
|
+
return {"ok": not errors, "errors": errors, "checks": notes}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def run_health_check(args: argparse.Namespace) -> int:
|
|
255
|
+
log(f"start health-check: data_dir={tilde(args.data_dir)}")
|
|
256
|
+
report = health_check(args.data_dir, args.protected_dir, args.skip_qlib)
|
|
257
|
+
errors, notes = report["errors"], report["checks"]
|
|
258
|
+
for note in notes:
|
|
259
|
+
print(f"[OK] {note}")
|
|
260
|
+
for error in errors:
|
|
261
|
+
print(f"[ERROR] {error}", file=sys.stderr)
|
|
262
|
+
if errors:
|
|
263
|
+
log(f"end health-check: failed errors={len(errors)}")
|
|
264
|
+
return 1
|
|
265
|
+
log("end health-check: passed")
|
|
266
|
+
return 0
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def resolve_backup(data_dir: Path, value: Path | None) -> Path:
|
|
270
|
+
if value is not None:
|
|
271
|
+
backup = value.expanduser().resolve()
|
|
272
|
+
if backup.parent != data_dir.parent.resolve() or not backup.name.startswith(BACKUP_PREFIX):
|
|
273
|
+
raise ValueError(f"backup must be a managed CN data backup under {data_dir.parent}")
|
|
274
|
+
if not backup.is_dir():
|
|
275
|
+
raise FileNotFoundError(f"backup not found: {backup}")
|
|
276
|
+
return backup
|
|
277
|
+
backups = list_backups(data_dir)
|
|
278
|
+
if not backups:
|
|
279
|
+
raise FileNotFoundError(f"no rollback backups found under {data_dir.parent}")
|
|
280
|
+
return backups[0]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def run_restore(args: argparse.Namespace) -> None:
|
|
284
|
+
backups = list_backups(args.data_dir)
|
|
285
|
+
if args.list:
|
|
286
|
+
for backup in backups:
|
|
287
|
+
print(f"{tilde(backup)}\tlatest={read_latest_date(backup)}\tsize={human_size(directory_size(backup))}")
|
|
288
|
+
return
|
|
289
|
+
if not args.yes:
|
|
290
|
+
raise RuntimeError("restore changes the active dataset; rerun with --yes after reviewing `restore --list`")
|
|
291
|
+
backup = resolve_backup(args.data_dir, args.backup)
|
|
292
|
+
backup_errors, _ = check_structure(backup)
|
|
293
|
+
if backup_errors:
|
|
294
|
+
raise RuntimeError(f"backup failed structural validation: {'; '.join(backup_errors)}")
|
|
295
|
+
stamp = dt.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
|
296
|
+
displaced = args.data_dir.parent / f"cn_data_before_restore_{stamp}"
|
|
297
|
+
log(f"start restore: backup={tilde(backup)} active={tilde(args.data_dir)}")
|
|
298
|
+
with exclusive_lock(lock_dir_for(args.data_dir)):
|
|
299
|
+
previous = replace_directory(args.data_dir, backup, displaced)
|
|
300
|
+
log(f"end restore: latest_date={read_latest_date(args.data_dir)} displaced_data={tilde(previous) if previous else 'none'}")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def add_data_args(parser: argparse.ArgumentParser) -> None:
|
|
304
|
+
parser.add_argument("--data-dir", type=Path, default=DEFAULT_DATA_DIR)
|
|
305
|
+
parser.add_argument("--protected-dir", type=Path, default=DEFAULT_PROTECTED_DIR)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def add_mirror_arg(parser: argparse.ArgumentParser) -> None:
|
|
309
|
+
parser.add_argument(
|
|
310
|
+
"--mirror",
|
|
311
|
+
help=f"Prefix-style GitHub proxy, e.g. https://ghfast.top (default: ${MIRROR_ENV})",
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def add_subcommands(parser: argparse.ArgumentParser) -> None:
|
|
316
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
317
|
+
|
|
318
|
+
status_parser = subparsers.add_parser("status", help="Show local data, pools, backups, and remote release metadata")
|
|
319
|
+
add_data_args(status_parser)
|
|
320
|
+
status_parser.add_argument("--offline", action="store_true", help="Do not read the remote release manifest")
|
|
321
|
+
status_parser.add_argument("--timeout", type=int, default=15)
|
|
322
|
+
status_parser.add_argument("--json", action="store_true", help="Print machine-readable JSON")
|
|
323
|
+
add_mirror_arg(status_parser)
|
|
324
|
+
|
|
325
|
+
update_parser = subparsers.add_parser(
|
|
326
|
+
"update", help="Install, or safely replace CN data with, the latest community package"
|
|
327
|
+
)
|
|
328
|
+
add_data_args(update_parser)
|
|
329
|
+
update_parser.add_argument(
|
|
330
|
+
"--protect", action="append", help="Protected instrument filename; repeat for multiple files"
|
|
331
|
+
)
|
|
332
|
+
update_parser.add_argument("--refresh-protected", action="store_true")
|
|
333
|
+
update_parser.add_argument("--keep-backup", action="store_true", help="Keep one rollback backup after a successful update")
|
|
334
|
+
update_parser.add_argument("--force", action="store_true", help="Replace even when package and local dates match")
|
|
335
|
+
update_parser.add_argument("--source-url", default=DEFAULT_SOURCE_URL, help="Community package URL")
|
|
336
|
+
update_parser.add_argument("--json", action="store_true", help="Print the update result as JSON")
|
|
337
|
+
add_mirror_arg(update_parser)
|
|
338
|
+
|
|
339
|
+
health_parser = subparsers.add_parser("health-check", help="Validate structure, protected pools, and Qlib reads")
|
|
340
|
+
add_data_args(health_parser)
|
|
341
|
+
health_parser.add_argument("--skip-qlib", action="store_true", help="Run filesystem checks only")
|
|
342
|
+
|
|
343
|
+
restore_parser = subparsers.add_parser("restore", help="List or restore managed CN data backups")
|
|
344
|
+
add_data_args(restore_parser)
|
|
345
|
+
restore_parser.add_argument("--list", action="store_true", help="List rollback backups without changing data")
|
|
346
|
+
restore_parser.add_argument("--backup", type=Path, help="Specific managed backup; defaults to the newest")
|
|
347
|
+
restore_parser.add_argument("--yes", action="store_true", help="Confirm replacement of the active data directory")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def get_parser() -> argparse.ArgumentParser:
|
|
352
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
353
|
+
add_subcommands(parser)
|
|
354
|
+
return parser
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def run(args: argparse.Namespace) -> None:
|
|
358
|
+
args.data_dir = args.data_dir.expanduser().resolve()
|
|
359
|
+
args.protected_dir = args.protected_dir.expanduser().resolve()
|
|
360
|
+
if args.command == "status":
|
|
361
|
+
result = collect_status(args.data_dir, args.protected_dir, args.offline, args.timeout, mirror=args.mirror)
|
|
362
|
+
if args.json:
|
|
363
|
+
print(json.dumps(result, indent=2, ensure_ascii=True))
|
|
364
|
+
else:
|
|
365
|
+
print_status(result)
|
|
366
|
+
elif args.command == "update":
|
|
367
|
+
run_update(args)
|
|
368
|
+
elif args.command == "health-check":
|
|
369
|
+
raise SystemExit(run_health_check(args))
|
|
370
|
+
elif args.command == "restore":
|
|
371
|
+
run_restore(args)
|
|
372
|
+
else:
|
|
373
|
+
raise AssertionError(f"unknown command: {args.command}")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def main() -> None:
|
|
377
|
+
run(get_parser().parse_args())
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
if __name__ == "__main__":
|
|
381
|
+
main()
|