feat: add Quant OS A-share baseline

This commit is contained in:
2026-07-26 12:54:04 +08:00
commit 48c5f64bbd
98 changed files with 31874 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Thin wrappers for platform-hosted and native backtest runtimes."""
+189
View File
@@ -0,0 +1,189 @@
"""Small deterministic JoinQuant API harness for local contract tests."""
import datetime
from types import SimpleNamespace
class FakePosition(object):
def __init__(self, total_amount=0, closeable_amount=None):
self.total_amount = int(total_amount)
self.closeable_amount = int(
total_amount if closeable_amount is None else closeable_amount
)
class FakeJoinQuantHarness(object):
"""Install fake hosted functions into a strategy module."""
def __init__(
self,
histories,
total_value=1_000_000.0,
positions=None,
st_symbols=None,
):
self.histories = {
symbol: [float(value) for value in values]
for symbol, values in histories.items()
}
self.options = {}
self.benchmark = None
self.order_cost = None
self.slippage = None
self.schedules = []
self.orders = []
self.order_styles = []
self.last_index_request = None
self.last_history_request = None
self.last_extras_request = None
self.st_symbols = set(st_symbols or [])
self.current_data = {
symbol: SimpleNamespace(
paused=False,
is_st=False,
high_limit=9999.0,
low_limit=0.01,
)
for symbol in histories
}
self.context = SimpleNamespace(
current_dt=datetime.datetime(2026, 7, 20, 9, 30),
previous_date=datetime.date(2026, 7, 17),
portfolio=SimpleNamespace(
total_value=float(total_value),
positions=dict(positions or {}),
)
)
self.g = SimpleNamespace()
def install(self, module):
module.g = self.g
module.set_option = self.set_option
module.run_daily = self.run_daily
module.attribute_history = self.attribute_history
module.history = self.history
module.get_index_stocks = self.get_index_stocks
module.get_extras = self.get_extras
module.set_benchmark = self.set_benchmark
module.set_order_cost = self.set_order_cost
module.set_slippage = self.set_slippage
module.OrderCost = self.OrderCost
module.PriceRelatedSlippage = self.PriceRelatedSlippage
module.LimitOrderStyle = self.LimitOrderStyle
module.order_target = self.order_target
module.get_current_data = self.get_current_data
return self
def set_option(self, key, value):
self.options[key] = value
def set_benchmark(self, symbol):
self.benchmark = symbol
@staticmethod
def OrderCost(**kwargs):
return SimpleNamespace(**kwargs)
@staticmethod
def PriceRelatedSlippage(value):
return SimpleNamespace(value=float(value))
@staticmethod
def LimitOrderStyle(limit_price):
return SimpleNamespace(
kind="limit",
limit_price=float(limit_price),
)
def set_order_cost(self, cost, type=None):
self.order_cost = {"cost": cost, "type": type}
def set_slippage(self, slippage, type=None):
self.slippage = {"slippage": slippage, "type": type}
def run_daily(self, callback, time="open"):
self.schedules.append((callback, time))
def attribute_history(
self,
symbol,
count,
unit="1d",
fields=None,
skip_paused=False,
df=False,
fq="pre",
):
del unit, fields, skip_paused, df, fq
return {"close": self.histories[symbol][-int(count) :]}
def history(
self,
count,
unit="1d",
field="close",
security_list=None,
df=False,
skip_paused=False,
fq="pre",
):
del unit, field, df, skip_paused, fq
symbols = list(security_list or self.histories)
self.last_history_request = {
"count": int(count),
"security_list": symbols,
}
return {
symbol: self.histories[symbol][-int(count) :]
for symbol in symbols
}
def get_index_stocks(self, index_symbol, date=None):
self.last_index_request = {
"index_symbol": index_symbol,
"date": date,
}
return sorted(self.histories)
def get_extras(
self,
info,
security_list,
end_date=None,
count=1,
df=False,
):
self.last_extras_request = {
"info": info,
"security_list": list(security_list),
"end_date": end_date,
"count": count,
"df": df,
}
return {
symbol: [symbol in self.st_symbols]
for symbol in security_list
}
def order_target(self, symbol, quantity, style=None):
self.orders.append((symbol, int(quantity)))
self.order_styles.append((symbol, style))
return SimpleNamespace(security=symbol, target_quantity=int(quantity))
def get_current_data(self):
return self.current_data
def initialize(self, module):
self.install(module)
module.initialize(self.context)
return self
def run_scheduled(self, index=0):
callback = self.schedules[index][0]
return callback(self.context)
def advance_session(self, days=1):
previous = self.context.current_dt.date()
self.context.previous_date = previous
self.context.current_dt += datetime.timedelta(days=int(days))
return self
+158
View File
@@ -0,0 +1,158 @@
"""Deterministic fake QMT ContextInfo and passorder recorder."""
import datetime
from types import SimpleNamespace
class FakeFrame(object):
def __init__(self, closes):
self._closes = list(closes)
def __getitem__(self, key):
if key != "close":
raise KeyError(key)
return list(self._closes)
class FakeQmtContext(object):
def __init__(
self,
histories,
asset=1_000_000.0,
positions=None,
bar_time=None,
trade_mode="backtest",
params=None,
st_periods=None,
):
self.histories = {
symbol: [float(value) for value in values]
for symbol, values in histories.items()
}
self.trade_mode = trade_mode
self.do_back_test = trade_mode == "backtest"
self._param = {"asset": float(asset), "trade_mode": trade_mode}
self._param.update(params or {})
self.barpos = 0
self._bar_time = bar_time or datetime.datetime(2026, 7, 20, 15, 0)
self._positions = list(positions or [])
self._asset = float(asset)
self.universe = []
self.last_market_request = None
self.last_sector_request = None
self.commission = None
self.slippage = None
self.st_periods = dict(st_periods or {})
def set_universe(self, symbols):
self.universe = list(symbols)
def set_commission(self, commission_type, commission_list):
self.commission = {
"type": int(commission_type),
"values": list(commission_list),
}
def set_slippage(self, slippage_type, slippage):
self.slippage = {
"type": int(slippage_type),
"value": float(slippage),
}
def get_bar_timetag(self, barpos):
del barpos
return int(self._bar_time.timestamp() * 1000)
def is_last_bar(self):
return True
def get_market_data_ex(
self,
fields,
stock_code,
period,
start_time,
end_time,
count,
dividend_type,
fill_data,
subscribe,
):
self.last_market_request = {
"fields": fields,
"stock_code": stock_code,
"period": period,
"start_time": start_time,
"end_time": end_time,
"count": count,
"dividend_type": dividend_type,
"fill_data": fill_data,
"subscribe": subscribe,
}
return {
symbol: FakeFrame(self.histories[symbol][-int(count) :])
for symbol in stock_code
}
def get_stock_list_in_sector(self, sector_name, timetag=None):
self.last_sector_request = {
"sector_name": sector_name,
"timetag": timetag,
}
return sorted(self.histories)
def get_his_st_data(self, symbol):
return dict(self.st_periods.get(symbol, {}))
def get_trade_detail_data(self, account_id, account_type, kind):
del account_id, account_type
if str(kind).lower() == "position":
return list(self._positions)
if str(kind).lower() == "account":
return [SimpleNamespace(total_asset=self._asset)]
return []
class FakeQmtHarness(object):
def __init__(self, context):
self.context = context
self.orders = []
def install(self, module):
module.passorder = self.passorder
return self
def passorder(
self,
operation,
order_type,
account_id,
order_code,
price_type,
price,
volume,
strategy_name,
quick_trade,
user_order_id,
context,
):
self.orders.append(
{
"operation": operation,
"order_type": order_type,
"account_id": account_id,
"order_code": order_code,
"price_type": price_type,
"price": price,
"volume": volume,
"strategy_name": strategy_name,
"quick_trade": quick_trade,
"user_order_id": user_order_id,
"context": context,
}
)
def run(self, module):
self.install(module)
module.init(self.context)
return module.handlebar(self.context)
+465
View File
@@ -0,0 +1,465 @@
# coding: utf-8
"""JoinQuant hosted wrapper.
Upload the generated bundle, not this source file, to JoinQuant. The bundle
tool replaces the marked import block with the exact portable core source.
"""
# __PORTABLE_CORE_BUNDLE_START__
from quant60.portable_core import build_rebalance_plan, convert_symbol
# __PORTABLE_CORE_BUNDLE_END__
import math
class PriceHistoryUnavailable(RuntimeError):
pass
class UnmanagedPositionError(RuntimeError):
pass
class ClockStateError(RuntimeError):
pass
# __BASELINE_CONFIG_START__
CONFIG = {
"universe_mode": "pit_index",
"index_symbol": "000905.XSHG",
"qmt_sector_name": "中证500",
"dedicated_account_required": True,
"exclude_st": True,
"universe": [
"600000.XSHG",
"000001.XSHE",
"300750.XSHE",
"000333.XSHE",
],
"lookback": 20,
# The first session close supplies the signal; the second session open
# supplies the hosted execution boundary, matching the QMT daily wrapper.
"skip": 0,
"top_n": 20,
"max_weight": 0.05,
"gross_target": 0.95,
"cash_buffer": 0.02,
"lot_size": 100,
"participation_rate": 0.1,
"slippage_bps": 2.0,
"commission_rate": 0.0002,
"minimum_commission": 5.0,
"stamp_duty_rate": 0.0005,
"transfer_fee_rate": 0.00001,
"rebalance_schedule": "weekly_first_close",
}
# __BASELINE_CONFIG_END__
def initialize(context):
"""Configure a daily-open clock that executes a first-week-close signal."""
set_option("avoid_future_data", True)
set_option("use_real_price", True)
g.quant60_config = dict(CONFIG)
if g.quant60_config.get("rebalance_schedule") != "weekly_first_close":
raise ValueError("unsupported rebalance_schedule")
if g.quant60_config.get("universe_mode") not in ("pit_index", "fixed"):
raise ValueError("unsupported universe_mode")
if (
g.quant60_config.get("universe_mode") == "pit_index"
and not g.quant60_config.get("dedicated_account_required")
):
raise ValueError("pit_index mode requires a dedicated account")
set_benchmark(g.quant60_config["index_symbol"])
commission = (
float(g.quant60_config["commission_rate"])
+ float(g.quant60_config["transfer_fee_rate"])
)
set_order_cost(
OrderCost(
open_tax=0.0,
close_tax=float(g.quant60_config["stamp_duty_rate"]),
open_commission=commission,
close_commission=commission,
close_today_commission=0.0,
min_commission=float(
g.quant60_config["minimum_commission"]
),
),
type="stock",
)
# JoinQuant applies half of PriceRelatedSlippage on each side. The local
# slippage_bps setting is one-sided, hence the explicit factor of two.
set_slippage(
PriceRelatedSlippage(
2.0 * float(g.quant60_config["slippage_bps"]) / 10000.0
),
type="stock",
)
set_option(
"order_volume_ratio",
float(g.quant60_config["participation_rate"]),
)
g.quant60_last_plan = None
g.quant60_last_universe = None
g.quant60_last_universe_as_of = None
g.quant60_skipped_orders = []
g.quant60_pending_first_session = None
g.quant60_last_callback_date = None
run_daily(rebalance, time="open")
def _close_history(symbol, count):
raw = attribute_history(
symbol,
count,
unit="1d",
fields=["close"],
skip_paused=False,
df=False,
fq="pre",
)
values = list(raw.get("close", [])) if hasattr(raw, "get") else []
if len(values) != int(count):
raise PriceHistoryUnavailable(
"%s needs %d completed daily closes, got %d"
% (symbol, int(count), len(values))
)
output = []
for value in values:
try:
number = float(value)
except (TypeError, ValueError):
raise PriceHistoryUnavailable(
"%s contains a missing/non-numeric close" % symbol
)
if not math.isfinite(number) or number <= 0.0:
raise PriceHistoryUnavailable(
"%s contains a missing/non-positive close" % symbol
)
output.append(number)
return output
def _close_histories(symbols, count):
raw = history(
int(count),
unit="1d",
field="close",
security_list=list(symbols),
df=False,
skip_paused=False,
fq="pre",
)
if not hasattr(raw, "get"):
raise PriceHistoryUnavailable("history returned an invalid payload")
output = {}
for symbol in symbols:
values = list(raw.get(symbol, []))
if len(values) != int(count):
raise PriceHistoryUnavailable(
"%s needs %d completed daily closes, got %d"
% (symbol, int(count), len(values))
)
clean = []
for value in values:
try:
number = float(value)
except (TypeError, ValueError):
raise PriceHistoryUnavailable(
"%s contains a missing/non-numeric close" % symbol
)
if not math.isfinite(number) or number <= 0.0:
raise PriceHistoryUnavailable(
"%s contains a missing/non-positive close" % symbol
)
clean.append(number)
output[convert_symbol(symbol, "canonical")] = clean
return output
def _decision_universe(context):
config = g.quant60_config
if config.get("universe_mode") == "fixed":
values = list(config["universe"])
else:
as_of = _as_date(
getattr(context, "previous_date", None),
"previous_date",
)
values = get_index_stocks(
config["index_symbol"],
date=as_of,
)
if not values:
raise PriceHistoryUnavailable(
"no PIT index members for %s on %s"
% (config["index_symbol"], as_of.isoformat())
)
g.quant60_last_universe_as_of = as_of.isoformat()
normalized = sorted(
set(convert_symbol(symbol, "joinquant") for symbol in values)
)
if not normalized:
raise PriceHistoryUnavailable("decision universe is empty")
g.quant60_last_universe = list(normalized)
return normalized
def _filter_st_universe(context, symbols):
if not g.quant60_config.get("exclude_st", True):
return list(symbols)
as_of = _as_date(
getattr(context, "previous_date", None),
"previous_date",
)
raw = get_extras(
"is_st",
list(symbols),
end_date=as_of,
count=1,
df=False,
)
if not hasattr(raw, "get"):
raise PriceHistoryUnavailable(
"get_extras is_st returned an invalid payload"
)
output = []
for symbol in symbols:
values = list(raw.get(symbol, []))
if len(values) != 1:
raise PriceHistoryUnavailable(
"%s needs one PIT is_st value on %s"
% (symbol, as_of.isoformat())
)
try:
numeric = float(values[0])
except (TypeError, ValueError):
raise PriceHistoryUnavailable(
"%s contains a non-boolean PIT is_st value" % symbol
)
if not math.isfinite(numeric) or numeric not in (0.0, 1.0):
raise PriceHistoryUnavailable(
"%s contains an invalid PIT is_st value" % symbol
)
if not bool(numeric):
output.append(symbol)
if not output:
raise PriceHistoryUnavailable(
"PIT ST exclusion removed the entire decision universe"
)
g.quant60_last_universe = list(output)
return output
def _position_amount(position, name, default=0):
value = getattr(position, name, default)
return int(value or 0)
def _snapshot_portfolio(context, managed):
current = {}
sellable = {}
managed = set(convert_symbol(symbol, "canonical") for symbol in managed)
dynamic = g.quant60_config.get("universe_mode") == "pit_index"
positions = getattr(context.portfolio, "positions", {})
for symbol, position in positions.items():
canonical = convert_symbol(str(symbol), "canonical")
quantity = _position_amount(position, "total_amount")
if quantity and canonical not in managed and not dynamic:
raise UnmanagedPositionError(
"account contains unmanaged position %s; use a dedicated "
"strategy account or reconcile ownership before trading" % canonical
)
if canonical not in managed and not dynamic:
continue
current[canonical] = quantity
sellable[canonical] = _position_amount(
position, "closeable_amount", current[canonical]
)
equity = float(getattr(context.portfolio, "total_value", 0.0) or 0.0)
return current, sellable, equity
def _is_star_board(symbol):
code = str(symbol).split(".", 1)[0]
return code.startswith("688") or code.startswith("689")
def _order_request(symbol, delta):
"""Build a fail-closed hosted order request from current market data.
JoinQuant rejects an unprotected market order for STAR Market securities.
A limit at the exchange daily bound supplies equivalent worst-price
protection while keeping the rebalance target expressed in exact shares.
"""
try:
item = get_current_data()[symbol]
except Exception as exc:
g.quant60_skipped_orders.append(
{"symbol": symbol, "reason": "current_data_unavailable", "detail": str(exc)}
)
return None
if bool(getattr(item, "paused", False)):
g.quant60_skipped_orders.append(
{"symbol": symbol, "reason": "paused"}
)
return None
if not _is_star_board(symbol):
return {"style": None}
field = "high_limit" if int(delta) > 0 else "low_limit"
raw_price = getattr(item, field, None)
try:
protection_price = float(raw_price)
except (TypeError, ValueError):
protection_price = float("nan")
if (
not math.isfinite(protection_price)
or protection_price <= 0.0
or protection_price >= 10000.0
):
g.quant60_skipped_orders.append(
{
"symbol": symbol,
"reason": "star_protection_price_invalid",
"detail": field,
}
)
return None
try:
style = LimitOrderStyle(protection_price)
except Exception as exc:
g.quant60_skipped_orders.append(
{
"symbol": symbol,
"reason": "star_protection_style_unavailable",
"detail": str(exc),
}
)
return None
return {"style": style}
def compute_plan(context):
"""Return the portable plan without submitting orders."""
config = g.quant60_config
required = int(config["lookback"]) + int(config["skip"]) + 1
platform_universe = _decision_universe(context)
platform_universe = _filter_st_universe(
context,
platform_universe,
)
price_history = _close_histories(platform_universe, required)
current, sellable, equity = _snapshot_portfolio(
context,
platform_universe,
)
if equity <= 0:
raise ValueError("portfolio.total_value must be positive")
plan = build_rebalance_plan(
price_history=price_history,
current=current,
sellable=sellable,
equity=equity,
lookback=config["lookback"],
skip=config["skip"],
top_n=config["top_n"],
max_weight=config["max_weight"],
gross_target=config["gross_target"],
cash_buffer=config["cash_buffer"],
lot_size=config["lot_size"],
)
return plan
def _execute_rebalance(context):
"""Submit the portable plan's exact executable share deltas."""
plan = compute_plan(context)
order_deltas = plan["orders"]
current, _, unused_equity = _snapshot_portfolio(
context,
g.quant60_last_universe or g.quant60_config["universe"],
)
del unused_equity
all_symbols = set(current)
all_symbols.update(order_deltas)
# Exits first. The requested target is current + executable delta, not the
# unconstrained portfolio target; this preserves T+1 sellable caps and odd
# lots from the portable plan.
for canonical in sorted(
all_symbols,
key=lambda item: (
int(order_deltas.get(item, 0)) > 0,
item,
),
):
delta = int(order_deltas.get(canonical, 0))
if delta == 0:
continue
platform_symbol = convert_symbol(canonical, "joinquant")
request = _order_request(platform_symbol, delta)
if request is None:
continue
target = int(current.get(canonical, 0)) + delta
if request["style"] is None:
order_target(platform_symbol, target)
else:
order_target(
platform_symbol,
target,
style=request["style"],
)
g.quant60_last_plan = plan
return plan
def _as_date(value, name):
if hasattr(value, "date"):
value = value.date()
if not hasattr(value, "isocalendar"):
raise ClockStateError("%s must be a date/datetime" % name)
return value
def rebalance(context):
"""Execute on the session immediately after a week's first close.
A daily state machine is used instead of ``run_weekly(..., 2)`` so a
one-session holiday week still executes on the next available session.
"""
current_date = _as_date(getattr(context, "current_dt", None), "current_dt")
previous_date = _as_date(
getattr(context, "previous_date", None),
"previous_date",
)
if g.quant60_last_callback_date == current_date:
return None
pending = g.quant60_pending_first_session
is_first_session = (
previous_date.isocalendar()[:2]
!= current_date.isocalendar()[:2]
)
if pending is not None:
if previous_date != pending:
raise ClockStateError(
"pending first-session close is not the immediately "
"previous trading session"
)
# Consume the clock token before crossing any hosted order boundary.
# A partial API failure must not make a callback retry duplicate the
# already accepted prefix of the order list.
g.quant60_pending_first_session = (
current_date if is_first_session else None
)
g.quant60_last_callback_date = current_date
plan = _execute_rebalance(context)
return plan
if is_first_session:
g.quant60_pending_first_session = current_date
g.quant60_last_callback_date = current_date
return None
+866
View File
@@ -0,0 +1,866 @@
"""Qlib 0.9.7 native momentum backtest and Alpha158/LightGBM workflow.
This module never downloads data. A real, user-authorized ``provider_uri`` is
required. Qlib, pandas and LightGBM are imported only inside execution paths.
"""
from __future__ import annotations
import argparse
from collections import deque
import datetime as dt
import hashlib
import importlib
import importlib.util
import json
import math
import os
from pathlib import Path
import tempfile
from typing import Any, Dict, Iterable, Mapping, Optional, Sequence
from quant60.portable_core import momentum_score
class QlibUnavailableError(RuntimeError):
"""Raised when Qlib or its configured provider cannot be used."""
def _table_audit(table: Any) -> Optional[Dict[str, Any]]:
"""Return compact deterministic evidence for a pandas Series/DataFrame."""
to_json = getattr(table, "to_json", None)
if not callable(to_json):
return None
encoded = to_json(
orient="split",
date_format="iso",
date_unit="ns",
double_precision=15,
).encode("utf-8")
columns = [
str(value)
for value in getattr(table, "columns", [getattr(table, "name", "value")])
]
audit: Dict[str, Any] = {
"row_count": int(len(table)),
"columns": columns,
"content_sha256": hashlib.sha256(encoded).hexdigest(),
}
index = getattr(table, "index", None)
if index is not None and len(index):
audit["period_start"] = str(index[0])
audit["period_end"] = str(index[-1])
def finite_values(name: str) -> list[float]:
if name not in columns:
return []
try:
raw = table[name].dropna().tolist()
values = [float(value) for value in raw]
except (KeyError, TypeError, ValueError, AttributeError):
return []
return [value for value in values if math.isfinite(value)]
returns = finite_values("return")
benchmark = finite_values("bench")
costs = finite_values("cost")
turnover = finite_values("turnover")
if returns:
wealth = 1.0
peak = 1.0
max_drawdown = 0.0
for value in returns:
wealth *= 1.0 + value
peak = max(peak, wealth)
max_drawdown = min(max_drawdown, wealth / peak - 1.0)
audit["derived_performance"] = {
"cumulative_return": wealth - 1.0,
"max_drawdown": max_drawdown,
"benchmark_cumulative_return": (
math.prod(1.0 + value for value in benchmark) - 1.0
if benchmark
else None
),
"total_cost": sum(costs) if costs else None,
"total_turnover": sum(turnover) if turnover else None,
}
return audit
def _mapping_table_audits(values: Any) -> Dict[str, Any]:
if not isinstance(values, Mapping):
return {}
output: Dict[str, Any] = {}
for frequency, item in sorted(values.items(), key=lambda pair: str(pair[0])):
table = item[0] if isinstance(item, (tuple, list)) and item else item
audit = _table_audit(table)
if audit is not None:
output[str(frequency)] = audit
return output
def _version_tuple(value: str) -> tuple[int, ...]:
parts = []
for chunk in str(value).split("."):
digits = "".join(character for character in chunk if character.isdigit())
if not digits:
break
parts.append(int(digits))
return tuple(parts)
def _load_qlib() -> Dict[str, Any]:
if importlib.util.find_spec("qlib") is None:
raise QlibUnavailableError(
"pyqlib 0.9.7 is not installed in this research environment"
)
try:
qlib = importlib.import_module("qlib")
data_module = importlib.import_module("qlib.data")
executor_module = importlib.import_module("qlib.backtest.executor")
backtest_module = importlib.import_module("qlib.backtest")
strategy_module = importlib.import_module(
"qlib.contrib.strategy.signal_strategy"
)
except Exception as exc:
raise QlibUnavailableError(f"Qlib import failed: {exc}") from exc
version = str(getattr(qlib, "__version__", "unknown"))
if version != "unknown" and _version_tuple(version) != (0, 9, 7):
raise QlibUnavailableError(
f"Qlib exactly 0.9.7 is required by this pinned adapter; found {version}"
)
return {
"qlib": qlib,
"D": data_module.D,
"SimulatorExecutor": executor_module.SimulatorExecutor,
"backtest": backtest_module.backtest,
"TopkDropoutStrategy": strategy_module.TopkDropoutStrategy,
"version": version,
}
def _validate_provider(provider_uri: str) -> str:
value = str(provider_uri or "").strip()
if not value:
raise ValueError("provider_uri is required; no public data is downloaded")
if "://" not in value:
path = Path(value).expanduser().resolve()
if not path.is_dir():
raise FileNotFoundError(
f"Qlib provider_uri does not exist: {path}"
)
return str(path)
return value
def _infer_columns(frame: Any) -> tuple[str, str, str]:
columns = [str(column) for column in frame.columns]
instrument = next(
(
name
for name in ("instrument", "level_0", "level_1")
if name in columns
),
None,
)
datetime_column = next(
(
name
for name in ("datetime", "date", "level_1", "level_0")
if name in columns and name != instrument
),
None,
)
close = next(
(name for name in ("$close", "close") if name in columns),
None,
)
if close is None:
non_index = [
name
for name in columns
if name not in {instrument, datetime_column}
]
close = non_index[0] if len(non_index) == 1 else None
if instrument is None or datetime_column is None or close is None:
raise ValueError(
f"unexpected D.features columns/index after reset: {columns}"
)
# Unnamed MultiIndexes may reset as level_0/level_1. Infer their order
# from the first non-null value instead of assuming a Qlib build detail.
if instrument.startswith("level_") and datetime_column.startswith("level_"):
sample = frame[[instrument, datetime_column]].dropna().head(1)
if not sample.empty:
first = sample.iloc[0][instrument]
if not isinstance(first, str) or not (
first.upper().startswith(("SH", "SZ", "BJ"))
or first[:1].isdigit()
):
instrument, datetime_column = datetime_column, instrument
return instrument, datetime_column, close
def generate_portable_momentum_signal(
D: Any,
instruments: Any,
*,
start_time: str,
end_time: str,
feature_start_time: Optional[str] = None,
lookback: int = 20,
skip: int = 0,
rebalance: str = "weekly",
) -> Any:
"""Use ``D.features`` and portable momentum to build a Qlib score Series.
The result is indexed ``(datetime, instrument)``. Qlib's
``BaseSignalStrategy`` reads the prior-step score (``shift=1``), so a score
computed on completed bar T is executed on the next engine step.
"""
pandas = importlib.import_module("pandas")
if feature_start_time is None:
start = dt.datetime.fromisoformat(str(start_time)[:10])
padding_days = max(14, 3 * (int(lookback) + int(skip) + 2))
feature_start_time = (start - dt.timedelta(days=padding_days)).date().isoformat()
if rebalance not in {"daily", "weekly"}:
raise ValueError("rebalance must be daily or weekly")
instrument_query = instruments
if isinstance(instruments, str):
instrument_query = D.instruments(market=instruments)
raw = D.features(
instrument_query,
["$close"],
start_time=feature_start_time,
end_time=end_time,
freq="day",
)
if raw is None or len(raw) == 0:
raise QlibUnavailableError("D.features returned no close data")
records = raw.reset_index()
instrument_col, datetime_col, close_col = _infer_columns(records)
records[datetime_col] = pandas.to_datetime(records[datetime_col])
records = records.sort_values([instrument_col, datetime_col])
rows = []
start_bound = pandas.Timestamp(start_time)
end_bound = pandas.Timestamp(end_time)
for instrument, group in records.groupby(instrument_col, sort=True):
prices = deque(maxlen=int(lookback) + int(skip) + 1)
for _, row in group.iterrows():
value = row[close_col]
if value is None or (
isinstance(value, float) and not math.isfinite(value)
):
prices.append(float("nan"))
continue
prices.append(float(value))
score = momentum_score(prices, lookback=lookback, skip=skip)
when = pandas.Timestamp(row[datetime_col])
if score is None or when < start_bound or when > end_bound:
continue
rows.append(
{
"datetime": when,
"instrument": str(instrument),
"score": float(score),
}
)
if not rows:
raise QlibUnavailableError(
"no momentum signal was produced; provider history may be shorter "
"than lookback + skip + 1"
)
signal = pandas.DataFrame(rows)
if rebalance == "weekly":
# Select the true first provider session of each week, rather than
# the first date on which a warmed-up signal happens to exist.
calendar = records[[datetime_col]].drop_duplicates().copy()
calendar = calendar[
(calendar[datetime_col] >= start_bound)
& (calendar[datetime_col] <= end_bound)
]
calendar["_week"] = calendar[datetime_col].dt.to_period("W")
first_dates = set(
calendar.groupby("_week")[datetime_col].min().tolist()
)
signal = signal[signal["datetime"].isin(first_dates)]
if signal.empty:
raise QlibUnavailableError(
"no signal exists on a first weekly provider session"
)
return signal.set_index(["datetime", "instrument"]).sort_index()["score"]
def run_native_momentum_backtest(
*,
provider_uri: str,
market: str,
benchmark: str,
start_time: str,
end_time: str,
feature_start_time: Optional[str] = None,
lookback: int = 20,
skip: int = 0,
rebalance: str = "weekly",
topk: int = 50,
n_drop: int = 5,
account: float = 10_000_000.0,
deal_price: str = "open",
open_cost: float = 0.0003,
close_cost: float = 0.0008,
min_cost: float = 5.0,
limit_threshold: float = 0.095,
) -> Dict[str, Any]:
"""Run the official SimulatorExecutor + TopkDropoutStrategy stack."""
provider = _validate_provider(provider_uri)
api = _load_qlib()
constant = importlib.import_module("qlib.constant")
api["qlib"].init(provider_uri=provider, region=constant.REG_CN)
signal = generate_portable_momentum_signal(
api["D"],
market,
start_time=start_time,
end_time=end_time,
feature_start_time=feature_start_time,
lookback=lookback,
skip=skip,
rebalance=rebalance,
)
strategy = api["TopkDropoutStrategy"](
signal=signal,
topk=int(topk),
n_drop=int(n_drop),
hold_thresh=1,
only_tradable=True,
forbid_all_trade_at_limit=False,
)
executor = api["SimulatorExecutor"](
time_per_step="day",
generate_portfolio_metrics=True,
)
portfolio_metrics, indicators = api["backtest"](
start_time=start_time,
end_time=end_time,
strategy=strategy,
executor=executor,
account=float(account),
benchmark=benchmark,
exchange_kwargs={
"freq": "day",
"limit_threshold": float(limit_threshold),
"deal_price": deal_price,
"open_cost": float(open_cost),
"close_cost": float(close_cost),
"min_cost": float(min_cost),
},
)
return {
"qlib_version": api["version"],
"signal": signal,
"portfolio_metrics": portfolio_metrics,
"indicators": indicators,
"clock_contract": (
"weekly_first_provider_session_close_to_next_engine_step"
if rebalance == "weekly"
else "T_close_signal_to_T_plus_1_engine_step"
),
"strategy_fidelity": (
"portable_signal_only_research_bridge_not_quant60_"
"target_or_order_parity"
),
"a_share_rule_fidelity": (
"uniform_limit_threshold_approximation_not_point_in_time_"
"board_or_ST_rules"
),
}
def build_alpha158_lightgbm_task(
*,
market: str,
train: Sequence[str],
valid: Sequence[str],
test: Sequence[str],
topk: int = 50,
n_drop: int = 5,
benchmark: str = "SH000300",
account: float = 10_000_000.0,
) -> Dict[str, Any]:
"""Return a Qlib 0.9.7 task plus official portfolio analysis config."""
if not (len(train) == len(valid) == len(test) == 2):
raise ValueError("train/valid/test must each contain start and end")
handler = {
"start_time": train[0],
"end_time": test[1],
"fit_start_time": train[0],
"fit_end_time": train[1],
"instruments": market,
}
task = {
"model": {
"class": "LGBModel",
"module_path": "qlib.contrib.model.gbdt",
"kwargs": {
"loss": "mse",
"learning_rate": 0.05,
"num_leaves": 64,
"max_depth": 8,
"subsample": 0.9,
"colsample_bytree": 0.9,
"lambda_l1": 1.0,
"lambda_l2": 1.0,
"num_threads": 4,
},
},
"dataset": {
"class": "DatasetH",
"module_path": "qlib.data.dataset",
"kwargs": {
"handler": {
"class": "Alpha158",
"module_path": "qlib.contrib.data.handler",
"kwargs": handler,
},
"segments": {
"train": tuple(train),
"valid": tuple(valid),
"test": tuple(test),
},
},
},
}
portfolio_analysis = {
"executor": {
"class": "SimulatorExecutor",
"module_path": "qlib.backtest.executor",
"kwargs": {
"time_per_step": "day",
"generate_portfolio_metrics": True,
},
},
"strategy": {
"class": "TopkDropoutStrategy",
"module_path": "qlib.contrib.strategy.signal_strategy",
"kwargs": {
# The runner replaces this with the actual (model, dataset).
"signal": None,
"topk": int(topk),
"n_drop": int(n_drop),
},
},
"backtest": {
"start_time": test[0],
"end_time": test[1],
"account": float(account),
"benchmark": benchmark,
"exchange_kwargs": {
"freq": "day",
"limit_threshold": 0.095,
"deal_price": "open",
"open_cost": 0.0003,
"close_cost": 0.0008,
"min_cost": 5.0,
},
},
}
return {"task": task, "portfolio_analysis": portfolio_analysis}
def run_alpha158_lightgbm_workflow(
*,
provider_uri: str,
market: str,
benchmark: str,
train: Sequence[str],
valid: Sequence[str],
test: Sequence[str],
experiment_name: str = "quant60_alpha158_lightgbm",
topk: int = 50,
n_drop: int = 5,
) -> Dict[str, Any]:
"""Fit, record signals and run PortAnaRecord with official Qlib APIs."""
provider = _validate_provider(provider_uri)
# Qlib 0.9.7 defaults to a local MLflow file store. MLflow 3.14 requires
# an explicit acknowledgement before it will open that backend. Keep the
# acknowledgement local to this process; a caller-provided tracking
# configuration still owns the actual destination.
os.environ.setdefault("MLFLOW_ALLOW_FILE_STORE", "true")
api = _load_qlib()
constant = importlib.import_module("qlib.constant")
utils = importlib.import_module("qlib.utils")
workflow = importlib.import_module("qlib.workflow")
records = importlib.import_module("qlib.workflow.record_temp")
api["qlib"].init(provider_uri=provider, region=constant.REG_CN)
config = build_alpha158_lightgbm_task(
market=market,
benchmark=benchmark,
train=train,
valid=valid,
test=test,
topk=topk,
n_drop=n_drop,
)
model = utils.init_instance_by_config(config["task"]["model"])
dataset = utils.init_instance_by_config(config["task"]["dataset"])
config["portfolio_analysis"]["strategy"]["kwargs"]["signal"] = (model, dataset)
with workflow.R.start(experiment_name=experiment_name):
model.fit(dataset)
workflow.R.save_objects(**{"trained_model.pkl": model})
recorder = workflow.R.get_recorder()
records.SignalRecord(model, dataset, recorder).generate()
records.SigAnaRecord(recorder).generate()
portfolio_record = records.PortAnaRecord(
recorder,
config["portfolio_analysis"],
"day",
)
portfolio_record.generate()
try:
report = recorder.load_object(
"portfolio_analysis/report_normal_1day.pkl"
)
except Exception as exc:
raise QlibUnavailableError(
"PortAnaRecord completed without a readable 1day report"
) from exc
report_audit = _table_audit(report)
if report_audit is None:
raise QlibUnavailableError(
"Qlib portfolio report is not a pandas-compatible table"
)
recorded_metrics = {
str(key): float(value)
for key, value in sorted(recorder.list_metrics().items())
if math.isfinite(float(value))
}
artifact_paths = sorted(
str(value)
for value in recorder.list_artifacts("portfolio_analysis")
)
recorder_id = getattr(recorder, "id", None)
return {
"qlib_version": api["version"],
"experiment_name": experiment_name,
"recorder_id": recorder_id,
"mlflow_local_file_store_explicitly_allowed": (
os.environ.get("MLFLOW_ALLOW_FILE_STORE") == "true"
),
"task": config["task"],
"portfolio_analysis": config["portfolio_analysis"],
"recorder_evidence": {
"portfolio_report": report_audit,
"recorded_metrics": recorded_metrics,
"portfolio_artifact_paths": artifact_paths,
},
}
def _canonical_cli_date(value: Optional[str], *, name: str) -> str:
raw = str(value or "").strip()
if not raw:
raise ValueError(f"{name} is required")
try:
parsed = dt.date.fromisoformat(raw)
except ValueError as exc:
raise ValueError(f"{name} must be an ISO date (YYYY-MM-DD)") from exc
if parsed.isoformat() != raw:
raise ValueError(f"{name} must be an ISO date (YYYY-MM-DD)")
return raw
def _validate_nonempty(value: str, *, name: str) -> str:
normalized = str(value or "").strip()
if not normalized:
raise ValueError(f"{name} must not be empty")
return normalized
def _validate_common_cli_args(args: argparse.Namespace) -> None:
args.provider_uri = _validate_nonempty(
args.provider_uri,
name="provider-uri",
)
args.market = _validate_nonempty(args.market, name="market")
args.benchmark = _validate_nonempty(args.benchmark, name="benchmark")
if args.topk <= 0:
raise ValueError("topk must be greater than zero")
if args.n_drop < 0:
raise ValueError("n-drop must be zero or greater")
if args.n_drop > args.topk:
raise ValueError("n-drop must not exceed topk")
if args.output_json is not None:
args.output_json = _validate_nonempty(
args.output_json,
name="output-json",
)
def _validated_momentum_dates(args: argparse.Namespace) -> tuple[str, str, Optional[str]]:
start = _canonical_cli_date(args.start, name="start")
end = _canonical_cli_date(args.end, name="end")
if start > end:
raise ValueError("start must be on or before end")
feature_start = None
if args.feature_start is not None:
feature_start = _canonical_cli_date(
args.feature_start,
name="feature-start",
)
if feature_start > start:
raise ValueError("feature-start must be on or before start")
if args.lookback <= 0:
raise ValueError("lookback must be greater than zero")
if args.skip < 0:
raise ValueError("skip must be zero or greater")
return start, end, feature_start
def _validated_alpha158_segments(
args: argparse.Namespace,
) -> tuple[tuple[str, str], tuple[str, str], tuple[str, str]]:
train = (
_canonical_cli_date(args.train_start, name="train-start"),
_canonical_cli_date(args.train_end, name="train-end"),
)
valid = (
_canonical_cli_date(args.valid_start, name="valid-start"),
_canonical_cli_date(args.valid_end, name="valid-end"),
)
test = (
_canonical_cli_date(args.test_start, name="test-start"),
_canonical_cli_date(args.test_end, name="test-end"),
)
for name, segment in (("train", train), ("valid", valid), ("test", test)):
if segment[0] > segment[1]:
raise ValueError(f"{name}-start must be on or before {name}-end")
if train[1] >= valid[0]:
raise ValueError("train and valid segments must be ordered and non-overlapping")
if valid[1] >= test[0]:
raise ValueError("valid and test segments must be ordered and non-overlapping")
args.experiment_name = _validate_nonempty(
args.experiment_name,
name="experiment-name",
)
return train, valid, test
def _portfolio_frequencies(portfolio_metrics: Any) -> list[str]:
if isinstance(portfolio_metrics, Mapping):
values = portfolio_metrics.keys()
elif isinstance(portfolio_metrics, (list, tuple, set, frozenset)):
values = portfolio_metrics
else:
values = ()
return sorted(str(value) for value in values)
def _momentum_cli_summary(result: Mapping[str, Any]) -> Dict[str, Any]:
signal = result.get("signal")
signal_table = (
signal.to_frame(name="score")
if callable(getattr(signal, "to_frame", None))
else None
)
return {
"workflow": "momentum",
"qlib_version": str(result.get("qlib_version", "unknown")),
"signal_rows": len(signal) if signal is not None else 0,
"portfolio_frequencies": _portfolio_frequencies(
result.get("portfolio_metrics")
),
"signal_evidence": _table_audit(signal_table),
"portfolio_report_evidence": _mapping_table_audits(
result.get("portfolio_metrics")
),
"indicator_evidence": _mapping_table_audits(
result.get("indicators")
),
"clock_contract": result.get("clock_contract"),
"strategy_fidelity": result.get("strategy_fidelity"),
"a_share_rule_fidelity": result.get("a_share_rule_fidelity"),
"investment_value_claim": False,
}
def _alpha158_cli_summary(
result: Mapping[str, Any],
*,
market: str,
benchmark: str,
train: Sequence[str],
valid: Sequence[str],
test: Sequence[str],
) -> Dict[str, Any]:
recorder_id = result.get("recorder_id")
return {
"workflow": "alpha158",
"qlib_version": str(result.get("qlib_version", "unknown")),
"experiment_name": str(result.get("experiment_name", "")),
"recorder_id": None if recorder_id is None else str(recorder_id),
"mlflow_local_file_store_explicitly_allowed": bool(
result.get("mlflow_local_file_store_explicitly_allowed", False)
),
"market": market,
"benchmark": benchmark,
"segments": {
"train": list(train),
"valid": list(valid),
"test": list(test),
},
"recorder_evidence": result.get("recorder_evidence"),
"investment_value_claim": False,
}
def _write_cli_json(path: str, payload: Mapping[str, Any]) -> None:
destination = Path(path).expanduser().resolve()
if destination.exists() and destination.is_dir():
raise ValueError(f"output-json points to a directory: {destination}")
destination.parent.mkdir(parents=True, exist_ok=True)
encoded = (
json.dumps(
dict(payload),
ensure_ascii=False,
indent=2,
sort_keys=True,
allow_nan=False,
)
+ "\n"
).encode("utf-8")
descriptor, temporary = tempfile.mkstemp(
prefix=f".{destination.name}.",
suffix=".tmp",
dir=str(destination.parent),
)
try:
with os.fdopen(descriptor, "wb") as handle:
handle.write(encoded)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, destination)
except BaseException:
try:
os.unlink(temporary)
except FileNotFoundError:
pass
raise
def _main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--workflow",
choices=("momentum", "alpha158"),
default="momentum",
help="default momentum preserves the original CLI behavior",
)
parser.add_argument("--provider-uri", required=True)
parser.add_argument("--market", default="csi300")
parser.add_argument("--benchmark", default="SH000300")
parser.add_argument("--start")
parser.add_argument("--end")
parser.add_argument("--feature-start")
parser.add_argument("--lookback", type=int, default=20)
parser.add_argument("--skip", type=int, default=0)
parser.add_argument("--topk", type=int, default=50)
parser.add_argument("--n-drop", type=int, default=5)
parser.add_argument("--rebalance", choices=("daily", "weekly"), default="weekly")
parser.add_argument("--train-start")
parser.add_argument("--train-end")
parser.add_argument("--valid-start")
parser.add_argument("--valid-end")
parser.add_argument("--test-start")
parser.add_argument("--test-end")
parser.add_argument(
"--experiment-name",
default="quant60_alpha158_lightgbm",
)
parser.add_argument(
"--output-json",
"--result-json",
dest="output_json",
help="optionally save the same JSON summary printed to stdout",
)
args = parser.parse_args(argv)
try:
_validate_common_cli_args(args)
if args.workflow == "momentum":
if any(
value is not None
for value in (
args.train_start,
args.train_end,
args.valid_start,
args.valid_end,
args.test_start,
args.test_end,
)
):
raise ValueError(
"train/valid/test segment arguments are only valid for "
"workflow=alpha158"
)
start, end, feature_start = _validated_momentum_dates(args)
result = run_native_momentum_backtest(
provider_uri=args.provider_uri,
market=args.market,
benchmark=args.benchmark,
start_time=start,
end_time=end,
feature_start_time=feature_start,
lookback=args.lookback,
skip=args.skip,
topk=args.topk,
n_drop=args.n_drop,
rebalance=args.rebalance,
)
summary = _momentum_cli_summary(result)
else:
if any(
value is not None
for value in (args.start, args.end, args.feature_start)
):
raise ValueError(
"start/end/feature-start are only valid for "
"workflow=momentum"
)
train, valid, test = _validated_alpha158_segments(args)
result = run_alpha158_lightgbm_workflow(
provider_uri=args.provider_uri,
market=args.market,
benchmark=args.benchmark,
train=train,
valid=valid,
test=test,
experiment_name=args.experiment_name,
topk=args.topk,
n_drop=args.n_drop,
)
summary = _alpha158_cli_summary(
result,
market=args.market,
benchmark=args.benchmark,
train=train,
valid=valid,
test=test,
)
if args.output_json:
_write_cli_json(args.output_json, summary)
except (OSError, ValueError) as exc:
parser.error(str(exc))
print(
json.dumps(
summary,
ensure_ascii=False,
sort_keys=True,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(_main())
+485
View File
@@ -0,0 +1,485 @@
# coding: gbk
"""QMT built-in Python 3.6 strategy wrapper. Keep this source ASCII-only."""
# __PORTABLE_CORE_BUNDLE_START__
from quant60.portable_core import build_rebalance_plan, convert_symbol
# __PORTABLE_CORE_BUNDLE_END__
import datetime
import math
class PriceHistoryUnavailable(RuntimeError):
pass
class UnmanagedPositionError(RuntimeError):
pass
class UnsupportedStrategyPeriod(RuntimeError):
pass
class G(object):
pass
# QMT explicitly documents that custom attributes written to ContextInfo can
# roll back before the next handlebar call. Keep all user-owned mutable state
# in a module global and use ContextInfo only for platform-owned fields/APIs.
g = G()
# __BASELINE_CONFIG_START__
CONFIG = {
"account_id": "test",
"universe_mode": "pit_index",
"index_symbol": "000905.XSHG",
"qmt_sector_name": "\u4e2d\u8bc1500",
"dedicated_account_required": True,
"exclude_st": True,
"universe": [
"600000.SH",
"000001.SZ",
"300750.SZ",
"000333.SZ",
],
"lookback": 20,
# T close is the signal clock; quickTrade=0 executes on the next step.
"skip": 0,
"top_n": 20,
"max_weight": 0.05,
"gross_target": 0.95,
"cash_buffer": 0.02,
"lot_size": 100,
"participation_rate": 0.1,
"slippage_bps": 2.0,
"commission_rate": 0.0002,
"minimum_commission": 5.0,
"stamp_duty_rate": 0.0005,
"transfer_fee_rate": 0.00001,
"rebalance_schedule": "weekly_first_close",
}
# __BASELINE_CONFIG_END__
def _param(ContextInfo, name, default=None):
params = getattr(ContextInfo, "_param", {})
if isinstance(params, dict) and name in params:
return params[name]
return default
def init(ContextInfo):
period = str(
getattr(ContextInfo, "period", _param(ContextInfo, "period", "1d"))
).strip().lower()
if period not in ("1d", "day"):
raise UnsupportedStrategyPeriod(
"quant60 QMT wrapper requires a 1d driving period"
)
g.config = dict(CONFIG)
for key in CONFIG:
override = _param(ContextInfo, "q60_" + key, None)
if override is not None:
g.config[key] = override
if (
g.config.get("rebalance_schedule")
!= "weekly_first_close"
):
raise UnsupportedStrategyPeriod(
"unsupported rebalance_schedule"
)
if g.config.get("universe_mode") not in ("pit_index", "fixed"):
raise UnsupportedStrategyPeriod("unsupported universe_mode")
if (
g.config.get("universe_mode") == "pit_index"
and not g.config.get("dedicated_account_required")
):
raise UnsupportedStrategyPeriod(
"pit_index mode requires a dedicated account"
)
set_commission = getattr(ContextInfo, "set_commission", None)
set_slippage = getattr(ContextInfo, "set_slippage", None)
if not callable(set_commission) or not callable(set_slippage):
raise UnsupportedStrategyPeriod(
"QMT backtest commission/slippage APIs are unavailable"
)
commission = (
float(g.config["commission_rate"])
+ float(g.config["transfer_fee_rate"])
)
set_commission(
0,
[
0.0,
float(g.config["stamp_duty_rate"]),
commission,
commission,
0.0,
float(g.config["minimum_commission"]),
],
)
set_slippage(
2,
float(g.config["slippage_bps"]) / 10000.0,
)
g.last_week = None
g.last_plan = None
g.last_universe = None
g.last_universe_as_of = None
if (
g.config.get("universe_mode") == "fixed"
and hasattr(ContextInfo, "set_universe")
):
ContextInfo.set_universe(g.config["universe"])
def _bar_datetime(ContextInfo):
timetag = ContextInfo.get_bar_timetag(ContextInfo.barpos)
return datetime.datetime.fromtimestamp(float(timetag) / 1000.0)
def _series_close(frame):
if frame is None:
return []
if hasattr(frame, "__getitem__"):
try:
values = frame["close"]
if hasattr(values, "tolist"):
values = values.tolist()
return list(values)
except (KeyError, TypeError):
pass
if isinstance(frame, dict):
values = frame.get("close", [])
return list(values)
return []
def _decision_universe(ContextInfo, membership_timetag):
config = g.config
if config.get("universe_mode") == "fixed":
values = list(config["universe"])
else:
function = getattr(
ContextInfo,
"get_stock_list_in_sector",
None,
)
if not callable(function):
raise PriceHistoryUnavailable(
"historical sector membership API is unavailable"
)
values = function(
config["qmt_sector_name"],
int(membership_timetag),
)
if not values:
raise PriceHistoryUnavailable(
"no PIT index members for %s on %s"
% (config["index_symbol"], membership_timetag)
)
normalized = sorted(
set(convert_symbol(symbol, "qmt") for symbol in values)
)
if not normalized:
raise PriceHistoryUnavailable("decision universe is empty")
g.last_universe = list(normalized)
g.last_universe_as_of = int(membership_timetag)
return normalized
def _history(ContextInfo, end_time, universe):
config = g.config
count = int(config["lookback"]) + int(config["skip"]) + 1
raw = ContextInfo.get_market_data_ex(
fields=["close"],
stock_code=universe,
period="1d",
start_time="",
end_time=end_time,
count=count,
dividend_type="front_ratio",
fill_data=False,
subscribe=False,
)
output = {}
for symbol in universe:
values = _series_close(raw.get(symbol))
if len(values) != count:
raise PriceHistoryUnavailable(
"%s needs %d completed daily closes, got %d"
% (symbol, count, len(values))
)
clean = []
for value in values:
try:
number = float(value)
except (TypeError, ValueError):
raise PriceHistoryUnavailable(
"%s contains a missing/non-numeric close" % symbol
)
if not math.isfinite(number) or number <= 0.0:
raise PriceHistoryUnavailable(
"%s contains a missing/non-positive close" % symbol
)
clean.append(number)
output[convert_symbol(symbol, "canonical")] = clean
return output
def _filter_st_universe(ContextInfo, universe, decision_date):
if not g.config.get("exclude_st", True):
return list(universe)
function = getattr(ContextInfo, "get_his_st_data", None)
if not callable(function):
raise PriceHistoryUnavailable(
"historical ST API is unavailable; QMT VIP ST data is required"
)
output = []
for symbol in universe:
raw = function(symbol)
if not isinstance(raw, dict):
raise PriceHistoryUnavailable(
"%s historical ST query returned an invalid payload" % symbol
)
excluded = False
for status in ("ST", "*ST", "PT"):
periods = raw.get(status, [])
if not isinstance(periods, (list, tuple)):
raise PriceHistoryUnavailable(
"%s historical %s periods are invalid" % (symbol, status)
)
for period in periods:
if (
not isinstance(period, (list, tuple))
or len(period) != 2
):
raise PriceHistoryUnavailable(
"%s historical %s period is invalid"
% (symbol, status)
)
start = str(period[0])
end = str(period[1])
if (
len(start) != 8
or len(end) != 8
or not start.isdigit()
or not end.isdigit()
or start > end
):
raise PriceHistoryUnavailable(
"%s historical %s date range is invalid"
% (symbol, status)
)
if start <= decision_date <= end:
excluded = True
if not excluded:
output.append(symbol)
if not output:
raise PriceHistoryUnavailable(
"PIT ST exclusion removed the entire decision universe"
)
g.last_universe = list(output)
return output
def _get_attr(obj, names, default=0):
for name in names:
if hasattr(obj, name):
value = getattr(obj, name)
if value is not None:
return value
return default
def _trade_details(ContextInfo, kind):
account_id = g.config["account_id"]
if hasattr(ContextInfo, "get_trade_detail_data"):
return ContextInfo.get_trade_detail_data(account_id, "STOCK", kind)
fn = globals().get("get_trade_detail_data")
if fn is None:
return []
return fn(account_id, "STOCK", kind)
def _portfolio(ContextInfo, managed):
current = {}
sellable = {}
managed = set(
convert_symbol(symbol, "canonical")
for symbol in managed
)
dynamic = g.config.get("universe_mode") == "pit_index"
for position in _trade_details(ContextInfo, "position") or []:
code = str(
_get_attr(position, ["stock_code", "m_strInstrumentID"], "")
)
market = str(_get_attr(position, ["market", "m_strExchangeID"], ""))
if "." not in code and market:
code = code + "." + market
if not code:
continue
canonical = convert_symbol(code, "canonical")
quantity = int(
_get_attr(position, ["volume", "m_nVolume", "total_amount"], 0)
)
if quantity and canonical not in managed and not dynamic:
raise UnmanagedPositionError(
"account contains unmanaged position %s; use a dedicated "
"strategy account or reconcile ownership before trading" % canonical
)
if canonical not in managed and not dynamic:
continue
current[canonical] = quantity
sellable[canonical] = int(
_get_attr(
position,
["can_use_volume", "m_nCanUseVolume", "closeable_amount"],
current[canonical],
)
)
equity = 0.0
assets = _trade_details(ContextInfo, "account") or []
if assets:
equity = float(
_get_attr(
assets[0],
["total_asset", "m_dBalance", "m_dTotalAsset", "asset"],
0.0,
)
)
if equity <= 0:
equity = float(_param(ContextInfo, "asset", 0.0) or 0.0)
return current, sellable, equity
def compute_plan(ContextInfo, bar_time=None):
if bar_time is None:
bar_time = _bar_datetime(ContextInfo)
membership_timetag = int(
ContextInfo.get_bar_timetag(ContextInfo.barpos)
)
end_time = bar_time.strftime("%Y%m%d")
universe = _decision_universe(ContextInfo, membership_timetag)
universe = _filter_st_universe(
ContextInfo,
universe,
end_time,
)
history = _history(ContextInfo, end_time, universe)
current, sellable, equity = _portfolio(ContextInfo, universe)
if equity <= 0:
raise ValueError("QMT account equity must be positive")
config = g.config
return build_rebalance_plan(
price_history=history,
current=current,
sellable=sellable,
equity=equity,
lookback=config["lookback"],
skip=config["skip"],
top_n=config["top_n"],
max_weight=config["max_weight"],
gross_target=config["gross_target"],
cash_buffer=config["cash_buffer"],
lot_size=config["lot_size"],
)
def _orders_allowed(ContextInfo):
# This hosted wrapper is deliberately backtest-only. Its cached account
# query and in-memory weekly marker cannot provide crash-safe live
# idempotency or a complete fresh reconciliation barrier. Real/shadow
# broker integration belongs behind the external XtTrader adapter.
return _is_backtest(ContextInfo)
def _is_backtest(ContextInfo):
if hasattr(ContextInfo, "do_back_test"):
value = getattr(ContextInfo, "do_back_test")
if callable(value):
value = value()
if value is True:
return True
mode = str(
getattr(ContextInfo, "trade_mode", _param(ContextInfo, "trade_mode", ""))
).strip().lower()
return mode == "backtest"
def _submit_delta(ContextInfo, canonical, delta, week_key):
config = g.config
order_code = convert_symbol(canonical, "qmt")
operation = 23 if delta > 0 else 24
volume = abs(int(delta))
user_order_id = "q60-%04d%02d-%s" % (
int(week_key[0]),
int(week_key[1]),
order_code.split(".")[0],
)
function = globals()["passorder"]
arg_count = getattr(getattr(function, "__code__", None), "co_argcount", 11)
if arg_count <= 8:
# qmttools native-Python compatibility signature.
function(
operation,
1101,
config["account_id"],
order_code,
5,
-1,
volume,
ContextInfo,
)
else:
# QMT built-in hosted signature.
function(
operation,
1101,
config["account_id"],
order_code,
5,
-1,
volume,
"quant60",
0,
user_order_id,
ContextInfo,
)
def handlebar(ContextInfo):
if not _is_backtest(ContextInfo) and hasattr(ContextInfo, "is_last_bar"):
if not ContextInfo.is_last_bar():
return None
bar_time = _bar_datetime(ContextInfo)
year, week, unused = bar_time.isocalendar()
del unused
week_key = (year, week)
if g.last_week is None:
# The strategy may be attached to a run in the middle of a week.
# Treat the first observed week as an initialization window; only a
# later ISO-week transition proves that this is the first observed
# trading session of a complete strategy week.
g.last_week = week_key
return None
if g.last_week == week_key:
return None
plan = compute_plan(ContextInfo, bar_time)
g.last_week = week_key
g.last_plan = plan
if not _orders_allowed(ContextInfo):
return plan
# Sells before buys. passorder is the QMT-hosted order boundary.
ordered = sorted(plan["orders"].items(), key=lambda item: item[1])
for canonical, delta in ordered:
if int(delta) != 0:
_submit_delta(ContextInfo, canonical, delta, week_key)
return plan
+344
View File
@@ -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())