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