Automate local JoinQuant smoke prep
This commit is contained in:
@@ -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
|
positions, and PnL, then run ingest and reconcile. Start with one or two days
|
||||||
before expanding the sample.
|
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
|
## Recommended First Sanity Checks
|
||||||
|
|
||||||
1. One liquid stock with a fixed target share count.
|
1. One liquid stock with a fixed target share count.
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ The plugin validates system mechanics, not alpha quality:
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
uv run python cli.py joinquant prepare-smoke \
|
||||||
|
--out-dir /tmp/chinese-equity-quant-realdata
|
||||||
|
|
||||||
uv run python cli.py joinquant export-targets \
|
uv run python cli.py joinquant export-targets \
|
||||||
--positions-path portfolio/run1.pq \
|
--positions-path portfolio/run1.pq \
|
||||||
--portfolio-name run1 \
|
--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`
|
For strict simulator-vs-JoinQuant comparison, pass `--execution-calendar-path`
|
||||||
so position dates are shifted to the next session open, matching the internal
|
so position dates are shifted to the next session open, matching the internal
|
||||||
simulator's next-open convention.
|
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.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import click
|
|||||||
from plugins.joinquant.export_targets import export_targets
|
from plugins.joinquant.export_targets import export_targets
|
||||||
from plugins.joinquant.ingest import ingest_joinquant_outputs
|
from plugins.joinquant.ingest import ingest_joinquant_outputs
|
||||||
from plugins.joinquant.reconcile import reconcile_joinquant
|
from plugins.joinquant.reconcile import reconcile_joinquant
|
||||||
|
from plugins.joinquant.smoke import prepare_smoke_test
|
||||||
from plugins.joinquant.wrapper_strategy import write_wrapper_strategy
|
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,
|
allow_short=allow_short,
|
||||||
)
|
)
|
||||||
click.echo(f"Saved JoinQuant wrapper strategy: {path}")
|
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']}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,6 +25,7 @@ from plugins.joinquant.schema import (
|
|||||||
JOINQUANT_TARGET_COLUMNS,
|
JOINQUANT_TARGET_COLUMNS,
|
||||||
RECONCILE_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.symbols import from_joinquant_symbol, to_joinquant_symbol
|
||||||
from plugins.joinquant.wrapper_strategy import write_wrapper_strategy
|
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 'PORTFOLIO_NAME = "run2"' in text
|
||||||
assert 'TARGET_MODE = "target_value"' in text
|
assert 'TARGET_MODE = "target_value"' in text
|
||||||
assert "order_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]
|
||||||
|
|||||||
Reference in New Issue
Block a user