Automate local JoinQuant smoke prep

This commit is contained in:
Yuxuan Yan
2026-07-04 17:55:19 +08:00
parent c200508f9e
commit 5388359dc8
5 changed files with 300 additions and 0 deletions
+13
View File
@@ -225,6 +225,19 @@ files and wrapper to JoinQuant, run the JoinQuant backtest, export fills,
positions, and PnL, then run ingest and reconcile. Start with one or two days
before expanding the sample.
For the first one-stock long-only smoke test, the local side can be prepared in
one command:
```bash
uv run python cli.py joinquant prepare-smoke \
--out-dir /tmp/chinese-equity-quant-realdata
```
The command downloads a tiny public daily-bar sample, builds a fixed-share
`sh600000` long-only position file, simulates it internally, exports aligned
JoinQuant target files, writes a configured wrapper strategy, and creates
`joinquant_smoke_manifest.json` with all output paths.
## Recommended First Sanity Checks
1. One liquid stock with a fixed target share count.
+8
View File
@@ -18,6 +18,9 @@ The plugin validates system mechanics, not alpha quality:
## Commands
```bash
uv run python cli.py joinquant prepare-smoke \
--out-dir /tmp/chinese-equity-quant-realdata
uv run python cli.py joinquant export-targets \
--positions-path portfolio/run1.pq \
--portfolio-name run1 \
@@ -56,3 +59,8 @@ from `portfolio build`, matching what the internal simulator executes.
For strict simulator-vs-JoinQuant comparison, pass `--execution-calendar-path`
so position dates are shifted to the next session open, matching the internal
simulator's next-open convention.
`prepare-smoke` automates the local side of the first sanity check: tiny real
data download, one-stock long-only position file, internal simulation, aligned
target export, wrapper generation, and a manifest with expected JoinQuant CSV
export paths.
+63
View File
@@ -7,6 +7,7 @@ import click
from plugins.joinquant.export_targets import export_targets
from plugins.joinquant.ingest import ingest_joinquant_outputs
from plugins.joinquant.reconcile import reconcile_joinquant
from plugins.joinquant.smoke import prepare_smoke_test
from plugins.joinquant.wrapper_strategy import write_wrapper_strategy
@@ -154,3 +155,65 @@ def write_wrapper_cmd(portfolio_name, mode, out_path, allow_short):
allow_short=allow_short,
)
click.echo(f"Saved JoinQuant wrapper strategy: {path}")
@joinquant.command("prepare-smoke")
@click.option("--out-dir", required=True, help="Root directory for generated smoke-test artifacts")
@click.option(
"--universe",
default="sh600000,sz000001,sh600519,sz002594,sz300750",
show_default=True,
help="Universe for the tiny real-data download",
)
@click.option("--trade-symbol", default="sh600000", show_default=True)
@click.option("--start-date", default="2024-01-02", show_default=True)
@click.option("--end-date", default="2024-01-12", show_default=True)
@click.option("--portfolio-name", default="jq_smoke_one_stock_long", show_default=True)
@click.option("--shares", default=1000, show_default=True, type=int)
@click.option("--booksize", default=1_000_000.0, show_default=True, type=float)
@click.option("--max-signal-dates", default=3, show_default=True, type=int)
@click.option("--cost-bps", default=5.0, show_default=True, type=float)
@click.option("--slippage-bps", default=5.0, show_default=True, type=float)
@click.option("--volume-frac", default=0.02, show_default=True, type=float)
@click.option("--force", is_flag=True, help="Overwrite existing frozen target files")
def prepare_smoke_cmd(
out_dir,
universe,
trade_symbol,
start_date,
end_date,
portfolio_name,
shares,
booksize,
max_signal_dates,
cost_bps,
slippage_bps,
volume_frac,
force,
):
"""Prepare a one-command local real-data JoinQuant smoke test."""
manifest = prepare_smoke_test(
out_dir=out_dir,
universe=universe,
trade_symbol=trade_symbol,
start_date=start_date,
end_date=end_date,
portfolio_name=portfolio_name,
shares=shares,
booksize=booksize,
max_signal_dates=max_signal_dates,
cost_bps=cost_bps,
slippage_bps=slippage_bps,
volume_frac=volume_frac,
force=force,
)
click.echo(f"Prepared JoinQuant smoke manifest: {manifest['manifest_path']}")
click.echo(f"Wrapper: {manifest['wrapper_path']}")
click.echo(f"Targets: {manifest['targets_dir']}")
click.echo(f"Expected JoinQuant exports: {manifest['joinquant_export_dir']}")
summary = manifest["local_summary"]
click.echo(
f"Local simulator: {summary['n_pnl_rows']} days, "
f"PnL={summary['total_pnl']:,.2f}, cost={summary['total_cost']:,.2f}, "
f"blocked={summary['blocked_trades']}"
)
+191
View File
@@ -0,0 +1,191 @@
"""End-to-end local smoke preparation for JoinQuant comparison."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from pathlib import Path
import pandas as pd
from pipeline.common.schema import POSITION_COLUMNS
from pipeline.data.downloader import download_universe
from pipeline.portfolio.constraints import get_constraint
from pipeline.portfolio.simulator import ReferenceSimulator
from plugins.joinquant.export_targets import export_targets
from plugins.joinquant.wrapper_strategy import write_wrapper_strategy
def build_fixed_share_positions(
data: pd.DataFrame,
*,
trade_symbol: str,
portfolio_name: str,
shares: int,
booksize: float,
max_signal_dates: int | None = None,
) -> pd.DataFrame:
"""Create a deterministic long-only fixed-share position book.
The final available data date is excluded because the internal simulator
executes each signal date at the next available open.
"""
data = data.copy()
data["date"] = pd.to_datetime(data["date"]).dt.normalize()
symbol_data = (
data[data["symbol_id"].astype(str) == trade_symbol]
.sort_values("date")
.reset_index(drop=True)
)
if len(symbol_data) < 2:
raise ValueError(f"Need at least two daily bars for {trade_symbol}")
signal_data = symbol_data.iloc[:-1].copy()
if max_signal_dates is not None and max_signal_dates > 0:
signal_data = signal_data.tail(max_signal_dates)
if signal_data.empty:
raise ValueError("No signal dates available after excluding final data date")
rows: list[dict[str, object]] = []
for row in signal_data.itertuples(index=False):
price = float(row.close)
target_value = float(shares * price)
rows.append({
"symbol_id": trade_symbol,
"date": pd.Timestamp(row.date),
"portfolio_name": portfolio_name,
"target_weight": target_value / float(booksize),
"target_value": target_value,
"target_shares": float(shares),
"position_shares": int(shares),
"position_value": target_value,
"price": price,
})
return pd.DataFrame(rows, columns=POSITION_COLUMNS)
def prepare_smoke_test(
*,
out_dir: str | Path,
universe: str = "sh600000,sz000001,sh600519,sz002594,sz300750",
trade_symbol: str = "sh600000",
start_date: str = "2024-01-02",
end_date: str = "2024-01-12",
portfolio_name: str = "jq_smoke_one_stock_long",
shares: int = 1000,
booksize: float = 1_000_000.0,
max_signal_dates: int = 3,
cost_bps: float = 5.0,
slippage_bps: float = 5.0,
volume_frac: float = 0.02,
force: bool = False,
) -> dict[str, object]:
"""Run the local side of a tiny real-data JoinQuant smoke test."""
root = Path(out_dir)
root.mkdir(parents=True, exist_ok=True)
stats = download_universe(
universe=universe,
start_date=start_date,
end_date=end_date,
output_dir=root / "daily_bars",
max_symbols=0,
chunk_size=100,
adjust="qfq",
)
data_path = Path(stats["dataset_path"])
data = pd.read_parquet(data_path)
positions = build_fixed_share_positions(
data,
trade_symbol=trade_symbol,
portfolio_name=portfolio_name,
shares=shares,
booksize=booksize,
max_signal_dates=max_signal_dates,
)
portfolio_dir = root / "portfolio"
portfolio_dir.mkdir(parents=True, exist_ok=True)
positions_path = portfolio_dir / f"{portfolio_name}.pq"
positions.to_parquet(positions_path, index=False)
constraints = [
get_constraint("suspension"),
get_constraint("price_limit"),
get_constraint("volume_cap", max_frac=volume_frac),
]
sim = ReferenceSimulator(
constraints=constraints,
cost_bps=cost_bps,
slippage_bps=slippage_bps,
)
fills, pnl = sim.run(positions, data)
execution_dir = root / "execution"
fills_dir = execution_dir / "fills"
pnl_dir = execution_dir / "pnl"
fills_dir.mkdir(parents=True, exist_ok=True)
pnl_dir.mkdir(parents=True, exist_ok=True)
fills_path = fills_dir / f"{portfolio_name}.pq"
pnl_path = pnl_dir / f"{portfolio_name}.pq"
fills.to_parquet(fills_path, index=False)
pnl.to_parquet(pnl_path, index=False)
target_root = root / "plugins_output" / "joinquant" / "targets_aligned"
snapshots = export_targets(
positions_path=positions_path,
portfolio_name=portfolio_name,
mode="target_shares",
out_dir=target_root,
execution_calendar_path=data_path,
force=force,
)
wrapper_path = root / "plugins_output" / "joinquant" / f"wrapper_strategy_{portfolio_name}.py"
write_wrapper_strategy(
portfolio_name=portfolio_name,
mode="target_shares",
out_path=wrapper_path,
)
export_dir = root / "joinquant_exports"
export_dir.mkdir(parents=True, exist_ok=True)
manifest = {
"created_at": datetime.now(timezone.utc).isoformat(),
"portfolio_name": portfolio_name,
"universe": universe,
"trade_symbol": trade_symbol,
"start_date": start_date,
"end_date": end_date,
"shares": shares,
"booksize": booksize,
"data_path": str(data_path),
"positions_path": str(positions_path),
"fills_path": str(fills_path),
"pnl_path": str(pnl_path),
"targets_dir": str(target_root / portfolio_name),
"wrapper_path": str(wrapper_path),
"joinquant_export_dir": str(export_dir),
"expected_joinquant_csvs": {
"fills": str(export_dir / "jq_fills.csv"),
"positions": str(export_dir / "jq_positions.csv"),
"pnl": str(export_dir / "jq_pnl.csv"),
},
"target_snapshots": snapshots,
"local_summary": {
"n_data_rows": int(len(data)),
"n_position_rows": int(len(positions)),
"n_fill_rows": int(len(fills)),
"n_pnl_rows": int(len(pnl)),
"total_pnl": float(pnl["pnl"].sum()) if len(pnl) else 0.0,
"total_cost": float(pnl["cost"].sum()) if len(pnl) else 0.0,
"blocked_trades": int(fills["blocked"].sum()) if len(fills) else 0,
},
}
manifest_path = root / "joinquant_smoke_manifest.json"
manifest["manifest_path"] = str(manifest_path)
manifest_path.write_text(
json.dumps(manifest, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
return manifest
+25
View File
@@ -25,6 +25,7 @@ from plugins.joinquant.schema import (
JOINQUANT_TARGET_COLUMNS,
RECONCILE_COLUMNS,
)
from plugins.joinquant.smoke import build_fixed_share_positions
from plugins.joinquant.symbols import from_joinquant_symbol, to_joinquant_symbol
from plugins.joinquant.wrapper_strategy import write_wrapper_strategy
@@ -505,3 +506,27 @@ def test_wrapper_strategy_generation_smoke(tmp_path):
assert 'PORTFOLIO_NAME = "run2"' in text
assert 'TARGET_MODE = "target_value"' in text
assert "order_target_value" in text
def test_build_fixed_share_positions_excludes_final_executionless_date():
data = pd.DataFrame({
"symbol_id": ["sh600000", "sh600000", "sh600000"],
"date": pd.to_datetime(["2024-01-09", "2024-01-10", "2024-01-11"]),
"close": [10.0, 10.5, 11.0],
})
positions = build_fixed_share_positions(
data,
trade_symbol="sh600000",
portfolio_name="run1",
shares=1000,
booksize=1_000_000.0,
)
assert list(positions.columns) == POSITION_COLUMNS
assert positions["date"].dt.strftime("%Y-%m-%d").tolist() == [
"2024-01-09",
"2024-01-10",
]
assert positions["position_shares"].tolist() == [1000, 1000]
assert positions["target_value"].tolist() == [10_000.0, 10_500.0]