"""Export portfolio positions as frozen JoinQuant target files.""" from __future__ import annotations import hashlib import json import uuid from datetime import datetime, timezone from pathlib import Path from typing import Iterable, Literal import pandas as pd from pipeline.common.schema import POSITION_COLUMNS from plugins.joinquant.schema import JOINQUANT_TARGET_COLUMNS from plugins.joinquant.symbols import to_joinquant_symbol ExportMode = Literal["target_shares", "target_value"] def _date_text(value: object) -> str: return pd.Timestamp(value).strftime("%Y-%m-%d") def _date_file_stem(date_text: str) -> str: return pd.Timestamp(date_text).strftime("%Y%m%d") def _snapshot_root_for(targets_root: Path) -> Path: if targets_root.name == "targets": return targets_root.parent / "snapshots" return targets_root / "snapshots" def _sha256_file(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as fh: for chunk in iter(lambda: fh.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def _check_position_columns(df: pd.DataFrame) -> None: missing = [col for col in POSITION_COLUMNS if col not in df.columns] if missing: raise ValueError(f"Positions input missing required columns: {missing}") def _filter_dates( df: pd.DataFrame, start_date: str | None, end_date: str | None, ) -> pd.DataFrame: out = df.copy() out["date"] = pd.to_datetime(out["date"]).dt.normalize() if start_date: out = out[out["date"] >= pd.Timestamp(start_date).normalize()] if end_date: out = out[out["date"] <= pd.Timestamp(end_date).normalize()] return out def build_target_frame( positions: pd.DataFrame, *, portfolio_name: str | None = None, mode: ExportMode = "target_shares", start_date: str | None = None, end_date: str | None = None, snapshot_ids: dict[str, str] | None = None, ) -> pd.DataFrame: """Build normalized JoinQuant target rows from portfolio positions. ``target_shares`` is populated from ``position_shares`` because the core simulator executes the discretized book, not continuous research shares. """ if mode not in {"target_shares", "target_value"}: raise ValueError("mode must be 'target_shares' or 'target_value'") _check_position_columns(positions) df = _filter_dates(positions, start_date, end_date) if portfolio_name is not None: df = df[df["portfolio_name"].astype(str) == portfolio_name] if df.empty: return pd.DataFrame(columns=JOINQUANT_TARGET_COLUMNS) out = pd.DataFrame({ "date": df["date"].map(_date_text), "portfolio_name": df["portfolio_name"].astype(str), "symbol_id": df["symbol_id"].astype(str), "jq_symbol": df["symbol_id"].map(to_joinquant_symbol), "target_shares": pd.to_numeric(df["position_shares"], errors="coerce").fillna(0).astype("int64"), "target_value": pd.to_numeric(df["target_value"], errors="coerce").fillna(0.0), "target_weight": pd.to_numeric(df["target_weight"], errors="coerce").fillna(0.0), "export_mode": mode, "snapshot_id": "", }) if snapshot_ids: out["snapshot_id"] = out["date"].map(snapshot_ids).fillna("") return out[JOINQUANT_TARGET_COLUMNS].sort_values( ["date", "portfolio_name", "symbol_id"] ).reset_index(drop=True) def export_targets( positions_path: str | Path, *, portfolio_name: str, mode: ExportMode = "target_shares", out_dir: str | Path = "plugins_output/joinquant/targets", start_date: str | None = None, end_date: str | None = None, force: bool = False, ) -> list[dict[str, object]]: """Export one daily CSV/parquet target file plus a snapshot JSON per date. Args: positions_path: Parquet file produced by ``portfolio build``. portfolio_name: Portfolio run to export. mode: ``target_shares`` or ``target_value``. out_dir: Target root. Files are written to ``out_dir/portfolio_name``. If the root is named ``targets``, snapshots are written to the sibling ``snapshots`` directory. start_date: Optional inclusive start date. end_date: Optional inclusive end date. force: If false, existing target or snapshot files are treated as frozen and cause ``FileExistsError``. Returns: Snapshot metadata dictionaries, one per exported date. """ positions_path = Path(positions_path) targets_root = Path(out_dir) snapshot_root = _snapshot_root_for(targets_root) targets_portfolio_dir = targets_root / portfolio_name snapshots_portfolio_dir = snapshot_root / portfolio_name targets_portfolio_dir.mkdir(parents=True, exist_ok=True) snapshots_portfolio_dir.mkdir(parents=True, exist_ok=True) positions = pd.read_parquet(positions_path) filtered = _filter_dates(positions, start_date, end_date) filtered = filtered[filtered["portfolio_name"].astype(str) == portfolio_name] if filtered.empty: return [] date_texts = sorted(filtered["date"].map(_date_text).unique()) snapshot_ids = { date_text: f"jq-{portfolio_name}-{date_text}-{uuid.uuid4().hex[:12]}" for date_text in date_texts } targets = build_target_frame( filtered, portfolio_name=portfolio_name, mode=mode, snapshot_ids=snapshot_ids, ) snapshots: list[dict[str, object]] = [] for date_text, daily in targets.groupby("date", sort=True): stem = _date_file_stem(date_text) csv_path = targets_portfolio_dir / f"{stem}.csv" parquet_path = targets_portfolio_dir / f"{stem}.parquet" snapshot_path = snapshots_portfolio_dir / f"{stem}.json" existing: Iterable[Path] = (csv_path, parquet_path, snapshot_path) if not force: conflicts = [str(path) for path in existing if path.exists()] if conflicts: raise FileExistsError( "Frozen JoinQuant target already exists; use --force to overwrite: " + ", ".join(conflicts) ) daily = daily[JOINQUANT_TARGET_COLUMNS].reset_index(drop=True) daily.to_csv(csv_path, index=False) daily.to_parquet(parquet_path, index=False) file_hash = _sha256_file(csv_path) snapshot = { "snapshot_id": snapshot_ids[date_text], "portfolio_name": portfolio_name, "date": date_text, "export_mode": mode, "source_positions_path": str(positions_path), "created_at": datetime.now(timezone.utc).isoformat(), "n_symbols": int(len(daily)), "file_sha256": file_hash, "notes": "Frozen JoinQuant target file.", "target_csv_path": str(csv_path), "target_parquet_path": str(parquet_path), } snapshot_path.write_text( json.dumps(snapshot, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) snapshots.append(snapshot) return snapshots