feat: add Quant OS A-share baseline
This commit is contained in:
Vendored
+843
@@ -0,0 +1,843 @@
|
||||
# coding: gbk
|
||||
"""QMT built-in Python 3.6 strategy wrapper. Keep this source ASCII-only."""
|
||||
|
||||
# __PORTABLE_CORE_BUNDLE_START__
|
||||
# Inlined by tools/bundle_platforms.py; do not edit this region.
|
||||
import math
|
||||
import re
|
||||
|
||||
|
||||
_CANONICAL_EXCHANGES = {
|
||||
"SH": "XSHG",
|
||||
"SSE": "XSHG",
|
||||
"XSHG": "XSHG",
|
||||
"SZ": "XSHE",
|
||||
"SZE": "XSHE",
|
||||
"SZSE": "XSHE",
|
||||
"XSHE": "XSHE",
|
||||
"BJ": "XBSE",
|
||||
"BSE": "XBSE",
|
||||
"XBSE": "XBSE",
|
||||
}
|
||||
|
||||
_SHORT_EXCHANGES = {
|
||||
"XSHG": "SH",
|
||||
"XSHE": "SZ",
|
||||
"XBSE": "BJ",
|
||||
}
|
||||
|
||||
|
||||
def _sum_numeric(values):
|
||||
"""Sum numerics without trusting a hosted runtime's global ``sum`` name."""
|
||||
|
||||
total = 0.0
|
||||
for value in values:
|
||||
total += value
|
||||
return total
|
||||
|
||||
|
||||
def _infer_exchange(code):
|
||||
if code.startswith(("4", "8")):
|
||||
return "XBSE"
|
||||
if code.startswith(("5", "6", "9")):
|
||||
return "XSHG"
|
||||
return "XSHE"
|
||||
|
||||
|
||||
def _split_symbol(symbol):
|
||||
if not isinstance(symbol, str):
|
||||
raise TypeError("symbol must be a string")
|
||||
value = symbol.strip().upper().replace(" ", "")
|
||||
if not value:
|
||||
raise ValueError("symbol must not be empty")
|
||||
|
||||
match = re.match(r"^(\d{6})\.(XSHG|XSHE|XBSE|SH|SZ|BJ)$", value)
|
||||
if match:
|
||||
return match.group(1), _CANONICAL_EXCHANGES[match.group(2)]
|
||||
|
||||
match = re.match(r"^(XSHG|XSHE|XBSE|SH|SZ|BJ)[\.:]?(\d{6})$", value)
|
||||
if match:
|
||||
return match.group(2), _CANONICAL_EXCHANGES[match.group(1)]
|
||||
|
||||
match = re.match(r"^(\d{6})[\.:](SSE|SZE|SZSE|BSE)$", value)
|
||||
if match:
|
||||
return match.group(1), _CANONICAL_EXCHANGES[match.group(2)]
|
||||
|
||||
if re.match(r"^\d{6}$", value):
|
||||
return value, _infer_exchange(value)
|
||||
raise ValueError("unsupported A-share symbol: %s" % symbol)
|
||||
|
||||
|
||||
def normalize_symbol(symbol, target="canonical"):
|
||||
"""Convert common A-share codes to canonical/JQ, QMT, or Qlib format."""
|
||||
|
||||
code, exchange = _split_symbol(symbol)
|
||||
target_name = str(target).strip().lower()
|
||||
if target_name in ("canonical", "joinquant", "jq"):
|
||||
return "%s.%s" % (code, exchange)
|
||||
if target_name in ("qmt", "xtquant"):
|
||||
return "%s.%s" % (code, _SHORT_EXCHANGES[exchange])
|
||||
if target_name == "qlib":
|
||||
return "%s%s" % (_SHORT_EXCHANGES[exchange], code)
|
||||
raise ValueError("unsupported target platform: %s" % target)
|
||||
|
||||
|
||||
def convert_symbol(symbol, target="canonical"):
|
||||
"""Alias kept as the stable adapter-facing name."""
|
||||
|
||||
return normalize_symbol(symbol, target)
|
||||
|
||||
|
||||
def momentum_score(prices, lookback=20, skip=0):
|
||||
"""Return trailing simple momentum, or ``None`` if history is insufficient.
|
||||
|
||||
``skip=0`` uses the latest completed decision bar. Set ``skip=1`` only
|
||||
when intentionally implementing a 20-1 convention. A lookback of N needs
|
||||
N+skip+1 observations.
|
||||
"""
|
||||
|
||||
lookback = int(lookback)
|
||||
skip = int(skip)
|
||||
if lookback <= 0 or skip < 0:
|
||||
raise ValueError("lookback must be positive and skip non-negative")
|
||||
values = list(prices)
|
||||
if len(values) < lookback + skip + 1:
|
||||
return None
|
||||
end_index = len(values) - 1 - skip
|
||||
start_index = end_index - lookback
|
||||
start = float(values[start_index])
|
||||
end = float(values[end_index])
|
||||
if (
|
||||
start <= 0.0
|
||||
or end <= 0.0
|
||||
or not math.isfinite(start)
|
||||
or not math.isfinite(end)
|
||||
):
|
||||
return None
|
||||
return end / start - 1.0
|
||||
|
||||
|
||||
def rank_momentum(price_history, lookback=20, skip=0, top_n=None):
|
||||
"""Rank ``{symbol: closes}`` by momentum with deterministic tie-breaking."""
|
||||
|
||||
ranked = []
|
||||
for symbol in sorted(price_history):
|
||||
score = momentum_score(price_history[symbol], lookback, skip)
|
||||
if score is not None and math.isfinite(score):
|
||||
ranked.append((normalize_symbol(symbol), score))
|
||||
ranked.sort(key=lambda item: (-item[1], item[0]))
|
||||
if top_n is not None:
|
||||
ranked = ranked[: max(0, int(top_n))]
|
||||
return ranked
|
||||
|
||||
|
||||
def capped_target_weights(
|
||||
scores, max_weight=0.20, gross_target=1.0, min_score=0.0
|
||||
):
|
||||
"""Long-only score-proportional weights with iterative cap redistribution."""
|
||||
|
||||
max_weight = float(max_weight)
|
||||
gross_target = float(gross_target)
|
||||
min_score = float(min_score)
|
||||
if not math.isfinite(max_weight) or not 0.0 < max_weight <= 1.0:
|
||||
raise ValueError("max_weight must be finite and lie in (0, 1]")
|
||||
if not math.isfinite(gross_target) or not 0.0 <= gross_target <= 1.0:
|
||||
raise ValueError("gross_target must be finite and lie in [0, 1]")
|
||||
if not math.isfinite(min_score):
|
||||
raise ValueError("min_score must be finite")
|
||||
|
||||
if hasattr(scores, "items"):
|
||||
items = list(scores.items())
|
||||
else:
|
||||
items = list(scores)
|
||||
normalized = {}
|
||||
for symbol, raw_score in items:
|
||||
name = normalize_symbol(symbol)
|
||||
score = float(raw_score)
|
||||
if not math.isfinite(score):
|
||||
raise ValueError("score must be finite for %s" % name)
|
||||
normalized[name] = score
|
||||
|
||||
weights = dict((symbol, 0.0) for symbol in normalized)
|
||||
active = dict(
|
||||
(symbol, score)
|
||||
for symbol, score in normalized.items()
|
||||
if score > min_score and score > 0.0
|
||||
)
|
||||
remaining = min(gross_target, len(active) * max_weight)
|
||||
|
||||
while active and remaining > 1e-15:
|
||||
score_sum = _sum_numeric(active.values())
|
||||
if score_sum <= 0.0:
|
||||
break
|
||||
capped = []
|
||||
for symbol in sorted(active):
|
||||
proposed = remaining * active[symbol] / score_sum
|
||||
if proposed >= max_weight - 1e-15:
|
||||
weights[symbol] = max_weight
|
||||
capped.append(symbol)
|
||||
if not capped:
|
||||
for symbol in sorted(active):
|
||||
weights[symbol] = remaining * active[symbol] / score_sum
|
||||
remaining = 0.0
|
||||
break
|
||||
for symbol in capped:
|
||||
active.pop(symbol)
|
||||
remaining -= max_weight
|
||||
remaining = max(0.0, remaining)
|
||||
return weights
|
||||
|
||||
|
||||
def round_board_lot(quantity, lot_size=100):
|
||||
"""Round an absolute long position down to a board-lot quantity."""
|
||||
|
||||
lot_size = int(lot_size)
|
||||
if lot_size <= 0:
|
||||
raise ValueError("lot_size must be positive")
|
||||
if not math.isfinite(float(quantity)):
|
||||
raise ValueError("quantity must be finite")
|
||||
quantity = max(0, int(quantity))
|
||||
return quantity // lot_size * lot_size
|
||||
|
||||
|
||||
def _is_star_market(symbol):
|
||||
code, exchange = _split_symbol(symbol)
|
||||
return exchange == "XSHG" and code.startswith(("688", "689"))
|
||||
|
||||
|
||||
def target_quantities(
|
||||
weights, prices, equity, lot_size=100, cash_buffer=0.02
|
||||
):
|
||||
"""Convert target weights to affordable board-lot long quantities."""
|
||||
|
||||
equity = float(equity)
|
||||
cash_buffer = float(cash_buffer)
|
||||
if (
|
||||
not math.isfinite(equity)
|
||||
or equity < 0.0
|
||||
or not math.isfinite(cash_buffer)
|
||||
or not 0.0 <= cash_buffer < 1.0
|
||||
):
|
||||
raise ValueError("invalid equity or cash_buffer")
|
||||
positive_weights = []
|
||||
for raw_symbol, raw_weight in weights.items():
|
||||
weight = float(raw_weight)
|
||||
if not math.isfinite(weight):
|
||||
raise ValueError(
|
||||
"weight must be finite for %s" % normalize_symbol(raw_symbol)
|
||||
)
|
||||
positive_weights.append(max(0.0, weight))
|
||||
requested_gross = _sum_numeric(positive_weights)
|
||||
gross_ceiling = 1.0 - cash_buffer
|
||||
scale = (
|
||||
min(1.0, gross_ceiling / requested_gross)
|
||||
if requested_gross > 0.0
|
||||
else 1.0
|
||||
)
|
||||
result = {}
|
||||
for raw_symbol in sorted(weights):
|
||||
symbol = normalize_symbol(raw_symbol)
|
||||
weight = float(weights[raw_symbol])
|
||||
if not math.isfinite(weight):
|
||||
raise ValueError("weight must be finite for %s" % symbol)
|
||||
weight = max(0.0, weight)
|
||||
price = prices.get(raw_symbol)
|
||||
if price is None:
|
||||
price = prices.get(symbol)
|
||||
if price is None:
|
||||
raise KeyError("missing price for %s" % symbol)
|
||||
price = float(price)
|
||||
if price <= 0.0 or not math.isfinite(price):
|
||||
raise ValueError("invalid price for %s" % symbol)
|
||||
# Weights are absolute equity weights. cash_buffer is a ceiling, not
|
||||
# a second multiplicative haircut: gross=.95 and buffer=.02 stays .95.
|
||||
budget = equity * weight * scale
|
||||
quantity = round_board_lot(budget / price, lot_size)
|
||||
# STAR Market auction orders must contain at least 200 shares. Keep
|
||||
# the portable planner conservative and on the configured 100-share
|
||||
# grid even though quantities above 200 may increment by one share.
|
||||
if _is_star_market(symbol) and 0 < quantity < 200:
|
||||
quantity = 0
|
||||
result[symbol] = quantity
|
||||
return result
|
||||
|
||||
|
||||
def order_deltas(
|
||||
targets, current, sellable=None, lot_size=100
|
||||
):
|
||||
"""Return signed board-lot orders; negative sells respect sellable quantity."""
|
||||
|
||||
canonical_targets = {}
|
||||
canonical_current = {}
|
||||
canonical_sellable = {}
|
||||
for symbol, quantity in targets.items():
|
||||
canonical_targets[normalize_symbol(symbol)] = max(0, int(quantity))
|
||||
for symbol, quantity in current.items():
|
||||
canonical_current[normalize_symbol(symbol)] = max(0, int(quantity))
|
||||
if sellable is not None:
|
||||
for symbol, quantity in sellable.items():
|
||||
canonical_sellable[normalize_symbol(symbol)] = max(0, int(quantity))
|
||||
|
||||
result = {}
|
||||
symbols = sorted(set(canonical_targets) | set(canonical_current))
|
||||
for symbol in symbols:
|
||||
target = round_board_lot(canonical_targets.get(symbol, 0), lot_size)
|
||||
if _is_star_market(symbol) and 0 < target < 200:
|
||||
target = 0
|
||||
held = canonical_current.get(symbol, 0)
|
||||
delta = target - held
|
||||
if delta > 0:
|
||||
delta = round_board_lot(delta, lot_size)
|
||||
if _is_star_market(symbol) and 0 < delta < 200:
|
||||
delta = 0
|
||||
elif delta < 0:
|
||||
available = held
|
||||
if sellable is not None:
|
||||
available = min(held, canonical_sellable.get(symbol, 0))
|
||||
requested = min(-delta, available)
|
||||
# A complete liquidation may submit the entire odd-lot balance.
|
||||
# This also covers the STAR exception for a remaining balance
|
||||
# below 200 shares. Partial orders stay on the configured grid.
|
||||
full_liquidation = (
|
||||
target == 0
|
||||
and requested == held
|
||||
and available >= held
|
||||
)
|
||||
if full_liquidation:
|
||||
delta = -held
|
||||
else:
|
||||
delta = -round_board_lot(requested, lot_size)
|
||||
if _is_star_market(symbol) and 0 < -delta < 200:
|
||||
delta = 0
|
||||
if delta:
|
||||
result[symbol] = delta
|
||||
return result
|
||||
|
||||
|
||||
def build_rebalance_plan(
|
||||
price_history,
|
||||
current,
|
||||
sellable,
|
||||
equity,
|
||||
lookback=20,
|
||||
skip=0,
|
||||
top_n=10,
|
||||
max_weight=0.20,
|
||||
gross_target=1.0,
|
||||
cash_buffer=0.02,
|
||||
lot_size=100,
|
||||
):
|
||||
"""Build the complete portable signal -> weight -> quantity -> order plan."""
|
||||
|
||||
ranked = rank_momentum(price_history, lookback, skip, top_n)
|
||||
scores = dict(ranked)
|
||||
weights = capped_target_weights(scores, max_weight, gross_target, 0.0)
|
||||
for raw_symbol in price_history:
|
||||
symbol = normalize_symbol(raw_symbol)
|
||||
if symbol not in scores:
|
||||
scores[symbol] = momentum_score(
|
||||
price_history[raw_symbol], lookback, skip
|
||||
)
|
||||
if symbol not in weights:
|
||||
weights[symbol] = 0.0
|
||||
prices = {}
|
||||
for raw_symbol, history in price_history.items():
|
||||
if not history:
|
||||
continue
|
||||
prices[normalize_symbol(raw_symbol)] = history[-1]
|
||||
targets = target_quantities(
|
||||
weights, prices, equity, lot_size, cash_buffer
|
||||
)
|
||||
orders = order_deltas(targets, current, sellable, lot_size)
|
||||
return {
|
||||
"scores": scores,
|
||||
"weights": weights,
|
||||
"targets": targets,
|
||||
"orders": orders,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_rebalance_plan",
|
||||
"capped_target_weights",
|
||||
"convert_symbol",
|
||||
"momentum_score",
|
||||
"normalize_symbol",
|
||||
"order_deltas",
|
||||
"rank_momentum",
|
||||
"round_board_lot",
|
||||
"target_quantities",
|
||||
]
|
||||
# __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__
|
||||
# Generated from configs/baseline.json; do not edit this region.
|
||||
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,
|
||||
'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': 1e-05,
|
||||
'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
|
||||
Reference in New Issue
Block a user