feat: add Quant OS A-share baseline
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Build and environment inspection tools for quant60."""
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Build a tiny synthetic Qlib binary provider with only the standard library."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import math
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, Mapping, Optional, Sequence
|
||||
|
||||
|
||||
def _qlib_name(symbol: str) -> str:
|
||||
value = str(symbol).strip().upper()
|
||||
if len(value) != 8 or value[:2] not in {"SH", "SZ", "BJ"}:
|
||||
raise ValueError("fixture symbols must use Qlib SH600000 form")
|
||||
if not value[2:].isdigit():
|
||||
raise ValueError("invalid Qlib symbol")
|
||||
return value
|
||||
|
||||
|
||||
def _write_lines(path: Path, lines: Iterable[str]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def _write_feature(path: Path, start_index: int, values: Sequence[float]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
numeric = [float(start_index)] + [float(value) for value in values]
|
||||
path.write_bytes(struct.pack("<" + "f" * len(numeric), *numeric))
|
||||
|
||||
|
||||
def write_qlib_fixture(
|
||||
root: Path,
|
||||
*,
|
||||
calendar: Sequence[str],
|
||||
features: Mapping[str, Mapping[str, Sequence[float]]],
|
||||
markets: Optional[Mapping[str, Sequence[str]]] = None,
|
||||
) -> Dict[str, object]:
|
||||
"""Write calendar/instruments/features in Qlib's documented bin layout."""
|
||||
output = root.expanduser().resolve()
|
||||
if not calendar:
|
||||
raise ValueError("calendar must not be empty")
|
||||
if len(set(calendar)) != len(calendar):
|
||||
raise ValueError("calendar contains duplicates")
|
||||
ordered_calendar = sorted(str(value) for value in calendar)
|
||||
if ordered_calendar != list(calendar):
|
||||
raise ValueError("calendar must be sorted")
|
||||
_write_lines(output / "calendars/day.txt", ordered_calendar)
|
||||
|
||||
normalized: Dict[str, Mapping[str, Sequence[float]]] = {}
|
||||
for raw_symbol, fields in features.items():
|
||||
symbol = _qlib_name(raw_symbol)
|
||||
if not fields:
|
||||
raise ValueError(f"{symbol} has no fields")
|
||||
for field, values in fields.items():
|
||||
if len(values) != len(calendar):
|
||||
raise ValueError(
|
||||
f"{symbol}/{field} has {len(values)} values; "
|
||||
f"expected {len(calendar)}"
|
||||
)
|
||||
for value in values:
|
||||
if not math.isfinite(float(value)):
|
||||
raise ValueError(f"{symbol}/{field} contains non-finite data")
|
||||
_write_feature(
|
||||
output
|
||||
/ "features"
|
||||
/ symbol.lower()
|
||||
/ (str(field).lower().lstrip("$") + ".day.bin"),
|
||||
0,
|
||||
values,
|
||||
)
|
||||
normalized[symbol] = fields
|
||||
|
||||
start, end = calendar[0], calendar[-1]
|
||||
all_lines = [
|
||||
f"{symbol}\t{start}\t{end}" for symbol in sorted(normalized)
|
||||
]
|
||||
_write_lines(output / "instruments/all.txt", all_lines)
|
||||
for market, symbols in (markets or {}).items():
|
||||
selected = [_qlib_name(symbol) for symbol in symbols]
|
||||
unknown = set(selected).difference(normalized)
|
||||
if unknown:
|
||||
raise ValueError(f"{market} contains missing symbols: {sorted(unknown)}")
|
||||
_write_lines(
|
||||
output / f"instruments/{market.lower()}.txt",
|
||||
[f"{symbol}\t{start}\t{end}" for symbol in sorted(selected)],
|
||||
)
|
||||
return {
|
||||
"provider_uri": str(output),
|
||||
"calendar_count": len(calendar),
|
||||
"instruments": sorted(normalized),
|
||||
"markets": sorted((markets or {}).keys()),
|
||||
"format_note": (
|
||||
"each *.day.bin is little-endian float32; element 0 is calendar "
|
||||
"start index, followed by field values"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _business_days(start: dt.date, count: int) -> list[str]:
|
||||
output = []
|
||||
current = start
|
||||
while len(output) < count:
|
||||
if current.weekday() < 5:
|
||||
output.append(current.isoformat())
|
||||
current += dt.timedelta(days=1)
|
||||
return output
|
||||
|
||||
|
||||
def _fields(closes: Sequence[float], volume: float) -> Dict[str, Sequence[float]]:
|
||||
change = [0.0]
|
||||
for previous, current in zip(closes, closes[1:]):
|
||||
change.append(float(current) / float(previous) - 1.0)
|
||||
return {
|
||||
"open": [value * 0.998 for value in closes],
|
||||
"high": [value * 1.01 for value in closes],
|
||||
"low": [value * 0.99 for value in closes],
|
||||
"close": list(closes),
|
||||
"volume": [volume] * len(closes),
|
||||
"factor": [1.0] * len(closes),
|
||||
"change": change,
|
||||
}
|
||||
|
||||
|
||||
def build_default_fixture(root: Path, days: int = 80) -> Dict[str, object]:
|
||||
"""Create a deterministic two-stock market and one benchmark."""
|
||||
if int(days) < 30:
|
||||
raise ValueError("default fixture needs at least 30 business days")
|
||||
calendar = _business_days(dt.date(2024, 1, 2), int(days))
|
||||
closes_up = [10.0 * (1.004 ** index) for index in range(len(calendar))]
|
||||
closes_down = [20.0 * (0.999 ** index) for index in range(len(calendar))]
|
||||
closes_bench = [100.0 * (1.001 ** index) for index in range(len(calendar))]
|
||||
return write_qlib_fixture(
|
||||
root,
|
||||
calendar=calendar,
|
||||
features={
|
||||
"SH600000": _fields(closes_up, 2_000_000.0),
|
||||
"SZ000001": _fields(closes_down, 1_500_000.0),
|
||||
"SH000300": _fields(closes_bench, 100_000_000.0),
|
||||
},
|
||||
markets={"csi300": ["SH600000", "SZ000001"]},
|
||||
)
|
||||
|
||||
|
||||
def _main(argv: Optional[list[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("output_dir", type=Path)
|
||||
parser.add_argument("--days", type=int, default=80)
|
||||
args = parser.parse_args(argv)
|
||||
print(
|
||||
json.dumps(
|
||||
build_default_fixture(args.output_dir, args.days),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(_main())
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Inline one portable core into JoinQuant and QMT single-file artifacts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import hashlib
|
||||
import json
|
||||
import pprint
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
START = "# __PORTABLE_CORE_BUNDLE_START__"
|
||||
END = "# __PORTABLE_CORE_BUNDLE_END__"
|
||||
CONFIG_START = "# __BASELINE_CONFIG_START__"
|
||||
CONFIG_END = "# __BASELINE_CONFIG_END__"
|
||||
|
||||
|
||||
def sha256_bytes(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def _portable_body(source: str) -> str:
|
||||
"""Remove the core module docstring and future imports before inlining."""
|
||||
tree = ast.parse(source)
|
||||
lines = source.splitlines()
|
||||
first_line = 0
|
||||
if (
|
||||
tree.body
|
||||
and isinstance(tree.body[0], ast.Expr)
|
||||
and isinstance(tree.body[0].value, ast.Constant)
|
||||
and isinstance(tree.body[0].value.value, str)
|
||||
):
|
||||
first_line = int(tree.body[0].end_lineno or 0)
|
||||
body = lines[first_line:]
|
||||
body = [
|
||||
line
|
||||
for line in body
|
||||
if not line.lstrip().startswith("from __future__ import ")
|
||||
]
|
||||
return "\n".join(body).strip() + "\n"
|
||||
|
||||
|
||||
def inline_core(wrapper_source: str, core_source: str) -> str:
|
||||
if wrapper_source.count(START) != 1 or wrapper_source.count(END) != 1:
|
||||
raise ValueError("wrapper must contain exactly one portable-core marker pair")
|
||||
start = wrapper_source.index(START)
|
||||
end = wrapper_source.index(END, start) + len(END)
|
||||
replacement = (
|
||||
START
|
||||
+ "\n# Inlined by tools/bundle_platforms.py; do not edit this region.\n"
|
||||
+ _portable_body(core_source)
|
||||
+ END
|
||||
)
|
||||
return wrapper_source[:start] + replacement + wrapper_source[end:]
|
||||
|
||||
|
||||
def _qmt_symbol(symbol: str) -> str:
|
||||
suffixes = {
|
||||
".XSHG": ".SH",
|
||||
".XSHE": ".SZ",
|
||||
".XBSE": ".BJ",
|
||||
}
|
||||
for suffix, replacement in suffixes.items():
|
||||
if symbol.endswith(suffix):
|
||||
return symbol[: -len(suffix)] + replacement
|
||||
raise ValueError(f"unsupported canonical symbol in baseline config: {symbol}")
|
||||
|
||||
|
||||
def effective_platform_config(
|
||||
baseline: Dict[str, Any],
|
||||
platform: str,
|
||||
) -> Dict[str, Any]:
|
||||
strategy = baseline["strategy"]
|
||||
execution = baseline["execution"]
|
||||
fees = baseline["fees"]
|
||||
universe_config = baseline["universe"]
|
||||
symbols = [str(symbol) for symbol in baseline["symbols"]]
|
||||
if platform == "qmt_builtin":
|
||||
universe = [_qmt_symbol(symbol) for symbol in symbols]
|
||||
elif platform == "joinquant":
|
||||
universe = symbols
|
||||
else:
|
||||
raise ValueError(f"unsupported platform config: {platform}")
|
||||
common: Dict[str, Any] = {
|
||||
"universe_mode": str(universe_config["mode"]),
|
||||
"index_symbol": str(universe_config["index_symbol"]),
|
||||
"qmt_sector_name": str(universe_config["qmt_sector_name"]),
|
||||
"dedicated_account_required": bool(
|
||||
universe_config["dedicated_account_required"]
|
||||
),
|
||||
"exclude_st": bool(universe_config["exclude_st"]),
|
||||
"universe": universe,
|
||||
"lookback": int(strategy["lookback"]),
|
||||
"skip": int(strategy["skip"]),
|
||||
"top_n": int(strategy["top_n"]),
|
||||
"max_weight": float(strategy["max_weight"]),
|
||||
"gross_target": float(strategy["gross_target"]),
|
||||
"cash_buffer": float(strategy["cash_buffer"]),
|
||||
"lot_size": int(execution["lot_size"]),
|
||||
"participation_rate": float(execution["participation_rate"]),
|
||||
"slippage_bps": float(execution["slippage_bps"]),
|
||||
"commission_rate": float(fees["commission_rate"]),
|
||||
"minimum_commission": float(fees["minimum_commission"]),
|
||||
"stamp_duty_rate": float(fees["stamp_duty_rate"]),
|
||||
"transfer_fee_rate": float(fees["transfer_fee_rate"]),
|
||||
"rebalance_schedule": str(strategy["rebalance_schedule"]),
|
||||
}
|
||||
if platform == "qmt_builtin":
|
||||
return {
|
||||
"account_id": "test",
|
||||
**common,
|
||||
}
|
||||
return common
|
||||
|
||||
|
||||
def inline_config(wrapper_source: str, config: Dict[str, Any]) -> str:
|
||||
if (
|
||||
wrapper_source.count(CONFIG_START) != 1
|
||||
or wrapper_source.count(CONFIG_END) != 1
|
||||
):
|
||||
raise ValueError("wrapper must contain exactly one baseline-config marker pair")
|
||||
start = wrapper_source.index(CONFIG_START)
|
||||
end = wrapper_source.index(CONFIG_END, start) + len(CONFIG_END)
|
||||
body = pprint.pformat(config, width=88, sort_dicts=False)
|
||||
# QMT's built-in strategy is shipped with a GBK coding declaration but
|
||||
# kept byte-for-byte ASCII for portability. Preserve non-ASCII config
|
||||
# values (for example a Chinese sector name) as Python unicode escapes.
|
||||
body = body.encode("ascii", "backslashreplace").decode("ascii")
|
||||
replacement = (
|
||||
CONFIG_START
|
||||
+ "\n# Generated from configs/baseline.json; do not edit this region.\n"
|
||||
+ "CONFIG = "
|
||||
+ body
|
||||
+ "\n"
|
||||
+ CONFIG_END
|
||||
)
|
||||
return wrapper_source[:start] + replacement + wrapper_source[end:]
|
||||
|
||||
|
||||
def bundle_one(
|
||||
*,
|
||||
core_path: Path,
|
||||
wrapper_path: Path,
|
||||
output_path: Path,
|
||||
effective_config: Dict[str, Any],
|
||||
require_ascii: bool = False,
|
||||
) -> Dict[str, str]:
|
||||
core_bytes = core_path.read_bytes()
|
||||
wrapper_bytes = wrapper_path.read_bytes()
|
||||
core_source = core_bytes.decode("utf-8")
|
||||
wrapper_source = wrapper_bytes.decode("utf-8")
|
||||
bundled = inline_config(
|
||||
inline_core(wrapper_source, core_source),
|
||||
effective_config,
|
||||
)
|
||||
output_bytes = bundled.encode("utf-8")
|
||||
if require_ascii:
|
||||
try:
|
||||
bundled.encode("ascii")
|
||||
except UnicodeEncodeError as exc:
|
||||
raise ValueError(
|
||||
"QMT #coding:gbk bundle must remain ASCII-only"
|
||||
) from exc
|
||||
# The built-in QMT runtime is documented as Python 3.6. Parse against
|
||||
# that grammar explicitly; compiling only on the build host would miss
|
||||
# accidental modern syntax.
|
||||
ast.parse(bundled, filename=str(output_path), feature_version=(3, 6))
|
||||
compile(bundled, str(output_path), "exec")
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(output_bytes)
|
||||
return {
|
||||
"core": str(core_path),
|
||||
"core_sha256": sha256_bytes(core_bytes),
|
||||
"wrapper": str(wrapper_path),
|
||||
"wrapper_sha256": sha256_bytes(wrapper_bytes),
|
||||
"effective_config_sha256": sha256_bytes(
|
||||
(
|
||||
json.dumps(
|
||||
effective_config,
|
||||
ensure_ascii=True,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n"
|
||||
).encode("ascii")
|
||||
),
|
||||
"output": str(output_path),
|
||||
"output_sha256": sha256_bytes(output_bytes),
|
||||
}
|
||||
|
||||
|
||||
def bundle_all(
|
||||
project_root: Path,
|
||||
output_dir: Optional[Path] = None,
|
||||
) -> Dict[str, object]:
|
||||
root = project_root.expanduser().resolve()
|
||||
output = (
|
||||
output_dir.expanduser().resolve()
|
||||
if output_dir
|
||||
else root / "dist"
|
||||
)
|
||||
core = root / "src" / "quant60" / "portable_core.py"
|
||||
if not core.is_file():
|
||||
raise FileNotFoundError(core)
|
||||
config_path = root / "configs" / "baseline.json"
|
||||
baseline_bytes = config_path.read_bytes()
|
||||
baseline = json.loads(baseline_bytes.decode("utf-8"))
|
||||
if not isinstance(baseline, dict):
|
||||
raise ValueError("configs/baseline.json must contain an object")
|
||||
entries = {
|
||||
"joinquant": bundle_one(
|
||||
core_path=core,
|
||||
wrapper_path=root / "platforms" / "joinquant_strategy.py",
|
||||
output_path=output / "joinquant_strategy.py",
|
||||
effective_config=effective_platform_config(baseline, "joinquant"),
|
||||
),
|
||||
"qmt_builtin": bundle_one(
|
||||
core_path=core,
|
||||
wrapper_path=root / "platforms" / "qmt_builtin_strategy.py",
|
||||
output_path=output / "qmt_builtin_strategy.py",
|
||||
effective_config=effective_platform_config(baseline, "qmt_builtin"),
|
||||
require_ascii=True,
|
||||
),
|
||||
}
|
||||
# Paths in a committed manifest must be portable and must not expose the
|
||||
# local account/home directory. Hashes remain byte-for-byte identities.
|
||||
logical_outputs = {
|
||||
"joinquant": "dist/joinquant_strategy.py",
|
||||
"qmt_builtin": "dist/qmt_builtin_strategy.py",
|
||||
}
|
||||
for platform_name, entry in entries.items():
|
||||
entry["core"] = "src/quant60/portable_core.py"
|
||||
entry["wrapper"] = (
|
||||
"platforms/joinquant_strategy.py"
|
||||
if "joinquant" in entry["wrapper"]
|
||||
else "platforms/qmt_builtin_strategy.py"
|
||||
)
|
||||
# The manifest describes the committed logical artifact, not the
|
||||
# caller's temporary build directory. This keeps clean-room builds
|
||||
# byte-for-byte comparable with committed ``dist``.
|
||||
entry["output"] = logical_outputs[platform_name]
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"baseline_config": "configs/baseline.json",
|
||||
"baseline_config_sha256": sha256_bytes(baseline_bytes),
|
||||
"portable_core_sha256": entries["joinquant"]["core_sha256"],
|
||||
"artifacts": entries,
|
||||
}
|
||||
manifest_path = output / "bundle_manifest.json"
|
||||
encoded = (
|
||||
json.dumps(manifest, ensure_ascii=True, indent=2, sort_keys=True) + "\n"
|
||||
).encode("ascii")
|
||||
manifest_path.write_bytes(encoded)
|
||||
try:
|
||||
manifest["manifest"] = str(manifest_path.relative_to(root))
|
||||
except ValueError:
|
||||
manifest["manifest"] = manifest_path.name
|
||||
manifest["manifest_sha256"] = sha256_bytes(encoded)
|
||||
return manifest
|
||||
|
||||
|
||||
def _main(argv: Optional[list[str]] = None) -> int:
|
||||
default_root = Path(__file__).resolve().parents[1]
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--project-root", type=Path, default=default_root)
|
||||
parser.add_argument("--output-dir", type=Path)
|
||||
args = parser.parse_args(argv)
|
||||
manifest = bundle_all(args.project_root, args.output_dir)
|
||||
print(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(_main())
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Report static artifacts and local optional-runtime availability."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import json
|
||||
import platform
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
CAPABILITY_MATRIX: Dict[str, Dict[str, Any]] = {
|
||||
"quant_os_research": {
|
||||
"backtest": True,
|
||||
"hosted": False,
|
||||
"live_orders": False,
|
||||
"safe_default": True,
|
||||
"market_scope": (
|
||||
"synthetic research plus verified authorized-provider "
|
||||
"PIT snapshot decisions/backtests"
|
||||
),
|
||||
},
|
||||
"portable_local": {
|
||||
"backtest": True,
|
||||
"hosted": False,
|
||||
"live_orders": False,
|
||||
"safe_default": True,
|
||||
"market_scope": "SH/SZ/BJ core math",
|
||||
},
|
||||
"joinquant_hosted": {
|
||||
"backtest": True,
|
||||
"hosted": True,
|
||||
"live_orders": False,
|
||||
"safe_default": True,
|
||||
"market_scope": "configured SH/SZ wrapper universe",
|
||||
},
|
||||
"qmt_builtin": {
|
||||
"backtest": True,
|
||||
"hosted": True,
|
||||
"live_orders": False,
|
||||
"safe_default": True,
|
||||
"market_scope": "configured SH/SZ wrapper universe; backtest-only",
|
||||
},
|
||||
"qmt_research": {
|
||||
"backtest": True,
|
||||
"hosted": False,
|
||||
"live_orders": False,
|
||||
"safe_default": True,
|
||||
"market_scope": "QMT entitlement; runner is backtest/history only",
|
||||
},
|
||||
"qmt_shadow": {
|
||||
"backtest": False,
|
||||
"hosted": False,
|
||||
"live_orders": False,
|
||||
"safe_default": True,
|
||||
"market_scope": (
|
||||
"licensed XtTrader SH/SZ read-only account queries; "
|
||||
"account-bound inert target diff only"
|
||||
),
|
||||
},
|
||||
"xttrader": {
|
||||
"backtest": False,
|
||||
"hosted": False,
|
||||
"live_orders": "explicit opt-in plus fresh structured policy guard",
|
||||
"safe_default": True,
|
||||
"market_scope": "SH/SZ; adapter rejects BJ explicitly",
|
||||
},
|
||||
"qlib_0_9_7": {
|
||||
"backtest": True,
|
||||
"hosted": False,
|
||||
"live_orders": False,
|
||||
"safe_default": True,
|
||||
"market_scope": (
|
||||
"provider-defined; portable signal research bridge, "
|
||||
"not target/order parity"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _module_probe(name: str, deep: bool) -> Dict[str, Any]:
|
||||
try:
|
||||
available = importlib.util.find_spec(name) is not None
|
||||
except (ImportError, ModuleNotFoundError, ValueError):
|
||||
available = False
|
||||
result: Dict[str, Any] = {
|
||||
"available": available,
|
||||
"import_checked": bool(deep),
|
||||
"import_ok": None,
|
||||
"error": None,
|
||||
}
|
||||
top_level = name.split(".", 1)[0]
|
||||
try:
|
||||
result["version"] = importlib.metadata.version(
|
||||
"pyqlib" if top_level == "qlib" else top_level
|
||||
)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
result["version"] = None
|
||||
if available and deep:
|
||||
try:
|
||||
importlib.import_module(name)
|
||||
result["import_ok"] = True
|
||||
except Exception as exc:
|
||||
result["import_ok"] = False
|
||||
result["error"] = f"{type(exc).__name__}: {exc}"
|
||||
return result
|
||||
|
||||
|
||||
def probe_capabilities(
|
||||
project_root: Optional[Path] = None,
|
||||
*,
|
||||
deep: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
root = (
|
||||
project_root.expanduser().resolve()
|
||||
if project_root
|
||||
else Path(__file__).resolve().parents[1]
|
||||
)
|
||||
generated = root / "dist"
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"python": {
|
||||
"version": platform.python_version(),
|
||||
"implementation": platform.python_implementation(),
|
||||
"executable": sys.executable,
|
||||
"platform": platform.platform(),
|
||||
},
|
||||
"artifacts": {
|
||||
"portable_core": (root / "src/quant60/portable_core.py").is_file(),
|
||||
"joinquant_wrapper": (root / "platforms/joinquant_strategy.py").is_file(),
|
||||
"qmt_builtin_wrapper": (
|
||||
root / "platforms/qmt_builtin_strategy.py"
|
||||
).is_file(),
|
||||
"joinquant_bundle": (
|
||||
generated / "joinquant_strategy.py"
|
||||
).is_file(),
|
||||
"qmt_builtin_bundle": (
|
||||
generated / "qmt_builtin_strategy.py"
|
||||
).is_file(),
|
||||
"bundle_manifest": (
|
||||
generated / "bundle_manifest.json"
|
||||
).is_file(),
|
||||
"colab_notebook": (
|
||||
root / "notebooks/quant_os_colab.ipynb"
|
||||
).is_file(),
|
||||
"jqdata_snapshot_adapter": (
|
||||
root / "adapters/jqdata_local.py"
|
||||
).is_file(),
|
||||
"snapshot_decision_pipeline": (
|
||||
root / "src/quant60/snapshot_pipeline.py"
|
||||
).is_file(),
|
||||
"snapshot_backtest": (
|
||||
root / "src/quant60/snapshot_backtest.py"
|
||||
).is_file(),
|
||||
"mock_parity_exporter": (
|
||||
root / "tools/export_mock_parity.py"
|
||||
).is_file(),
|
||||
"qlib_momentum_and_alpha158_cli": (
|
||||
root / "platforms/qlib_runner.py"
|
||||
).is_file(),
|
||||
"qmt_shadow_planner": (
|
||||
root / "src/quant60/qmt_shadow.py"
|
||||
).is_file(),
|
||||
"qmt_shadow_cli": (
|
||||
root / "tools/qmt_shadow_plan.py"
|
||||
).is_file(),
|
||||
},
|
||||
"optional_runtimes": {
|
||||
"jqdatasdk": _module_probe("jqdatasdk", deep),
|
||||
"cvxpy": _module_probe("cvxpy", deep),
|
||||
"qlib": _module_probe("qlib", deep),
|
||||
"xtquant": _module_probe("xtquant", deep),
|
||||
"xtquant.qmttools": _module_probe("xtquant.qmttools", deep),
|
||||
"xtquant.xttrader": _module_probe("xtquant.xttrader", deep),
|
||||
},
|
||||
"matrix": CAPABILITY_MATRIX,
|
||||
"verification_boundary": (
|
||||
"artifact presence and optional import probes only; no external "
|
||||
"platform backtest or broker entitlement is claimed"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _main(argv: Optional[list[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--project-root", type=Path)
|
||||
parser.add_argument(
|
||||
"--deep",
|
||||
action="store_true",
|
||||
help="Import optional runtimes; still performs no broker mutation.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
print(
|
||||
json.dumps(
|
||||
probe_capabilities(args.project_root, deep=args.deep),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(_main())
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Export and verify the deterministic JoinQuant/QMT wrapper parity fixture.
|
||||
|
||||
This evidence proves only the local wrapper contract. It never represents a
|
||||
real hosted-platform, licensed QMT, broker, or investment-performance pass.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from platforms import joinquant_strategy, qmt_builtin_strategy
|
||||
from platforms.fake_joinquant_harness import FakeJoinQuantHarness
|
||||
from platforms.fake_qmt_harness import FakeQmtContext, FakeQmtHarness
|
||||
|
||||
|
||||
PROJECT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _canonical_json(payload: Any) -> str:
|
||||
return json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
)
|
||||
|
||||
|
||||
def _sha256_bytes(payload: bytes) -> str:
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def _sha256_payload(payload: Any) -> str:
|
||||
return _sha256_bytes(_canonical_json(payload).encode("utf-8"))
|
||||
|
||||
|
||||
def _price_histories() -> dict[str, list[float]]:
|
||||
return {
|
||||
"600000.XSHG": [15.0 - index * 0.02 for index in range(21)],
|
||||
"000001.XSHE": [10.0 + index * 0.10 for index in range(21)],
|
||||
"300750.XSHE": [100.0 + index * 1.5 for index in range(21)],
|
||||
"000333.XSHE": [20.0 + index * 0.05 for index in range(21)],
|
||||
}
|
||||
|
||||
|
||||
def _source_hashes() -> dict[str, str]:
|
||||
paths = (
|
||||
PROJECT / "platforms/joinquant_strategy.py",
|
||||
PROJECT / "platforms/qmt_builtin_strategy.py",
|
||||
PROJECT / "src/quant60/portable_core.py",
|
||||
PROJECT / "configs/baseline.json",
|
||||
)
|
||||
return {
|
||||
str(path.relative_to(PROJECT)): _sha256_bytes(path.read_bytes())
|
||||
for path in paths
|
||||
}
|
||||
|
||||
|
||||
def build_report() -> dict[str, Any]:
|
||||
histories = _price_histories()
|
||||
jq = FakeJoinQuantHarness(histories)
|
||||
jq.initialize(joinquant_strategy)
|
||||
jq.context.previous_date = dt.date(2026, 7, 24)
|
||||
jq.context.current_dt = dt.datetime(2026, 7, 27, 9, 30)
|
||||
jq_plan = joinquant_strategy.compute_plan(jq.context)
|
||||
|
||||
qmt_histories = {
|
||||
symbol.replace(".XSHE", ".SZ").replace(".XSHG", ".SH"): values
|
||||
for symbol, values in histories.items()
|
||||
}
|
||||
qmt_context = FakeQmtContext(
|
||||
qmt_histories,
|
||||
bar_time=dt.datetime(2026, 7, 24, 15, 0),
|
||||
)
|
||||
FakeQmtHarness(qmt_context).install(qmt_builtin_strategy)
|
||||
qmt_builtin_strategy.init(qmt_context)
|
||||
qmt_plan = qmt_builtin_strategy.compute_plan(
|
||||
qmt_context,
|
||||
dt.datetime(2026, 7, 24, 15, 0),
|
||||
)
|
||||
|
||||
sources = _source_hashes()
|
||||
body = {
|
||||
"schema_version": "1.0",
|
||||
"evidence_class": "mock_contract_only",
|
||||
"real_platform_pass": False,
|
||||
"investment_value_claim": False,
|
||||
"fixture": {
|
||||
"as_of": "2026-07-24T15:00:00+08:00",
|
||||
"history_length": 21,
|
||||
"symbols": sorted(histories),
|
||||
"price_history_sha256": _sha256_payload(histories),
|
||||
},
|
||||
"tolerances": {
|
||||
"score_absolute": 1e-12,
|
||||
"target_weight_absolute": 1e-10,
|
||||
"target_quantity": "exact_integer",
|
||||
"order_delta": "exact_integer",
|
||||
},
|
||||
"joinquant_plan": jq_plan,
|
||||
"qmt_plan": qmt_plan,
|
||||
"comparisons": {
|
||||
"whole_plan_exact": jq_plan == qmt_plan,
|
||||
"score_exact": jq_plan["scores"] == qmt_plan["scores"],
|
||||
"target_weight_exact": (
|
||||
jq_plan["weights"] == qmt_plan["weights"]
|
||||
),
|
||||
"target_quantity_exact": (
|
||||
jq_plan["targets"] == qmt_plan["targets"]
|
||||
),
|
||||
"order_delta_exact": jq_plan["orders"] == qmt_plan["orders"],
|
||||
},
|
||||
"source_sha256": sources,
|
||||
"source_aggregate_sha256": _sha256_payload(sources),
|
||||
"gate_credit": [],
|
||||
"required_next_evidence": [
|
||||
"JoinQuant hosted export",
|
||||
"licensed QMT backtest export",
|
||||
"canonical clock/data/fee/fill difference report",
|
||||
],
|
||||
}
|
||||
if not all(body["comparisons"].values()):
|
||||
raise RuntimeError("local JoinQuant/QMT wrapper parity failed")
|
||||
body["report_sha256"] = _sha256_payload(body)
|
||||
return body
|
||||
|
||||
|
||||
def write_report(path: Path) -> dict[str, Any]:
|
||||
report = build_report()
|
||||
output = path.expanduser().resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
encoded = (
|
||||
json.dumps(
|
||||
report,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
allow_nan=False,
|
||||
)
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
descriptor, temporary = tempfile.mkstemp(
|
||||
prefix=f".{output.name}.",
|
||||
suffix=".tmp",
|
||||
dir=str(output.parent),
|
||||
)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as handle:
|
||||
handle.write(encoded)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, output)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(temporary)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
raise
|
||||
return report
|
||||
|
||||
|
||||
def verify_report(path: Path) -> dict[str, Any]:
|
||||
report = json.loads(path.read_text(encoding="utf-8"))
|
||||
saved_hash = report.pop("report_sha256", None)
|
||||
if not isinstance(saved_hash, str) or _sha256_payload(report) != saved_hash:
|
||||
raise ValueError("mock parity report hash mismatch")
|
||||
expected = build_report()
|
||||
if saved_hash != expected["report_sha256"]:
|
||||
raise ValueError("mock parity report does not match current sources")
|
||||
return {
|
||||
"ok": True,
|
||||
"evidence_class": report["evidence_class"],
|
||||
"report_sha256": saved_hash,
|
||||
"whole_plan_exact": report["comparisons"]["whole_plan_exact"],
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
export_parser = subparsers.add_parser("export")
|
||||
export_parser.add_argument("--output", required=True, type=Path)
|
||||
verify_parser = subparsers.add_parser("verify")
|
||||
verify_parser.add_argument("report", type=Path)
|
||||
args = parser.parse_args(argv)
|
||||
if args.command == "export":
|
||||
result = write_report(args.output)
|
||||
summary = {
|
||||
"ok": True,
|
||||
"output": str(args.output.expanduser().resolve()),
|
||||
"evidence_class": result["evidence_class"],
|
||||
"report_sha256": result["report_sha256"],
|
||||
}
|
||||
else:
|
||||
summary = verify_report(args.report)
|
||||
print(json.dumps(summary, ensure_ascii=False, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Authenticate interactively and build a canonical JQData index snapshot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
from adapters.jqdata_local import (
|
||||
authenticate,
|
||||
fetch_index_daily_snapshot,
|
||||
load_jqdata,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--index", default="000905.XSHG")
|
||||
parser.add_argument("--start", required=True, type=date.fromisoformat)
|
||||
parser.add_argument("--end", required=True, type=date.fromisoformat)
|
||||
parser.add_argument("--output", required=True)
|
||||
args = parser.parse_args()
|
||||
sdk = load_jqdata()
|
||||
authenticate(sdk)
|
||||
paths = fetch_index_daily_snapshot(
|
||||
sdk,
|
||||
index_symbol=args.index,
|
||||
start_date=args.start,
|
||||
end_date=args.end,
|
||||
output_dir=args.output,
|
||||
)
|
||||
print(json.dumps({"ok": True, "artifacts": paths}, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Run or verify the strictly read-only QMT/XtTrader shadow preflight.
|
||||
|
||||
Environment fallbacks:
|
||||
QMT_USERDATA_PATH QMT_ACCOUNT_ID QMT_SESSION_ID
|
||||
QMT_ACCOUNT_HASH_KEY_FILE
|
||||
|
||||
The runtime adapter is always constructed with ``allow_live_orders=False``.
|
||||
No command in this tool submits or cancels an order.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
for import_root in (PROJECT_ROOT, PROJECT_ROOT / "src"):
|
||||
if str(import_root) not in sys.path:
|
||||
sys.path.insert(0, str(import_root))
|
||||
|
||||
from adapters.xttrader_live import XtTraderLiveAdapter
|
||||
from quant60.qmt_shadow import (
|
||||
MAX_BROKER_ASSET_ABSOLUTE_TOLERANCE,
|
||||
MAX_SHADOW_QUERY_DURATION_SECONDS,
|
||||
build_qmt_shadow_plan,
|
||||
verify_qmt_shadow_plan,
|
||||
write_qmt_shadow_plan,
|
||||
)
|
||||
|
||||
|
||||
def _required(value: str | None, name: str) -> str:
|
||||
if value is None or not value.strip():
|
||||
raise ValueError(f"{name} is required")
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _bounded_non_negative_float(
|
||||
*,
|
||||
name: str,
|
||||
maximum: float,
|
||||
):
|
||||
def parse(value: str) -> float:
|
||||
try:
|
||||
parsed = float(value)
|
||||
except ValueError as exc:
|
||||
raise argparse.ArgumentTypeError(
|
||||
f"{name} must be a number"
|
||||
) from exc
|
||||
if not 0 <= parsed <= maximum:
|
||||
raise argparse.ArgumentTypeError(
|
||||
f"{name} must be between 0 and {maximum:g}"
|
||||
)
|
||||
return parsed
|
||||
|
||||
return parse
|
||||
|
||||
|
||||
def _load_or_create_account_hash_key(path: str | Path) -> bytes:
|
||||
"""Load a stable local HMAC key without ever exposing it in artifacts."""
|
||||
|
||||
key_path = Path(path).expanduser()
|
||||
parent_existed = key_path.parent.exists()
|
||||
key_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
if not parent_existed:
|
||||
try:
|
||||
key_path.parent.chmod(0o700)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
create_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
create_flags |= getattr(os, "O_CLOEXEC", 0)
|
||||
try:
|
||||
descriptor = os.open(str(key_path), create_flags, 0o600)
|
||||
except FileExistsError:
|
||||
descriptor = None
|
||||
if descriptor is not None:
|
||||
key = secrets.token_bytes(32)
|
||||
with os.fdopen(descriptor, "wb") as handle:
|
||||
handle.write(key)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
return _load_existing_account_hash_key(key_path)
|
||||
|
||||
|
||||
def _load_existing_account_hash_key(path: str | Path) -> bytes:
|
||||
key_path = Path(path).expanduser()
|
||||
if not key_path.is_file():
|
||||
raise ValueError(
|
||||
"QMT account hash key file does not exist; verification never "
|
||||
"creates a replacement key"
|
||||
)
|
||||
read_flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0)
|
||||
read_flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(str(key_path), read_flags)
|
||||
with os.fdopen(descriptor, "rb") as handle:
|
||||
key = handle.read()
|
||||
if len(key) < 32:
|
||||
raise ValueError("QMT account hash key must contain at least 32 bytes")
|
||||
try:
|
||||
mode = stat.S_IMODE(key_path.stat().st_mode)
|
||||
if os.name != "nt" and mode & 0o077:
|
||||
raise ValueError(
|
||||
"QMT account hash key file must not be group/world accessible"
|
||||
)
|
||||
except OSError as exc:
|
||||
raise ValueError("cannot validate QMT account hash key file") from exc
|
||||
return key
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
plan = subparsers.add_parser("plan", help="query QMT and write a shadow plan")
|
||||
plan.add_argument("--decision", required=True)
|
||||
plan.add_argument("--output", required=True)
|
||||
plan.add_argument(
|
||||
"--userdata-path", default=os.environ.get("QMT_USERDATA_PATH")
|
||||
)
|
||||
plan.add_argument("--account-id", default=os.environ.get("QMT_ACCOUNT_ID"))
|
||||
plan.add_argument(
|
||||
"--session-id",
|
||||
type=int,
|
||||
default=(
|
||||
int(os.environ["QMT_SESSION_ID"])
|
||||
if os.environ.get("QMT_SESSION_ID")
|
||||
else None
|
||||
),
|
||||
)
|
||||
plan.add_argument(
|
||||
"--max-query-duration-seconds",
|
||||
type=_bounded_non_negative_float(
|
||||
name="max-query-duration-seconds",
|
||||
maximum=MAX_SHADOW_QUERY_DURATION_SECONDS,
|
||||
),
|
||||
default=MAX_SHADOW_QUERY_DURATION_SECONDS,
|
||||
help=(
|
||||
"query-window ceiling; may be tightened but cannot exceed "
|
||||
f"{MAX_SHADOW_QUERY_DURATION_SECONDS:g}s"
|
||||
),
|
||||
)
|
||||
plan.add_argument(
|
||||
"--market-value-tolerance",
|
||||
type=_bounded_non_negative_float(
|
||||
name="market-value-tolerance",
|
||||
maximum=MAX_BROKER_ASSET_ABSOLUTE_TOLERANCE,
|
||||
),
|
||||
default=MAX_BROKER_ASSET_ABSOLUTE_TOLERANCE,
|
||||
help=(
|
||||
"asset identity tolerance; may be tightened but cannot exceed "
|
||||
f"{MAX_BROKER_ASSET_ABSOLUTE_TOLERANCE:g} CNY"
|
||||
),
|
||||
)
|
||||
plan.add_argument(
|
||||
"--account-hash-key-file",
|
||||
default=os.environ.get(
|
||||
"QMT_ACCOUNT_HASH_KEY_FILE",
|
||||
str(Path.home() / ".config" / "quant-os" / "account-hash.key"),
|
||||
),
|
||||
help=(
|
||||
"local 0600 HMAC key file; generated once when absent and never "
|
||||
"written to artifacts"
|
||||
),
|
||||
)
|
||||
|
||||
verify = subparsers.add_parser(
|
||||
"verify", help="verify a completed shadow-plan artifact set"
|
||||
)
|
||||
verify.add_argument("--manifest", required=True)
|
||||
verify.add_argument("--decision", required=True)
|
||||
verify.add_argument(
|
||||
"--account-hash-key-file",
|
||||
default=os.environ.get(
|
||||
"QMT_ACCOUNT_HASH_KEY_FILE",
|
||||
str(Path.home() / ".config" / "quant-os" / "account-hash.key"),
|
||||
),
|
||||
help=(
|
||||
"existing local 0600 HMAC key used to authenticate the broker "
|
||||
"observation; verify never creates a key"
|
||||
),
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
if args.command == "verify":
|
||||
account_hash_key = _load_existing_account_hash_key(
|
||||
args.account_hash_key_file
|
||||
)
|
||||
payload = verify_qmt_shadow_plan(
|
||||
args.manifest,
|
||||
decision_manifest=args.decision,
|
||||
account_hash_key=account_hash_key,
|
||||
)
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
userdata_path = _required(args.userdata_path, "QMT userdata path")
|
||||
account_id = _required(args.account_id, "QMT account id")
|
||||
session_id = (
|
||||
args.session_id
|
||||
if args.session_id is not None
|
||||
else secrets.randbelow(2_000_000_000) + 1
|
||||
)
|
||||
account_hash_key = _load_or_create_account_hash_key(
|
||||
args.account_hash_key_file
|
||||
)
|
||||
adapter = XtTraderLiveAdapter.from_xtquant(
|
||||
userdata_path=userdata_path,
|
||||
session_id=session_id,
|
||||
account_id=account_id,
|
||||
allow_live_orders=False,
|
||||
strategy_name="quant-os-shadow",
|
||||
)
|
||||
try:
|
||||
adapter.connect()
|
||||
result = build_qmt_shadow_plan(
|
||||
decision_manifest=args.decision,
|
||||
adapter=adapter,
|
||||
max_query_duration_seconds=args.max_query_duration_seconds,
|
||||
market_value_tolerance=args.market_value_tolerance,
|
||||
account_hash_key=account_hash_key,
|
||||
)
|
||||
paths = write_qmt_shadow_plan(result, args.output)
|
||||
verification = verify_qmt_shadow_plan(
|
||||
paths["manifest"],
|
||||
decision_manifest=args.decision,
|
||||
account_hash_key=account_hash_key,
|
||||
)
|
||||
ready = result["plan"]["status"] == "SHADOW_READY"
|
||||
payload = {
|
||||
"ok": ready,
|
||||
"artifact_written": True,
|
||||
"artifact_verified": verification["ok"],
|
||||
"plan_id": result["plan"]["plan_id"],
|
||||
"status": result["plan"]["status"],
|
||||
"blockers": result["plan"]["blockers"],
|
||||
"artifacts": paths,
|
||||
"read_only": True,
|
||||
}
|
||||
finally:
|
||||
adapter.close()
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
|
||||
return 0 if payload["ok"] else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Validate or execute the plain-Python cells in the Quant60 Colab notebook."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def load_notebook(path: Path) -> dict[str, Any]:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
if payload.get("nbformat") != 4 or not isinstance(payload.get("cells"), list):
|
||||
raise ValueError("expected a valid nbformat 4 notebook")
|
||||
return payload
|
||||
|
||||
|
||||
def code_cells(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
include_optional: bool,
|
||||
) -> list[tuple[int, str]]:
|
||||
output: list[tuple[int, str]] = []
|
||||
for index, cell in enumerate(payload["cells"]):
|
||||
if cell.get("cell_type") != "code":
|
||||
continue
|
||||
tags = cell.get("metadata", {}).get("tags", [])
|
||||
if "optional" in tags and not include_optional:
|
||||
continue
|
||||
source = cell.get("source")
|
||||
if not isinstance(source, list) or any(
|
||||
not isinstance(line, str) for line in source
|
||||
):
|
||||
raise ValueError(f"cell {index} source must be a string array")
|
||||
output.append((index, "".join(source)))
|
||||
return output
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("path", type=Path)
|
||||
parser.add_argument("--execute", action="store_true")
|
||||
parser.add_argument("--include-optional", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
payload = load_notebook(args.path.resolve())
|
||||
cells = code_cells(payload, include_optional=args.include_optional)
|
||||
for index, source in cells:
|
||||
compile(source, f"{args.path}:cell-{index}", "exec")
|
||||
if args.execute:
|
||||
namespace: dict[str, Any] = {"__name__": "__quant60_notebook__"}
|
||||
for index, source in cells:
|
||||
print(f"[quant60-notebook] executing cell {index}")
|
||||
exec(
|
||||
compile(source, f"{args.path}:cell-{index}", "exec"),
|
||||
namespace,
|
||||
namespace,
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"code_cells": len(cells),
|
||||
"executed": bool(args.execute),
|
||||
"optional_included": bool(args.include_optional),
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user