345 lines
12 KiB
Python
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())
|