Align JoinQuant targets to execution dates

This commit is contained in:
Yuxuan Yan
2026-07-04 17:50:55 +08:00
parent f25db279bf
commit c200508f9e
5 changed files with 107 additions and 11 deletions
+8 -1
View File
@@ -39,6 +39,7 @@ uv run python cli.py joinquant export-targets \
--positions-path portfolio/run1.pq \ --positions-path portfolio/run1.pq \
--portfolio-name run1 \ --portfolio-name run1 \
--mode target_shares \ --mode target_shares \
--execution-calendar-path data/daily_bars/<universe> \
--out-dir plugins_output/joinquant/targets --out-dir plugins_output/joinquant/targets
# 3. Generate and copy the wrapper strategy and target files into JoinQuant. # 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 \ --positions-path portfolio/run1.pq \
--portfolio-name run1 \ --portfolio-name run1 \
--mode target_shares \ --mode target_shares \
--execution-calendar-path data/daily_bars/<universe> \
--start-date T \ --start-date T \
--end-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 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. 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 Mode
`target_shares` is the default and preferred correctness mode. The exported `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. - Corporate actions may need special handling and should not be hidden.
- The internal simulator does not currently emit execution price in - The internal simulator does not currently emit execution price in
`FILL_COLUMNS`; price reconciliation uses explicit price columns if supplied. `FILL_COLUMNS`; price reconciliation uses explicit price columns if supplied.
+4 -1
View File
@@ -22,6 +22,7 @@ uv run python cli.py joinquant export-targets \
--positions-path portfolio/run1.pq \ --positions-path portfolio/run1.pq \
--portfolio-name run1 \ --portfolio-name run1 \
--mode target_shares \ --mode target_shares \
--execution-calendar-path data/daily_bars/csi500 \
--start-date 2026-07-01 \ --start-date 2026-07-01 \
--end-date 2026-07-31 \ --end-date 2026-07-31 \
--out-dir plugins_output/joinquant/targets --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` `target_shares` is the default and uses the built integer `position_shares`
from `portfolio build`, matching what the internal simulator executes. 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.
+16 -2
View File
@@ -28,9 +28,23 @@ def joinquant():
) )
@click.option("--start-date", default=None, help="Inclusive YYYY-MM-DD start date") @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("--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("--out-dir", default="plugins_output/joinquant/targets", show_default=True)
@click.option("--force", is_flag=True, help="Overwrite frozen target/snapshot files") @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.""" """Export frozen daily target files for JoinQuant."""
snapshots = export_targets( snapshots = export_targets(
positions_path=positions_path, positions_path=positions_path,
@@ -38,6 +52,7 @@ def export_targets_cmd(positions_path, portfolio_name, mode, start_date, end_dat
mode=mode, mode=mode,
start_date=start_date, start_date=start_date,
end_date=end_date, end_date=end_date,
execution_calendar_path=execution_calendar_path,
out_dir=out_dir, out_dir=out_dir,
force=force, force=force,
) )
@@ -139,4 +154,3 @@ 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}")
+53 -6
View File
@@ -51,16 +51,41 @@ def _filter_dates(
df: pd.DataFrame, df: pd.DataFrame,
start_date: str | None, start_date: str | None,
end_date: str | None, end_date: str | None,
*,
date_column: str = "date",
) -> pd.DataFrame: ) -> pd.DataFrame:
out = df.copy() 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: 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: if end_date:
out = out[out["date"] <= pd.Timestamp(end_date).normalize()] out = out[out[date_column] <= pd.Timestamp(end_date).normalize()]
return out 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( def build_target_frame(
positions: pd.DataFrame, positions: pd.DataFrame,
*, *,
@@ -69,6 +94,7 @@ def build_target_frame(
start_date: str | None = None, start_date: str | None = None,
end_date: str | None = None, end_date: str | None = None,
snapshot_ids: dict[str, str] | None = None, snapshot_ids: dict[str, str] | None = None,
execution_calendar_path: str | Path | None = None,
) -> pd.DataFrame: ) -> pd.DataFrame:
"""Build normalized JoinQuant target rows from portfolio positions. """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'") raise ValueError("mode must be 'target_shares' or 'target_value'")
_check_position_columns(positions) _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: if portfolio_name is not None:
df = df[df["portfolio_name"].astype(str) == portfolio_name] df = df[df["portfolio_name"].astype(str) == portfolio_name]
if df.empty: if df.empty:
@@ -113,6 +147,7 @@ def export_targets(
out_dir: str | Path = "plugins_output/joinquant/targets", out_dir: str | Path = "plugins_output/joinquant/targets",
start_date: str | None = None, start_date: str | None = None,
end_date: str | None = None, end_date: str | None = None,
execution_calendar_path: str | Path | None = None,
force: bool = False, force: bool = False,
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
"""Export one daily CSV/parquet target file plus a snapshot JSON per date. """Export one daily CSV/parquet target file plus a snapshot JSON per date.
@@ -126,6 +161,9 @@ def export_targets(
sibling ``snapshots`` directory. sibling ``snapshots`` directory.
start_date: Optional inclusive start date. start_date: Optional inclusive start date.
end_date: Optional inclusive end 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 force: If false, existing target or snapshot files are treated as
frozen and cause ``FileExistsError``. frozen and cause ``FileExistsError``.
@@ -141,7 +179,15 @@ def export_targets(
snapshots_portfolio_dir.mkdir(parents=True, exist_ok=True) snapshots_portfolio_dir.mkdir(parents=True, exist_ok=True)
positions = pd.read_parquet(positions_path) 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] filtered = filtered[filtered["portfolio_name"].astype(str) == portfolio_name]
if filtered.empty: if filtered.empty:
return [] return []
@@ -156,6 +202,7 @@ def export_targets(
portfolio_name=portfolio_name, portfolio_name=portfolio_name,
mode=mode, mode=mode,
snapshot_ids=snapshot_ids, snapshot_ids=snapshot_ids,
execution_calendar_path=None,
) )
snapshots: list[dict[str, object]] = [] snapshots: list[dict[str, object]] = []
@@ -184,6 +231,7 @@ def export_targets(
"date": date_text, "date": date_text,
"export_mode": mode, "export_mode": mode,
"source_positions_path": str(positions_path), "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(), "created_at": datetime.now(timezone.utc).isoformat(),
"n_symbols": int(len(daily)), "n_symbols": int(len(daily)),
"file_sha256": file_hash, "file_sha256": file_hash,
@@ -198,4 +246,3 @@ def export_targets(
snapshots.append(snapshot) snapshots.append(snapshot)
return snapshots return snapshots
+26 -1
View File
@@ -269,6 +269,32 @@ def test_export_targets_target_value_mode_from_position_columns(tmp_path):
assert target.loc[0, "target_shares"] == 250 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): def test_ingest_permissive_csv_column_mapping_and_output_schemas(tmp_path):
fills_csv = tmp_path / "jq_fills.csv" fills_csv = tmp_path / "jq_fills.csv"
positions_csv = tmp_path / "jq_positions.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 '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