feat: add Quant OS A-share baseline
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user