feat: add Quant OS A-share baseline
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Thin wrappers for platform-hosted and native backtest runtimes."""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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