feat: add Quant OS A-share baseline
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user