diff --git a/docs/joinquant_comparison_plugin.md b/docs/joinquant_comparison_plugin.md index eb0ffaf..0e6c4d9 100644 --- a/docs/joinquant_comparison_plugin.md +++ b/docs/joinquant_comparison_plugin.md @@ -39,6 +39,7 @@ uv run python cli.py joinquant export-targets \ --positions-path portfolio/run1.pq \ --portfolio-name run1 \ --mode target_shares \ + --execution-calendar-path data/daily_bars/ \ --out-dir plugins_output/joinquant/targets # 3. Generate and copy the wrapper strategy and target files into JoinQuant. @@ -79,6 +80,7 @@ uv run python cli.py joinquant export-targets \ --positions-path portfolio/run1.pq \ --portfolio-name run1 \ --mode target_shares \ + --execution-calendar-path data/daily_bars/ \ --start-date T \ --end-date T ``` @@ -94,6 +96,12 @@ target file after observing open or close data for the same trading date. The exporter writes a snapshot JSON with a SHA-256 hash for this reason and refuses to overwrite existing target/snapshot files unless `--force` is passed. +For comparisons against the internal simulator, pass +`--execution-calendar-path` to `joinquant export-targets`. The positions file is +dated by construction/signal date, while the simulator executes at the next +available open. The calendar option shifts exported target files to that next +trading date, so JoinQuant reads the same target on the same execution session. + ## Target-Shares Mode `target_shares` is the default and preferred correctness mode. The exported @@ -234,4 +242,3 @@ before expanding the sample. - Corporate actions may need special handling and should not be hidden. - The internal simulator does not currently emit execution price in `FILL_COLUMNS`; price reconciliation uses explicit price columns if supplied. - diff --git a/plugins/joinquant/README.md b/plugins/joinquant/README.md index 4da2d47..9739e3c 100644 --- a/plugins/joinquant/README.md +++ b/plugins/joinquant/README.md @@ -22,6 +22,7 @@ uv run python cli.py joinquant export-targets \ --positions-path portfolio/run1.pq \ --portfolio-name run1 \ --mode target_shares \ + --execution-calendar-path data/daily_bars/csi500 \ --start-date 2026-07-01 \ --end-date 2026-07-31 \ --out-dir plugins_output/joinquant/targets @@ -52,4 +53,6 @@ uv run python cli.py joinquant reconcile \ `target_shares` is the default and uses the built integer `position_shares` 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. diff --git a/plugins/joinquant/cli.py b/plugins/joinquant/cli.py index 5606c1b..7bfadc9 100644 --- a/plugins/joinquant/cli.py +++ b/plugins/joinquant/cli.py @@ -28,9 +28,23 @@ def joinquant(): ) @click.option("--start-date", default=None, help="Inclusive YYYY-MM-DD start date") @click.option("--end-date", default=None, help="Inclusive YYYY-MM-DD end date") +@click.option( + "--execution-calendar-path", + default=None, + help="Daily data parquet/dataset used to shift position dates to next execution session", +) @click.option("--out-dir", default="plugins_output/joinquant/targets", show_default=True) @click.option("--force", is_flag=True, help="Overwrite frozen target/snapshot files") -def export_targets_cmd(positions_path, portfolio_name, mode, start_date, end_date, out_dir, force): +def export_targets_cmd( + positions_path, + portfolio_name, + mode, + start_date, + end_date, + execution_calendar_path, + out_dir, + force, +): """Export frozen daily target files for JoinQuant.""" snapshots = export_targets( positions_path=positions_path, @@ -38,6 +52,7 @@ def export_targets_cmd(positions_path, portfolio_name, mode, start_date, end_dat mode=mode, start_date=start_date, end_date=end_date, + execution_calendar_path=execution_calendar_path, out_dir=out_dir, force=force, ) @@ -139,4 +154,3 @@ def write_wrapper_cmd(portfolio_name, mode, out_path, allow_short): allow_short=allow_short, ) click.echo(f"Saved JoinQuant wrapper strategy: {path}") - diff --git a/plugins/joinquant/export_targets.py b/plugins/joinquant/export_targets.py index 8e467b0..63ed21d 100644 --- a/plugins/joinquant/export_targets.py +++ b/plugins/joinquant/export_targets.py @@ -51,16 +51,41 @@ def _filter_dates( df: pd.DataFrame, start_date: str | None, end_date: str | None, + *, + date_column: str = "date", ) -> pd.DataFrame: out = df.copy() - out["date"] = pd.to_datetime(out["date"]).dt.normalize() + out[date_column] = pd.to_datetime(out[date_column]).dt.normalize() if start_date: - out = out[out["date"] >= pd.Timestamp(start_date).normalize()] + out = out[out[date_column] >= pd.Timestamp(start_date).normalize()] if end_date: - out = out[out["date"] <= pd.Timestamp(end_date).normalize()] + out = out[out[date_column] <= pd.Timestamp(end_date).normalize()] return out +def _read_execution_calendar(path: str | Path) -> pd.DatetimeIndex: + data = pd.read_parquet(path) + if "date" not in data.columns: + raise ValueError("execution calendar parquet must contain a 'date' column") + dates = pd.to_datetime(data["date"], errors="coerce").dropna().dt.normalize() + return pd.DatetimeIndex(sorted(dates.unique())) + + +def _apply_execution_calendar(df: pd.DataFrame, calendar_path: str | Path) -> pd.DataFrame: + calendar = _read_execution_calendar(calendar_path) + if calendar.empty: + raise ValueError("execution calendar contains no dates") + + source_dates = pd.to_datetime(df["date"]).dt.normalize() + positions = calendar.searchsorted(source_dates, side="right") + out = df.copy() + out["source_date"] = source_dates + out["export_date"] = pd.NaT + valid = positions < len(calendar) + out.loc[valid, "export_date"] = calendar.take(positions[valid]) + return out[out["export_date"].notna()].copy() + + def build_target_frame( positions: pd.DataFrame, *, @@ -69,6 +94,7 @@ def build_target_frame( start_date: str | None = None, end_date: str | None = None, snapshot_ids: dict[str, str] | None = None, + execution_calendar_path: str | Path | None = None, ) -> pd.DataFrame: """Build normalized JoinQuant target rows from portfolio positions. @@ -79,7 +105,15 @@ def build_target_frame( raise ValueError("mode must be 'target_shares' or 'target_value'") _check_position_columns(positions) - df = _filter_dates(positions, start_date, end_date) + df = positions.copy() + df["date"] = pd.to_datetime(df["date"]).dt.normalize() + if execution_calendar_path is not None: + df = _apply_execution_calendar(df, execution_calendar_path) + df = df.drop(columns=["date"]).rename(columns={"export_date": "date"}) + df["source_date"] = pd.to_datetime(df["source_date"]).dt.strftime("%Y-%m-%d") + df = _filter_dates(df, start_date, end_date) + else: + df = _filter_dates(df, start_date, end_date) if portfolio_name is not None: df = df[df["portfolio_name"].astype(str) == portfolio_name] if df.empty: @@ -113,6 +147,7 @@ def export_targets( out_dir: str | Path = "plugins_output/joinquant/targets", start_date: str | None = None, end_date: str | None = None, + execution_calendar_path: str | Path | None = None, force: bool = False, ) -> list[dict[str, object]]: """Export one daily CSV/parquet target file plus a snapshot JSON per date. @@ -126,6 +161,9 @@ def export_targets( sibling ``snapshots`` directory. start_date: Optional inclusive start date. end_date: Optional inclusive end date. + execution_calendar_path: Optional daily-bar parquet dataset used to + shift each position date to the next available execution session. + This matches the internal simulator's next-open convention. force: If false, existing target or snapshot files are treated as frozen and cause ``FileExistsError``. @@ -141,7 +179,15 @@ def export_targets( snapshots_portfolio_dir.mkdir(parents=True, exist_ok=True) positions = pd.read_parquet(positions_path) - filtered = _filter_dates(positions, start_date, end_date) + filtered = positions.copy() + filtered["date"] = pd.to_datetime(filtered["date"]).dt.normalize() + if execution_calendar_path is not None: + filtered = _apply_execution_calendar(filtered, execution_calendar_path) + filtered = filtered.drop(columns=["date"]).rename(columns={"export_date": "date"}) + filtered["source_date"] = pd.to_datetime(filtered["source_date"]).dt.strftime("%Y-%m-%d") + filtered = _filter_dates(filtered, start_date, end_date) + else: + filtered = _filter_dates(filtered, start_date, end_date) filtered = filtered[filtered["portfolio_name"].astype(str) == portfolio_name] if filtered.empty: return [] @@ -156,6 +202,7 @@ def export_targets( portfolio_name=portfolio_name, mode=mode, snapshot_ids=snapshot_ids, + execution_calendar_path=None, ) snapshots: list[dict[str, object]] = [] @@ -184,6 +231,7 @@ def export_targets( "date": date_text, "export_mode": mode, "source_positions_path": str(positions_path), + "execution_calendar_path": str(execution_calendar_path) if execution_calendar_path else None, "created_at": datetime.now(timezone.utc).isoformat(), "n_symbols": int(len(daily)), "file_sha256": file_hash, @@ -198,4 +246,3 @@ def export_targets( snapshots.append(snapshot) return snapshots - diff --git a/tests/test_joinquant_plugin.py b/tests/test_joinquant_plugin.py index fc1552b..feca0f2 100644 --- a/tests/test_joinquant_plugin.py +++ b/tests/test_joinquant_plugin.py @@ -269,6 +269,32 @@ def test_export_targets_target_value_mode_from_position_columns(tmp_path): assert target.loc[0, "target_shares"] == 250 +def test_export_targets_can_shift_to_next_execution_session(tmp_path): + positions_path = tmp_path / "positions.pq" + _positions(date="2024-01-09").to_parquet(positions_path, index=False) + calendar_path = tmp_path / "daily.pq" + pd.DataFrame({ + "date": pd.to_datetime(["2024-01-09", "2024-01-10", "2024-01-11"]), + "symbol_id": ["sh600000", "sh600000", "sh600000"], + }).to_parquet(calendar_path, index=False) + + snapshots = export_targets( + positions_path, + portfolio_name="run1", + out_dir=tmp_path / "targets_shifted", + mode="target_shares", + start_date="2024-01-10", + end_date="2024-01-10", + execution_calendar_path=calendar_path, + ) + + assert len(snapshots) == 1 + assert snapshots[0]["date"] == "2024-01-10" + assert (tmp_path / "targets_shifted" / "run1" / "20240110.csv").exists() + target = pd.read_csv(tmp_path / "targets_shifted" / "run1" / "20240110.csv") + assert target.loc[0, "date"] == "2024-01-10" + + def test_ingest_permissive_csv_column_mapping_and_output_schemas(tmp_path): fills_csv = tmp_path / "jq_fills.csv" positions_csv = tmp_path / "jq_positions.csv" @@ -479,4 +505,3 @@ 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 -