Files
quant-os/platforms/qmt_research_runner.py
T

345 lines
12 KiB
Python

"""Native-Python runner for QMT research backtests.
All xtquant imports are deliberately lazy. Importing this module is safe on a
machine that does not have QMT, while ``run_qmt_backtest`` fails before the
strategy is started if the local client or data permission is unavailable.
"""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import importlib
import importlib.util
import json
import math
import os
import re
from pathlib import Path
from typing import Any, Dict, Optional
class QmtUnavailableError(RuntimeError):
"""Raised when the native QMT runtime cannot pass its read-only preflight."""
def _baseline_execution_defaults() -> Dict[str, Any]:
path = Path(__file__).resolve().parents[1] / "configs" / "baseline.json"
raw_bytes = path.read_bytes()
raw = json.loads(raw_bytes.decode("utf-8"))
fees = raw["fees"]
execution = raw["execution"]
strategy = raw["strategy"]
universe = raw["universe"]
index_symbol = str(universe["index_symbol"])
benchmark = (
index_symbol.replace(".XSHG", ".SH")
.replace(".XSHE", ".SZ")
.replace(".XBSE", ".BJ")
)
return {
"slippage_type": 2,
"slippage": float(execution["slippage_bps"]) / 10_000.0,
"max_vol_rate": float(execution["participation_rate"]),
"open_tax": 0.0,
"close_tax": float(fees["stamp_duty_rate"]),
"min_commission": float(fees["minimum_commission"]),
# QMT has no separate transfer-fee parameter in this backtest
# contract, so the proportional transfer fee is folded into the
# commission rate. The minimum-fee edge remains a declared engine
# difference rather than an exact local-fill claim.
"open_commission": (
float(fees["commission_rate"])
+ float(fees["transfer_fee_rate"])
),
"close_commission": (
float(fees["commission_rate"])
+ float(fees["transfer_fee_rate"])
),
"q60_baseline_config_sha256": hashlib.sha256(raw_bytes).hexdigest(),
"q60_rebalance_schedule": str(
strategy["rebalance_schedule"]
),
"q60_index_symbol": index_symbol,
"q60_qmt_sector_name": str(universe["qmt_sector_name"]),
"q60_exclude_st": bool(universe["exclude_st"]),
"benchmark": benchmark,
}
def _normalize_qmt_time(value: str, name: str, end_of_day: bool = False) -> str:
"""Accept compact/ISO inputs and emit qmttools' documented timestamp."""
text = str(value).strip()
parsed: Optional[dt.datetime] = None
formats = (
"%Y%m%d",
"%Y-%m-%d",
"%Y%m%d%H%M%S",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%dT%H:%M:%S",
)
used_date_only = False
for fmt in formats:
try:
parsed = dt.datetime.strptime(text, fmt)
used_date_only = fmt in {"%Y%m%d", "%Y-%m-%d"}
break
except ValueError:
continue
if parsed is None:
raise ValueError(
f"{name} must use YYYYMMDD, YYYY-MM-DD, or an ISO timestamp"
)
if used_date_only and end_of_day:
parsed = parsed.replace(hour=23, minute=59, second=59)
return parsed.strftime("%Y-%m-%d %H:%M:%S")
def _compact_time(value: str) -> str:
return dt.datetime.strptime(value, "%Y-%m-%d %H:%M:%S").strftime(
"%Y%m%d%H%M%S"
)
def build_qmt_parameters(
*,
stock_code: str,
start_time: str,
end_time: str,
period: str = "1d",
asset: float = 1_000_000.0,
account_id: str = "test",
overrides: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Build the documented minimum history/backtest parameter set."""
normalized_start = _normalize_qmt_time(start_time, "start_time")
normalized_end = _normalize_qmt_time(end_time, "end_time", end_of_day=True)
if normalized_start > normalized_end:
raise ValueError("start_time must not be after end_time")
normalized_stock = str(stock_code).strip().upper()
if not re.fullmatch(r"\d{6}\.(SH|SZ|BJ)", normalized_stock):
raise ValueError("stock_code must use QMT code.market form")
if period != "1d":
raise ValueError(
"quant60 QMT strategy uses completed daily bars; period must be 1d"
)
numeric_asset = float(asset)
if not math.isfinite(numeric_asset) or numeric_asset <= 0:
raise ValueError("asset must be finite and positive")
normalized_account = str(account_id).strip()
if not normalized_account:
raise ValueError("account_id must be non-empty")
params: Dict[str, Any] = {
"stock_code": normalized_stock,
"period": period,
"start_time": normalized_start,
"end_time": normalized_end,
"trade_mode": "backtest",
"quote_mode": "history",
"dividend_type": "front_ratio",
"asset": numeric_asset,
"account_id": normalized_account,
"q60_account_id": normalized_account,
}
params.update(_baseline_execution_defaults())
if overrides:
forbidden = {
"stock_code",
"period",
"start_time",
"end_time",
"trade_mode",
"quote_mode",
"asset",
"account_id",
"q60_account_id",
"benchmark",
}
invalid = forbidden.intersection(overrides)
if invalid:
raise ValueError(
"backtest runner does not allow overrides for "
+ ", ".join(sorted(invalid))
)
params.update(overrides)
bounded_rates = (
"max_vol_rate",
"open_tax",
"close_tax",
"open_commission",
"close_commission",
)
for name in bounded_rates:
value = float(params[name])
if not math.isfinite(value) or not (0 <= value <= 1):
raise ValueError(f"{name} must be finite and lie in [0, 1]")
params[name] = value
for name in ("slippage", "min_commission"):
value = float(params[name])
if not math.isfinite(value) or value < 0:
raise ValueError(f"{name} must be finite and non-negative")
params[name] = value
return params
def _load_xtquant() -> tuple[Any, Any, str]:
if importlib.util.find_spec("xtquant") is None:
raise QmtUnavailableError(
"xtquant is not installed; use the Python shipped/supported by the "
"licensed QMT research terminal"
)
try:
xtquant = importlib.import_module("xtquant")
qmttools = importlib.import_module("xtquant.qmttools")
xtdata = importlib.import_module("xtquant.xtdata")
except Exception as exc:
raise QmtUnavailableError(f"xtquant import failed: {exc}") from exc
version = str(
getattr(xtquant, "__version__", None)
or getattr(xtquant, "version", None)
or "unknown"
)
return qmttools, xtdata, version
def preflight_qmt(
strategy_file: os.PathLike[str] | str,
params: Dict[str, Any],
*,
check_data_access: bool = True,
) -> Dict[str, Any]:
"""Validate file, runtime and one read-only bar before starting a run."""
path = Path(strategy_file).expanduser().resolve()
if not path.is_file():
raise FileNotFoundError(path)
required = {
"stock_code",
"period",
"start_time",
"end_time",
"trade_mode",
"quote_mode",
}
missing = required.difference(params)
if missing:
raise ValueError("missing QMT parameters: " + ", ".join(sorted(missing)))
if params["trade_mode"] != "backtest" or params["quote_mode"] != "history":
raise ValueError("this runner is backtest/history only")
if params["period"] != "1d":
raise ValueError("this runner requires period=1d")
expected_benchmark = _baseline_execution_defaults()["benchmark"]
if params.get("benchmark") != expected_benchmark:
raise ValueError(
"QMT benchmark must match the configured PIT index "
f"{expected_benchmark}"
)
if not re.fullmatch(
r"\d{6}\.(SH|SZ|BJ)",
str(params["stock_code"]).strip().upper(),
):
raise ValueError("invalid QMT stock_code in params")
asset = float(params.get("asset", 0.0))
if not math.isfinite(asset) or asset <= 0:
raise ValueError("params.asset must be finite and positive")
qmttools, xtdata, version = _load_xtquant()
run_strategy_file = getattr(qmttools, "run_strategy_file", None)
if not callable(run_strategy_file):
raise QmtUnavailableError("xtquant.qmttools.run_strategy_file is unavailable")
report: Dict[str, Any] = {
"ok": True,
"strategy_file": str(path),
"xtquant_version": version,
"data_access_checked": bool(check_data_access),
"data_access_ok": None,
}
if check_data_access:
try:
sample = xtdata.get_market_data_ex(
field_list=["time"],
stock_list=[params["stock_code"]],
period=params["period"],
start_time=_compact_time(params["start_time"]),
end_time=_compact_time(params["end_time"]),
count=1,
fill_data=False,
)
except Exception as exc:
raise QmtUnavailableError(
"QMT read-only data preflight failed; check terminal login, "
f"research entitlement and local data: {exc}"
) from exc
frame = sample.get(params["stock_code"]) if isinstance(sample, dict) else None
if frame is None or len(frame) == 0:
raise QmtUnavailableError(
"QMT returned no preflight bar; verify entitlement and download "
"history for the requested symbol/date"
)
report["data_access_ok"] = True
return report
def run_qmt_backtest(
strategy_file: os.PathLike[str] | str,
params: Dict[str, Any],
*,
check_data_access: bool = True,
) -> Any:
"""Run ``xtquant.qmttools.run_strategy_file`` after fail-fast checks."""
preflight_qmt(
strategy_file,
params,
check_data_access=check_data_access,
)
qmttools, _, _ = _load_xtquant()
result = qmttools.run_strategy_file(
str(Path(strategy_file).expanduser().resolve()),
param=dict(params),
)
if result is None:
raise RuntimeError("QMT strategy returned no result")
return result
def _main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("strategy_file")
parser.add_argument("--stock-code", required=True)
parser.add_argument("--start", required=True)
parser.add_argument("--end", required=True)
parser.add_argument("--period", default="1d")
parser.add_argument("--asset", type=float, default=1_000_000.0)
parser.add_argument("--account-id", default="test")
parser.add_argument(
"--skip-data-preflight",
action="store_true",
help="Only for offline test doubles; real runs should keep preflight enabled.",
)
args = parser.parse_args(argv)
params = build_qmt_parameters(
stock_code=args.stock_code,
start_time=args.start,
end_time=args.end,
period=args.period,
asset=args.asset,
account_id=args.account_id,
)
result = run_qmt_backtest(
args.strategy_file,
params,
check_data_access=not args.skip_data_preflight,
)
summary = {
"result_type": type(result).__name__,
"has_backtest_index": callable(getattr(result, "get_backtest_index", None)),
}
print(json.dumps(summary, ensure_ascii=False, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(_main())