Align JoinQuant targets to execution dates
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user