Files
quant-os/src/quant60/portable_core.py
T

375 lines
12 KiB
Python

"""Small strategy core that also runs on Python 3.6 platform runtimes.
Keep this module deliberately boring: no dataclasses, no third-party imports,
no modern annotation syntax. JoinQuant/QMT/Qlib adapters can vendor this
single file when importing the complete ``quant60`` package is not possible.
"""
from __future__ import division
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",
]