256 lines
8.2 KiB
Python
256 lines
8.2 KiB
Python
"""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())
|