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 \
--portfolio-name run1 \
--mode target_shares \
--execution-calendar-path data/daily_bars/<universe> \
--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/<universe> \
--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.
+4 -1
View File
@@ -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.
+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("--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}")
+53 -6
View File
@@ -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
+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
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