1736 lines
57 KiB
Python
1736 lines
57 KiB
Python
# 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 hashlib
|
|
import json
|
|
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",
|
|
}
|
|
|
|
_TARGET_PACKAGE_FIELDS = {
|
|
"schema_version",
|
|
"package_type",
|
|
"package_id",
|
|
"decision_id",
|
|
"release",
|
|
"evidence_class",
|
|
"model_id",
|
|
"signal_as_of",
|
|
"next_session",
|
|
"model_sha256",
|
|
"data_sha256",
|
|
"feature_sha256",
|
|
"risk_sha256",
|
|
"cost_sha256",
|
|
"optimizer_sha256",
|
|
"config_sha256",
|
|
"source_sha256",
|
|
"target_weights",
|
|
"universe",
|
|
"constraints",
|
|
"package_sha256",
|
|
}
|
|
|
|
_TARGET_PACKAGE_HASH_FIELDS = (
|
|
"model_sha256",
|
|
"data_sha256",
|
|
"feature_sha256",
|
|
"risk_sha256",
|
|
"cost_sha256",
|
|
"optimizer_sha256",
|
|
"config_sha256",
|
|
"source_sha256",
|
|
)
|
|
|
|
|
|
class TargetPackageLookupMiss(ValueError, LookupError):
|
|
"""An already verified tape has no exact requested decision."""
|
|
|
|
|
|
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 _any_true(values):
|
|
"""Return built-in ``any`` semantics without trusting hosted globals."""
|
|
|
|
for value in values:
|
|
if value:
|
|
return True
|
|
return False
|
|
|
|
|
|
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 _canonical_json(value):
|
|
"""Match ``quant60.ledger.canonical_json`` for plain JSON values."""
|
|
|
|
return json.dumps(
|
|
value,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
allow_nan=False,
|
|
)
|
|
|
|
|
|
def _json_copy(value, name):
|
|
try:
|
|
return json.loads(_canonical_json(value))
|
|
except (TypeError, ValueError) as error:
|
|
raise ValueError("%s must contain finite JSON values only" % name) from error
|
|
|
|
|
|
def _canonical_mapping(value, name, value_validator):
|
|
if not hasattr(value, "items"):
|
|
raise TypeError("%s must be a mapping" % name)
|
|
output = {}
|
|
for raw_symbol, raw_value in value.items():
|
|
symbol = normalize_symbol(raw_symbol)
|
|
if symbol in output:
|
|
raise ValueError(
|
|
"%s contains duplicate normalized symbol %s" % (name, symbol)
|
|
)
|
|
output[symbol] = value_validator(raw_value, "%s.%s" % (name, symbol))
|
|
return dict((symbol, output[symbol]) for symbol in sorted(output))
|
|
|
|
|
|
def _finite_non_negative(value, name):
|
|
if isinstance(value, bool):
|
|
raise ValueError("%s must be a finite non-negative number" % name)
|
|
try:
|
|
numeric = float(value)
|
|
except (TypeError, ValueError):
|
|
raise ValueError("%s must be a finite non-negative number" % name)
|
|
if not math.isfinite(numeric) or numeric < 0.0:
|
|
raise ValueError("%s must be a finite non-negative number" % name)
|
|
return numeric
|
|
|
|
|
|
def _non_negative_quantity(value, name):
|
|
numeric = _finite_non_negative(value, name)
|
|
if numeric != math.floor(numeric):
|
|
raise ValueError("%s must be a whole-share quantity" % name)
|
|
return int(numeric)
|
|
|
|
|
|
def _positive_price(value, name):
|
|
numeric = _finite_non_negative(value, name)
|
|
if numeric <= 0.0:
|
|
raise ValueError("%s must be positive" % name)
|
|
return numeric
|
|
|
|
|
|
def _optional_non_empty_string(value, name):
|
|
if value is None:
|
|
return None
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise ValueError("%s must be a non-empty string when provided" % name)
|
|
return value
|
|
|
|
|
|
def _sha256_hex(value, name, allow_none=False):
|
|
if value is None and allow_none:
|
|
return None
|
|
if (
|
|
not isinstance(value, str)
|
|
or len(value) != 64
|
|
or _any_true(
|
|
character not in "0123456789abcdef"
|
|
for character in value
|
|
)
|
|
):
|
|
raise ValueError(
|
|
"%s must be a 64-character lowercase SHA-256 digest" % name
|
|
)
|
|
if value == "0" * 64:
|
|
raise ValueError("%s cannot be an all-zero placeholder" % name)
|
|
return value
|
|
|
|
|
|
def _reject_quantity_fields(value, path="$"):
|
|
if hasattr(value, "items"):
|
|
for raw_key, child in value.items():
|
|
key = str(raw_key)
|
|
normalized = key.lower().replace("-", "_")
|
|
if "quantity" in normalized or "quantities" in normalized:
|
|
raise ValueError(
|
|
"%s.%s: quantities do not belong in TargetPackageV1"
|
|
% (path, key)
|
|
)
|
|
_reject_quantity_fields(child, "%s.%s" % (path, key))
|
|
elif isinstance(value, list):
|
|
for index, child in enumerate(value):
|
|
_reject_quantity_fields(child, "%s[%d]" % (path, index))
|
|
|
|
|
|
def _verify_identifier(value, name):
|
|
if (
|
|
not isinstance(value, str)
|
|
or re.match(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", value) is None
|
|
):
|
|
raise ValueError("%s is not a canonical identifier" % name)
|
|
return value
|
|
|
|
|
|
def _verify_target_package_clock(signal_as_of, next_session):
|
|
if (
|
|
not isinstance(signal_as_of, str)
|
|
or re.match(
|
|
r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T15:00:00\+08:00$",
|
|
signal_as_of,
|
|
)
|
|
is None
|
|
):
|
|
raise ValueError("signal_as_of must be canonical China-market close")
|
|
if (
|
|
not isinstance(next_session, str)
|
|
or re.match(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$", next_session) is None
|
|
):
|
|
raise ValueError("next_session must use canonical YYYY-MM-DD")
|
|
if next_session <= signal_as_of[:10]:
|
|
raise ValueError("next_session must follow signal_as_of")
|
|
|
|
|
|
def _target_package_content_sha256(package):
|
|
body = dict(package)
|
|
body.pop("package_sha256", None)
|
|
_reject_quantity_fields(body)
|
|
try:
|
|
payload = _canonical_json(body).encode("utf-8")
|
|
except (TypeError, ValueError) as error:
|
|
raise ValueError(
|
|
"target package must contain finite JSON values only"
|
|
) from error
|
|
return hashlib.sha256(payload).hexdigest()
|
|
|
|
|
|
def verify_target_package(
|
|
package,
|
|
expected_signal_as_of=None,
|
|
expected_next_session=None,
|
|
expected_decision_id=None,
|
|
expected_package_sha256=None,
|
|
):
|
|
"""Verify the platform-critical subset of a sealed ``TargetPackageV1``.
|
|
|
|
This intentionally duplicates a small, Python-3.6-compatible safety
|
|
boundary from the richer local validator. Hosted runtimes must verify the
|
|
embedded package again instead of trusting the bundling process.
|
|
"""
|
|
|
|
if not hasattr(package, "items"):
|
|
raise TypeError("target package must be a mapping")
|
|
candidate = _json_copy(package, "target package")
|
|
_reject_quantity_fields(candidate)
|
|
fields = set(candidate)
|
|
if fields != _TARGET_PACKAGE_FIELDS:
|
|
missing = sorted(_TARGET_PACKAGE_FIELDS - fields)
|
|
unknown = sorted(fields - _TARGET_PACKAGE_FIELDS)
|
|
raise ValueError(
|
|
"target package fields mismatch; missing=%s, unknown=%s"
|
|
% (missing, unknown)
|
|
)
|
|
if candidate["schema_version"] != "1.0":
|
|
raise ValueError("unsupported target package schema_version")
|
|
if candidate["package_type"] != "TargetPackageV1":
|
|
raise ValueError("unsupported target package type")
|
|
for name in (
|
|
"package_id",
|
|
"decision_id",
|
|
"release",
|
|
"evidence_class",
|
|
"model_id",
|
|
):
|
|
_verify_identifier(candidate[name], name)
|
|
_verify_target_package_clock(
|
|
candidate["signal_as_of"], candidate["next_session"]
|
|
)
|
|
for name in _TARGET_PACKAGE_HASH_FIELDS:
|
|
_sha256_hex(candidate[name], name)
|
|
_sha256_hex(candidate["package_sha256"], "package_sha256")
|
|
|
|
raw_weights = candidate["target_weights"]
|
|
if not hasattr(raw_weights, "items") or not raw_weights:
|
|
raise ValueError("target_weights must be a non-empty mapping")
|
|
weights = {}
|
|
for raw_symbol, raw_weight in raw_weights.items():
|
|
symbol = normalize_symbol(raw_symbol)
|
|
if symbol != raw_symbol:
|
|
raise ValueError(
|
|
"target_weights symbol must be canonical: %s" % raw_symbol
|
|
)
|
|
if symbol in weights:
|
|
raise ValueError("duplicate target weight for %s" % symbol)
|
|
weight = _finite_non_negative(
|
|
raw_weight, "target_weights.%s" % symbol
|
|
)
|
|
if weight > 1.0:
|
|
raise ValueError("target weight exceeds one for %s" % symbol)
|
|
weights[symbol] = weight
|
|
gross = math.fsum(weights.values())
|
|
if gross > 1.0 + 1e-12:
|
|
raise ValueError("target_weights gross exceeds one")
|
|
|
|
universe = candidate["universe"]
|
|
if not hasattr(universe, "items") or not universe:
|
|
raise ValueError("universe must be a non-empty mapping")
|
|
canonical_universe = {}
|
|
for raw_symbol, raw_state in universe.items():
|
|
symbol = normalize_symbol(raw_symbol)
|
|
if symbol != raw_symbol:
|
|
raise ValueError("universe symbol must be canonical: %s" % raw_symbol)
|
|
if symbol in canonical_universe:
|
|
raise ValueError("duplicate universe symbol %s" % symbol)
|
|
if not hasattr(raw_state, "items"):
|
|
raise ValueError("universe state must be a mapping for %s" % symbol)
|
|
state = raw_state.get("state")
|
|
if state not in (
|
|
"openable",
|
|
"hold_only",
|
|
"sell_only",
|
|
"frozen",
|
|
"excluded",
|
|
):
|
|
raise ValueError("unsupported universe state for %s" % symbol)
|
|
canonical_universe[symbol] = state
|
|
missing_universe = sorted(set(weights) - set(canonical_universe))
|
|
if missing_universe:
|
|
raise ValueError(
|
|
"target symbols are absent from universe: %s" % missing_universe
|
|
)
|
|
for symbol, weight in weights.items():
|
|
if canonical_universe[symbol] == "excluded" and weight > 1e-12:
|
|
raise ValueError("excluded symbol has positive weight: %s" % symbol)
|
|
|
|
constraints = candidate["constraints"]
|
|
if not hasattr(constraints, "items"):
|
|
raise ValueError("constraints must be a mapping")
|
|
if constraints.get("long_only") is not True:
|
|
raise ValueError("TargetPackageV1 must be long-only")
|
|
declared_gross = _finite_non_negative(
|
|
constraints.get("actual_gross_weight"),
|
|
"constraints.actual_gross_weight",
|
|
)
|
|
max_gross = _finite_non_negative(
|
|
constraints.get("max_gross_weight"),
|
|
"constraints.max_gross_weight",
|
|
)
|
|
max_single = _finite_non_negative(
|
|
constraints.get("max_single_weight"),
|
|
"constraints.max_single_weight",
|
|
)
|
|
if abs(declared_gross - gross) > 1e-12:
|
|
raise ValueError("declared gross does not equal target_weights gross")
|
|
if gross > max_gross + 1e-12 or max_gross > 1.0:
|
|
raise ValueError("target_weights exceed max_gross_weight")
|
|
if max_single <= 0.0 or max_single > 1.0:
|
|
raise ValueError("invalid max_single_weight")
|
|
if _any_true(
|
|
weight > max_single + 1e-12 for weight in weights.values()
|
|
):
|
|
raise ValueError("target_weights exceed max_single_weight")
|
|
model_claim = " ".join(
|
|
(
|
|
candidate["release"],
|
|
candidate["evidence_class"],
|
|
)
|
|
).lower()
|
|
if (
|
|
"baseline" in model_claim
|
|
and "portable-momentum" in candidate["model_id"].lower()
|
|
):
|
|
raise ValueError(
|
|
"portable-momentum cannot claim a baseline target package"
|
|
)
|
|
|
|
expected_hash = _target_package_content_sha256(candidate)
|
|
if candidate["package_sha256"] != expected_hash:
|
|
raise ValueError("target package content hash mismatch")
|
|
if (
|
|
expected_signal_as_of is not None
|
|
and candidate["signal_as_of"] != expected_signal_as_of
|
|
):
|
|
raise ValueError("target package signal_as_of does not match request")
|
|
if (
|
|
expected_next_session is not None
|
|
and candidate["next_session"] != expected_next_session
|
|
):
|
|
raise ValueError("target package next_session does not match request")
|
|
if (
|
|
expected_decision_id is not None
|
|
and candidate["decision_id"] != expected_decision_id
|
|
):
|
|
raise ValueError("target package decision_id does not match request")
|
|
if expected_package_sha256 is not None:
|
|
_sha256_hex(
|
|
expected_package_sha256,
|
|
"expected_package_sha256",
|
|
)
|
|
if candidate["package_sha256"] != expected_package_sha256:
|
|
raise ValueError("target package digest does not match request")
|
|
return candidate
|
|
|
|
|
|
def target_package_tape_sha256(packages):
|
|
"""Hash the exact ordered canonical JSON package array."""
|
|
|
|
if not isinstance(packages, (list, tuple)):
|
|
raise TypeError("target package tape must be a list or tuple")
|
|
try:
|
|
payload = _canonical_json(list(packages)).encode("utf-8")
|
|
except (TypeError, ValueError) as error:
|
|
raise ValueError(
|
|
"target package tape must contain finite JSON values only"
|
|
) from error
|
|
return hashlib.sha256(payload).hexdigest()
|
|
|
|
|
|
def lookup_target_package_exact(
|
|
packages,
|
|
signal_as_of,
|
|
next_session,
|
|
decision_id=None,
|
|
expected_tape_sha256=None,
|
|
expected_count=None,
|
|
):
|
|
"""Verify a complete tape, then return one exact dual-clock decision."""
|
|
|
|
if not isinstance(packages, (list, tuple)) or not packages:
|
|
raise ValueError("target package tape must be a non-empty list or tuple")
|
|
_verify_target_package_clock(signal_as_of, next_session)
|
|
decision_id = _optional_non_empty_string(decision_id, "decision_id")
|
|
if expected_count is not None:
|
|
if (
|
|
isinstance(expected_count, bool)
|
|
or not isinstance(expected_count, int)
|
|
or expected_count < 0
|
|
):
|
|
raise ValueError("expected_count must be a non-negative integer")
|
|
if len(packages) != expected_count:
|
|
raise ValueError("target package tape count mismatch")
|
|
if expected_tape_sha256 is not None:
|
|
_sha256_hex(expected_tape_sha256, "expected_tape_sha256")
|
|
if target_package_tape_sha256(packages) != expected_tape_sha256:
|
|
raise ValueError("target package tape digest mismatch")
|
|
|
|
verified = []
|
|
package_ids = set()
|
|
decision_ids = set()
|
|
clock_keys = set()
|
|
for raw_package in packages:
|
|
package = verify_target_package(raw_package)
|
|
clock_key = (package["signal_as_of"], package["next_session"])
|
|
if package["package_id"] in package_ids:
|
|
raise ValueError("duplicate target package_id")
|
|
if package["decision_id"] in decision_ids:
|
|
raise ValueError("duplicate target decision_id")
|
|
if clock_key in clock_keys:
|
|
raise ValueError("duplicate target package clock")
|
|
package_ids.add(package["package_id"])
|
|
decision_ids.add(package["decision_id"])
|
|
clock_keys.add(clock_key)
|
|
verified.append(package)
|
|
|
|
matches = [
|
|
package
|
|
for package in verified
|
|
if package["signal_as_of"] == signal_as_of
|
|
and package["next_session"] == next_session
|
|
]
|
|
if not matches:
|
|
raise TargetPackageLookupMiss(
|
|
"no target package for exact signal_as_of/next_session clock"
|
|
)
|
|
if len(matches) != 1:
|
|
raise ValueError("ambiguous exact target package decision")
|
|
if (
|
|
decision_id is not None
|
|
and matches[0]["decision_id"] != decision_id
|
|
):
|
|
raise ValueError("exact target package decision_id mismatch")
|
|
return _json_copy(matches[0], "target package")
|
|
|
|
|
|
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_target_weight_plan(
|
|
target_weights,
|
|
prices,
|
|
current,
|
|
sellable,
|
|
equity,
|
|
cash_buffer=0.02,
|
|
lot_size=100,
|
|
decision_id=None,
|
|
package_sha256=None,
|
|
):
|
|
"""Bind canonical post-risk weights to one account without recomputing Alpha.
|
|
|
|
``prices`` must cover exactly every target or currently managed symbol.
|
|
Missing prices fail closed even for a held symbol whose desired weight is
|
|
zero. A missing ``sellable`` entry is safe and means zero sellable shares.
|
|
"""
|
|
|
|
weights = _canonical_mapping(
|
|
target_weights,
|
|
"target_weights",
|
|
_finite_non_negative,
|
|
)
|
|
gross = math.fsum(weights.values())
|
|
if _any_true(weight > 1.0 for weight in weights.values()):
|
|
raise ValueError("target weight must not exceed one")
|
|
if gross > 1.0 + 1e-12:
|
|
raise ValueError("target weight gross must not exceed one")
|
|
|
|
canonical_current = _canonical_mapping(
|
|
current,
|
|
"current",
|
|
_non_negative_quantity,
|
|
)
|
|
canonical_sellable = _canonical_mapping(
|
|
sellable,
|
|
"sellable",
|
|
_non_negative_quantity,
|
|
)
|
|
unknown_sellable = sorted(set(canonical_sellable) - set(canonical_current))
|
|
if unknown_sellable:
|
|
raise ValueError(
|
|
"sellable contains symbols absent from current: %s"
|
|
% unknown_sellable
|
|
)
|
|
for symbol, quantity in canonical_sellable.items():
|
|
if quantity > canonical_current[symbol]:
|
|
raise ValueError(
|
|
"sellable exceeds current quantity for %s" % symbol
|
|
)
|
|
|
|
canonical_prices = _canonical_mapping(
|
|
prices,
|
|
"prices",
|
|
_positive_price,
|
|
)
|
|
managed_symbols = set(weights) | set(canonical_current)
|
|
price_symbols = set(canonical_prices)
|
|
missing_prices = sorted(managed_symbols - price_symbols)
|
|
extra_prices = sorted(price_symbols - managed_symbols)
|
|
if missing_prices or extra_prices:
|
|
if missing_prices:
|
|
raise KeyError(
|
|
"prices do not cover managed symbols: missing=%s"
|
|
% missing_prices
|
|
)
|
|
raise ValueError(
|
|
"prices contain unmanaged symbols: extra=%s" % extra_prices
|
|
)
|
|
|
|
complete_weights = dict(
|
|
(symbol, weights.get(symbol, 0.0))
|
|
for symbol in sorted(managed_symbols)
|
|
)
|
|
targets = target_quantities(
|
|
complete_weights,
|
|
canonical_prices,
|
|
equity,
|
|
lot_size,
|
|
cash_buffer,
|
|
)
|
|
orders = order_deltas(
|
|
targets,
|
|
canonical_current,
|
|
canonical_sellable,
|
|
lot_size,
|
|
)
|
|
decision_id = _optional_non_empty_string(decision_id, "decision_id")
|
|
package_sha256 = _sha256_hex(
|
|
package_sha256,
|
|
"package_sha256",
|
|
allow_none=True,
|
|
)
|
|
return {
|
|
"decision_id": decision_id,
|
|
"package_sha256": package_sha256,
|
|
"weights": complete_weights,
|
|
"targets": targets,
|
|
"orders": orders,
|
|
}
|
|
|
|
|
|
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",
|
|
"build_target_weight_plan",
|
|
"capped_target_weights",
|
|
"convert_symbol",
|
|
"lookup_target_package_exact",
|
|
"momentum_score",
|
|
"normalize_symbol",
|
|
"order_deltas",
|
|
"rank_momentum",
|
|
"round_board_lot",
|
|
"TargetPackageLookupMiss",
|
|
"target_package_tape_sha256",
|
|
"target_quantities",
|
|
"verify_target_package",
|
|
]
|
|
# __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',
|
|
'decision_mode': 'target_package',
|
|
'target_package_count': 1,
|
|
'target_package_tape_sha256': '5383c376951637b8d4b1ca42fc6db80347abda74c0181c0f70d6c411fb97ea64',
|
|
'universe_mode': 'fixed',
|
|
'index_symbol': '000905.XSHG',
|
|
'qmt_sector_name': '\u4e2d\u8bc1500',
|
|
'dedicated_account_required': True,
|
|
'exclude_st': True,
|
|
'universe': ['000001.SZ',
|
|
'000333.SZ',
|
|
'000651.SZ',
|
|
'002415.SZ',
|
|
'002594.SZ',
|
|
'300059.SZ',
|
|
'300750.SZ',
|
|
'600000.SH',
|
|
'600519.SH',
|
|
'601012.SH',
|
|
'601318.SH',
|
|
'688981.SH'],
|
|
'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__
|
|
|
|
# __TARGET_PACKAGE_TAPE_START__
|
|
# Generated by tools/bundle_platforms.py; do not edit this region.
|
|
TARGET_PACKAGES = [{'config_sha256': 'e4edf1eb730597e220266c1f8007ddb5e1ab4d26a490cd10738823527d030470',
|
|
'constraints': {'actual_gross_weight': 0.4,
|
|
'constraint_state': 'FEASIBLE',
|
|
'long_only': True,
|
|
'max_gross_weight': 0.9,
|
|
'max_one_way_turnover': 0.45,
|
|
'max_single_weight': 0.1},
|
|
'cost_sha256': 'e2a2d8e6d3829c105a9f6c9ec80d9df18c2dee0553aafeb761abb79d378769a7',
|
|
'data_sha256': 'f5b3e66b0a94eeccb44a114bf3a0bbfad44b5d5da9d5b698c81e83a3e0007d03',
|
|
'decision_id': 'RD-20240311',
|
|
'evidence_class': 'synthetic-research',
|
|
'feature_sha256': '24e671cebc35afe7497b69c59d48bb5bad9401e30c1607f0fc41a69f59082aa1',
|
|
'model_id': 'deterministic-ridge-alpha-5',
|
|
'model_sha256': 'e14e8df7e2f86e6b3178a6e500d1d2c0427bb02d187b13cb4ec672bbf5f9f334',
|
|
'next_session': '2024-03-12',
|
|
'optimizer_sha256': 'b6aba6ab22fc375e89e4bfbdffb10c7261a2fae01b0183265caacb589643c31e',
|
|
'package_id': 'TP-20240311-795dcfba',
|
|
'package_sha256': 'baa15c8156aa6cb54d3775d0cd562613a9d0a4f15b0c39f2a60e0f8b8c6f1f3a',
|
|
'package_type': 'TargetPackageV1',
|
|
'release': 'quant60-research-candidate-v1',
|
|
'risk_sha256': 'a3d95aa6cd569d2452856b148df053b040b2acc2e6b993bbd1072b48b46cde5a',
|
|
'schema_version': '1.0',
|
|
'signal_as_of': '2024-03-11T15:00:00+08:00',
|
|
'source_sha256': '86c1e0514f608b2012b4084dbc947eada90e8015fccbdf8ae13ae99340ede178',
|
|
'target_weights': {'000001.XSHE': 0.0,
|
|
'000333.XSHE': 0.0,
|
|
'000651.XSHE': 0.0,
|
|
'002415.XSHE': 0.0,
|
|
'002594.XSHE': 0.0,
|
|
'300059.XSHE': 0.0,
|
|
'300750.XSHE': 0.0,
|
|
'600000.XSHG': 0.0,
|
|
'600519.XSHG': 0.1,
|
|
'601012.XSHG': 0.1,
|
|
'601318.XSHG': 0.1,
|
|
'688981.XSHG': 0.1},
|
|
'universe': {'000001.XSHE': {'reasons': ['ELIGIBLE'], 'state': 'openable'},
|
|
'000333.XSHE': {'reasons': ['ELIGIBLE'], 'state': 'openable'},
|
|
'000651.XSHE': {'reasons': ['ELIGIBLE'], 'state': 'openable'},
|
|
'002415.XSHE': {'reasons': ['ELIGIBLE'], 'state': 'openable'},
|
|
'002594.XSHE': {'reasons': ['ELIGIBLE'], 'state': 'openable'},
|
|
'300059.XSHE': {'reasons': ['ELIGIBLE'], 'state': 'openable'},
|
|
'300750.XSHE': {'reasons': ['ELIGIBLE'], 'state': 'openable'},
|
|
'600000.XSHG': {'reasons': ['ELIGIBLE'], 'state': 'openable'},
|
|
'600519.XSHG': {'reasons': ['ELIGIBLE'], 'state': 'openable'},
|
|
'601012.XSHG': {'reasons': ['ELIGIBLE'], 'state': 'openable'},
|
|
'601318.XSHG': {'reasons': ['ELIGIBLE'], 'state': 'openable'},
|
|
'688981.XSHG': {'reasons': ['ELIGIBLE'], 'state': 'openable'}}}]
|
|
# __TARGET_PACKAGE_TAPE_END__
|
|
|
|
|
|
MOMENTUM_SMOKE_MODE = "portable_momentum_smoke"
|
|
TARGET_PACKAGE_MODE = "target_package"
|
|
|
|
|
|
def _validated_target_packages(config):
|
|
mode = config.get("decision_mode")
|
|
if mode not in (MOMENTUM_SMOKE_MODE, TARGET_PACKAGE_MODE):
|
|
raise UnsupportedStrategyPeriod("unsupported decision_mode")
|
|
if not isinstance(TARGET_PACKAGES, list):
|
|
raise UnsupportedStrategyPeriod(
|
|
"TARGET_PACKAGES must be a list"
|
|
)
|
|
expected_count = config.get("target_package_count")
|
|
expected_sha256 = config.get("target_package_tape_sha256")
|
|
if mode == MOMENTUM_SMOKE_MODE:
|
|
if (
|
|
type(expected_count) is not int
|
|
or expected_count != 0
|
|
or expected_sha256 is not None
|
|
or TARGET_PACKAGES
|
|
):
|
|
raise UnsupportedStrategyPeriod(
|
|
"portable_momentum_smoke cannot carry target packages"
|
|
)
|
|
return []
|
|
if type(expected_count) is not int or expected_count <= 0:
|
|
raise UnsupportedStrategyPeriod(
|
|
"target_package mode requires a positive target_package_count"
|
|
)
|
|
if expected_count != len(TARGET_PACKAGES):
|
|
raise UnsupportedStrategyPeriod(
|
|
"target package count mismatch"
|
|
)
|
|
actual_sha256 = target_package_tape_sha256(TARGET_PACKAGES)
|
|
if expected_sha256 != actual_sha256:
|
|
raise UnsupportedStrategyPeriod(
|
|
"target package tape sha256 mismatch"
|
|
)
|
|
verified = []
|
|
package_ids = set()
|
|
decision_ids = set()
|
|
clocks = set()
|
|
signal_clocks = set()
|
|
for raw_package in TARGET_PACKAGES:
|
|
package = verify_target_package(raw_package)
|
|
package_id = package["package_id"]
|
|
decision_id = package["decision_id"]
|
|
clock = (package["signal_as_of"], package["next_session"])
|
|
signal_as_of = package["signal_as_of"]
|
|
if (
|
|
package_id in package_ids
|
|
or decision_id in decision_ids
|
|
or clock in clocks
|
|
or signal_as_of in signal_clocks
|
|
):
|
|
raise UnsupportedStrategyPeriod(
|
|
"duplicate or ambiguous target package decision identity"
|
|
)
|
|
package_ids.add(package_id)
|
|
decision_ids.add(decision_id)
|
|
clocks.add(clock)
|
|
signal_clocks.add(signal_as_of)
|
|
verified.append(package)
|
|
return verified
|
|
|
|
|
|
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
|
|
g.target_packages = _validated_target_packages(g.config)
|
|
g.decision_mode = g.config["decision_mode"]
|
|
if g.config["decision_mode"] == MOMENTUM_SMOKE_MODE:
|
|
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
|
|
g.last_target_signal_as_of = None
|
|
if (
|
|
g.config.get("universe_mode") == "fixed"
|
|
and hasattr(ContextInfo, "set_universe")
|
|
):
|
|
ContextInfo.set_universe(g.config["universe"])
|
|
|
|
|
|
def _decision_mode():
|
|
configured = g.config.get("decision_mode")
|
|
frozen = getattr(g, "decision_mode", configured)
|
|
if configured != frozen:
|
|
raise UnsupportedStrategyPeriod(
|
|
"decision_mode cannot change after init"
|
|
)
|
|
return frozen
|
|
|
|
|
|
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 = (
|
|
_decision_mode() == MOMENTUM_SMOKE_MODE
|
|
and 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 _decision_mode() == TARGET_PACKAGE_MODE:
|
|
return _compute_target_package_plan(ContextInfo, bar_time)
|
|
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 _signal_as_of(bar_time):
|
|
if (
|
|
int(bar_time.hour) != 15
|
|
or int(bar_time.minute) != 0
|
|
or int(bar_time.second) != 0
|
|
or int(bar_time.microsecond) != 0
|
|
):
|
|
raise UnsupportedStrategyPeriod(
|
|
"target_package mode requires the completed 15:00 daily close"
|
|
)
|
|
return bar_time.strftime("%Y-%m-%dT15:00:00+08:00")
|
|
|
|
|
|
def _target_package_for_close(bar_time):
|
|
signal_as_of = _signal_as_of(bar_time)
|
|
config = g.config
|
|
packages = _validated_target_packages(config)
|
|
matches = [
|
|
package
|
|
for package in packages
|
|
if package["signal_as_of"] == signal_as_of
|
|
]
|
|
if not matches:
|
|
return None
|
|
if len(matches) != 1:
|
|
raise UnsupportedStrategyPeriod(
|
|
"ambiguous target package signal clock"
|
|
)
|
|
package = matches[0]
|
|
return lookup_target_package_exact(
|
|
packages,
|
|
signal_as_of,
|
|
package["next_session"],
|
|
decision_id=package["decision_id"],
|
|
expected_tape_sha256=config["target_package_tape_sha256"],
|
|
expected_count=config["target_package_count"],
|
|
)
|
|
|
|
|
|
def _target_close_prices(ContextInfo, bar_time, symbols):
|
|
platform_symbols = [
|
|
convert_symbol(symbol, "qmt")
|
|
for symbol in sorted(symbols)
|
|
]
|
|
raw = ContextInfo.get_market_data_ex(
|
|
fields=["close"],
|
|
stock_code=platform_symbols,
|
|
period="1d",
|
|
start_time="",
|
|
end_time=bar_time.strftime("%Y%m%d"),
|
|
count=1,
|
|
dividend_type="front_ratio",
|
|
fill_data=False,
|
|
subscribe=False,
|
|
)
|
|
if not hasattr(raw, "get"):
|
|
raise PriceHistoryUnavailable(
|
|
"target close query returned an invalid payload"
|
|
)
|
|
prices = {}
|
|
for symbol in platform_symbols:
|
|
values = _series_close(raw.get(symbol))
|
|
if len(values) != 1:
|
|
raise PriceHistoryUnavailable(
|
|
"%s needs one completed daily close" % symbol
|
|
)
|
|
try:
|
|
price = float(values[0])
|
|
except (TypeError, ValueError):
|
|
raise PriceHistoryUnavailable(
|
|
"%s contains a missing/non-numeric close" % symbol
|
|
)
|
|
if not math.isfinite(price) or price <= 0.0:
|
|
raise PriceHistoryUnavailable(
|
|
"%s contains a missing/non-positive close" % symbol
|
|
)
|
|
prices[convert_symbol(symbol, "canonical")] = price
|
|
return prices
|
|
|
|
|
|
def _target_lineage(package):
|
|
return {
|
|
"package_id": package["package_id"],
|
|
"package_sha256": package["package_sha256"],
|
|
"decision_id": package["decision_id"],
|
|
"signal_as_of": package["signal_as_of"],
|
|
"next_session": package["next_session"],
|
|
"release": package["release"],
|
|
"model_id": package["model_id"],
|
|
}
|
|
|
|
|
|
def _compute_target_package_plan(ContextInfo, bar_time=None):
|
|
if bar_time is None:
|
|
bar_time = _bar_datetime(ContextInfo)
|
|
package = _target_package_for_close(bar_time)
|
|
if package is None:
|
|
return None
|
|
managed = sorted(package["universe"])
|
|
current, sellable, equity = _portfolio(ContextInfo, managed)
|
|
if equity <= 0:
|
|
raise ValueError("QMT account equity must be positive")
|
|
plan = build_target_weight_plan(
|
|
target_weights=package["target_weights"],
|
|
prices=_target_close_prices(
|
|
ContextInfo,
|
|
bar_time,
|
|
set(package["target_weights"]) | set(current),
|
|
),
|
|
current=current,
|
|
sellable=sellable,
|
|
equity=equity,
|
|
cash_buffer=g.config["cash_buffer"],
|
|
lot_size=g.config["lot_size"],
|
|
decision_id=package["decision_id"],
|
|
package_sha256=package["package_sha256"],
|
|
)
|
|
plan["decision_mode"] = TARGET_PACKAGE_MODE
|
|
plan["lineage"] = _target_lineage(package)
|
|
plan["universe"] = package["universe"]
|
|
plan["execution_contract"] = {
|
|
"signal_price_source": "COMPLETED_DAILY_CLOSE",
|
|
"submit_trigger": "NEXT_BAR_FIRST_TICK",
|
|
"quick_trade": 0,
|
|
"declared_next_session": package["next_session"],
|
|
"real_platform_observed": False,
|
|
}
|
|
return plan
|
|
|
|
|
|
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 _submit_target_delta(ContextInfo, canonical, delta, package_sha256):
|
|
config = g.config
|
|
order_code = convert_symbol(canonical, "qmt")
|
|
operation = 23 if delta > 0 else 24
|
|
volume = abs(int(delta))
|
|
user_order_id = "q60t-%s-%s" % (
|
|
str(package_sha256)[:8],
|
|
order_code.split(".")[0],
|
|
)
|
|
function = globals()["passorder"]
|
|
arg_count = getattr(getattr(function, "__code__", None), "co_argcount", 11)
|
|
if arg_count <= 8:
|
|
function(
|
|
operation,
|
|
1101,
|
|
config["account_id"],
|
|
order_code,
|
|
5,
|
|
-1,
|
|
volume,
|
|
ContextInfo,
|
|
)
|
|
else:
|
|
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)
|
|
if _decision_mode() == TARGET_PACKAGE_MODE:
|
|
signal_as_of = _signal_as_of(bar_time)
|
|
if g.last_target_signal_as_of == signal_as_of:
|
|
return None
|
|
plan = compute_plan(ContextInfo, bar_time)
|
|
# Consume the clock before crossing passorder so a callback retry
|
|
# cannot duplicate an already accepted order prefix.
|
|
g.last_target_signal_as_of = signal_as_of
|
|
if plan is None:
|
|
return None
|
|
g.last_plan = plan
|
|
if not _orders_allowed(ContextInfo):
|
|
return plan
|
|
ordered = sorted(
|
|
plan["orders"].items(),
|
|
key=lambda item: item[1],
|
|
)
|
|
for canonical, delta in ordered:
|
|
if int(delta) != 0:
|
|
_submit_target_delta(
|
|
ContextInfo,
|
|
canonical,
|
|
delta,
|
|
plan["lineage"]["package_sha256"],
|
|
)
|
|
return plan
|
|
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
|