"""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())