242 lines
7.6 KiB
Python
242 lines
7.6 KiB
Python
"""Generate a standalone JoinQuant wrapper strategy.
|
|
|
|
The module also defines default JoinQuant strategy hooks by executing the same
|
|
template with ``run1`` / ``target_shares`` defaults. That means this file can be
|
|
copied directly into JoinQuant for a quick smoke test, while the CLI can still
|
|
write a configured standalone file for a real run.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from string import Template
|
|
from typing import Literal
|
|
|
|
|
|
WrapperMode = Literal["target_shares", "target_value"]
|
|
|
|
|
|
_WRAPPER_TEMPLATE = Template(
|
|
r'''# Standalone JoinQuant target wrapper generated by chinese-equity-quant.
|
|
# Copy this file and the exported daily CSV target files into JoinQuant.
|
|
#
|
|
# The file loader is isolated in _read_target_file(). The default implementation
|
|
# uses JoinQuant's read_file API for uploaded files. If your JoinQuant runtime
|
|
# allows HTTP or another storage backend, replace only that function.
|
|
|
|
import csv
|
|
import io
|
|
import json
|
|
|
|
|
|
PORTFOLIO_NAME = "${portfolio_name}"
|
|
TARGET_MODE = "${mode}"
|
|
ALLOW_SHORT = ${allow_short}
|
|
TARGET_FILE_PREFIX = "" # Optional uploaded-file prefix, for example "run1/"
|
|
|
|
|
|
def initialize(context):
|
|
set_benchmark("000300.XSHG")
|
|
set_option("use_real_price", True)
|
|
g.portfolio_name = PORTFOLIO_NAME
|
|
g.target_mode = TARGET_MODE
|
|
g.targets_by_date = {}
|
|
run_daily(load_targets, time="before_open")
|
|
run_daily(rebalance_at_open, time="open")
|
|
run_daily(record_after_close, time="after_close")
|
|
|
|
|
|
def _today_text(context):
|
|
return context.current_dt.strftime("%Y-%m-%d")
|
|
|
|
|
|
def _today_file_name(context):
|
|
return context.current_dt.strftime("%Y%m%d") + ".csv"
|
|
|
|
|
|
def _read_target_file(file_name):
|
|
data = read_file(TARGET_FILE_PREFIX + file_name)
|
|
if isinstance(data, bytes):
|
|
data = data.decode("utf-8")
|
|
return data
|
|
|
|
|
|
def _load_target_rows(context):
|
|
file_name = _today_file_name(context)
|
|
text = _read_target_file(file_name)
|
|
rows = list(csv.DictReader(io.StringIO(text)))
|
|
clean_rows = []
|
|
for row in rows:
|
|
if row.get("portfolio_name") and row["portfolio_name"] != PORTFOLIO_NAME:
|
|
continue
|
|
jq_symbol = row.get("jq_symbol") or row.get("security") or row.get("symbol")
|
|
if not jq_symbol:
|
|
log.warn("Skipping target row with no jq_symbol: %s" % row)
|
|
continue
|
|
|
|
if TARGET_MODE == "target_shares":
|
|
target = int(float(row.get("target_shares") or 0))
|
|
elif TARGET_MODE == "target_value":
|
|
target = float(row.get("target_value") or 0.0)
|
|
else:
|
|
raise ValueError("Unsupported TARGET_MODE: %s" % TARGET_MODE)
|
|
|
|
if not ALLOW_SHORT and target < 0:
|
|
log.warn(
|
|
"SHORT_NOT_SUPPORTED clipping %s target from %s to 0" %
|
|
(jq_symbol, target)
|
|
)
|
|
target = 0
|
|
|
|
clean_rows.append({"jq_symbol": jq_symbol, "target": target, "raw": row})
|
|
return clean_rows
|
|
|
|
|
|
def load_targets(context):
|
|
date_text = _today_text(context)
|
|
try:
|
|
rows = _load_target_rows(context)
|
|
except Exception as exc:
|
|
log.error("Failed to load JoinQuant target file for %s: %s" % (date_text, exc))
|
|
rows = []
|
|
g.targets_by_date[date_text] = rows
|
|
log.info("JOINQUANT_TARGET_LOAD|%s" % json.dumps({
|
|
"date": date_text,
|
|
"portfolio_name": PORTFOLIO_NAME,
|
|
"target_mode": TARGET_MODE,
|
|
"n_targets": len(rows),
|
|
}, sort_keys=True))
|
|
|
|
|
|
def rebalance_at_open(context):
|
|
date_text = _today_text(context)
|
|
rows = g.targets_by_date.get(date_text, [])
|
|
target_symbols = set()
|
|
for row in rows:
|
|
security = row["jq_symbol"]
|
|
target_symbols.add(security)
|
|
if TARGET_MODE == "target_shares":
|
|
order_target(security, int(row["target"]))
|
|
else:
|
|
order_target_value(security, float(row["target"]))
|
|
log.info("JOINQUANT_ORDER_SUBMIT|%s" % json.dumps({
|
|
"date": date_text,
|
|
"portfolio_name": PORTFOLIO_NAME,
|
|
"jq_symbol": security,
|
|
"target_mode": TARGET_MODE,
|
|
"target": row["target"],
|
|
}, sort_keys=True))
|
|
|
|
for security in list(context.portfolio.positions.keys()):
|
|
if security not in target_symbols:
|
|
order_target(security, 0)
|
|
log.info("JOINQUANT_ORDER_CLOSE|%s" % json.dumps({
|
|
"date": date_text,
|
|
"portfolio_name": PORTFOLIO_NAME,
|
|
"jq_symbol": security,
|
|
}, sort_keys=True))
|
|
|
|
|
|
def _position_records(context):
|
|
records = []
|
|
cash = float(context.portfolio.available_cash)
|
|
total_value = float(context.portfolio.total_value)
|
|
for security, position in context.portfolio.positions.items():
|
|
records.append({
|
|
"date": _today_text(context),
|
|
"portfolio_name": PORTFOLIO_NAME,
|
|
"jq_symbol": security,
|
|
"position_shares": int(position.total_amount),
|
|
"position_value": float(position.value),
|
|
"cash": cash,
|
|
"total_value": total_value,
|
|
})
|
|
return records
|
|
|
|
|
|
def _trade_records(context):
|
|
records = []
|
|
try:
|
|
trades = get_trades()
|
|
except Exception:
|
|
trades = {}
|
|
for trade_id, trade in trades.items():
|
|
amount = int(getattr(trade, "amount", 0))
|
|
price = float(getattr(trade, "price", 0.0))
|
|
security = getattr(trade, "security", "")
|
|
side = "buy" if amount >= 0 else "sell"
|
|
records.append({
|
|
"date": _today_text(context),
|
|
"portfolio_name": PORTFOLIO_NAME,
|
|
"jq_symbol": security,
|
|
"order_id": str(getattr(trade, "order_id", trade_id)),
|
|
"side": side,
|
|
"filled_shares": amount,
|
|
"fill_price": price,
|
|
"trade_value": abs(amount * price),
|
|
"trade_cost": float(getattr(trade, "commission", 0.0)),
|
|
"raw_status": "filled",
|
|
})
|
|
return records
|
|
|
|
|
|
def record_after_close(context):
|
|
date_text = _today_text(context)
|
|
for record in _trade_records(context):
|
|
log.info("JOINQUANT_FILL|%s" % json.dumps(record, sort_keys=True))
|
|
for record in _position_records(context):
|
|
log.info("JOINQUANT_POSITION|%s" % json.dumps(record, sort_keys=True))
|
|
log.info("JOINQUANT_PNL|%s" % json.dumps({
|
|
"date": date_text,
|
|
"portfolio_name": PORTFOLIO_NAME,
|
|
"cash": float(context.portfolio.available_cash),
|
|
"total_value": float(context.portfolio.total_value),
|
|
}, sort_keys=True))
|
|
'''
|
|
)
|
|
|
|
|
|
# Make this module itself usable as a JoinQuant strategy with defaults.
|
|
exec(_WRAPPER_TEMPLATE.substitute(
|
|
portfolio_name="run1",
|
|
mode="target_shares",
|
|
allow_short="False",
|
|
))
|
|
|
|
|
|
def render_wrapper_strategy(
|
|
*,
|
|
portfolio_name: str,
|
|
mode: WrapperMode = "target_shares",
|
|
allow_short: bool = False,
|
|
) -> str:
|
|
"""Render the standalone JoinQuant wrapper strategy source."""
|
|
if mode not in {"target_shares", "target_value"}:
|
|
raise ValueError("mode must be 'target_shares' or 'target_value'")
|
|
return _WRAPPER_TEMPLATE.substitute(
|
|
portfolio_name=portfolio_name,
|
|
mode=mode,
|
|
allow_short="True" if allow_short else "False",
|
|
)
|
|
|
|
|
|
def write_wrapper_strategy(
|
|
*,
|
|
portfolio_name: str,
|
|
mode: WrapperMode = "target_shares",
|
|
out_path: str | Path,
|
|
allow_short: bool = False,
|
|
) -> Path:
|
|
"""Write a configured standalone JoinQuant wrapper strategy."""
|
|
path = Path(out_path)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(
|
|
render_wrapper_strategy(
|
|
portfolio_name=portfolio_name,
|
|
mode=mode,
|
|
allow_short=allow_short,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return path
|