Add JoinQuant comparison plugin
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user