From 5388359dc896889ab036054af1f4fc4b797c6bbd Mon Sep 17 00:00:00 2001 From: Yuxuan Yan Date: Sat, 4 Jul 2026 17:55:19 +0800 Subject: [PATCH] Automate local JoinQuant smoke prep --- docs/joinquant_comparison_plugin.md | 13 ++ plugins/joinquant/README.md | 8 ++ plugins/joinquant/cli.py | 63 +++++++++ plugins/joinquant/smoke.py | 191 ++++++++++++++++++++++++++++ tests/test_joinquant_plugin.py | 25 ++++ 5 files changed, 300 insertions(+) create mode 100644 plugins/joinquant/smoke.py diff --git a/docs/joinquant_comparison_plugin.md b/docs/joinquant_comparison_plugin.md index 0e6c4d9..6cd0e88 100644 --- a/docs/joinquant_comparison_plugin.md +++ b/docs/joinquant_comparison_plugin.md @@ -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. diff --git a/plugins/joinquant/README.md b/plugins/joinquant/README.md index 9739e3c..6b29f75 100644 --- a/plugins/joinquant/README.md +++ b/plugins/joinquant/README.md @@ -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. diff --git a/plugins/joinquant/cli.py b/plugins/joinquant/cli.py index 7bfadc9..7848aaf 100644 --- a/plugins/joinquant/cli.py +++ b/plugins/joinquant/cli.py @@ -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']}" + ) diff --git a/plugins/joinquant/smoke.py b/plugins/joinquant/smoke.py new file mode 100644 index 0000000..22896bb --- /dev/null +++ b/plugins/joinquant/smoke.py @@ -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 + diff --git a/tests/test_joinquant_plugin.py b/tests/test_joinquant_plugin.py index feca0f2..281f16d 100644 --- a/tests/test_joinquant_plugin.py +++ b/tests/test_joinquant_plugin.py @@ -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]