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