226 lines
8.5 KiB
Python
226 lines
8.5 KiB
Python
"""Normalize JoinQuant CSV exports into plugin parquet schemas."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
from plugins.joinquant.schema import (
|
|
JOINQUANT_FILL_COLUMNS,
|
|
JOINQUANT_PNL_COLUMNS,
|
|
JOINQUANT_POSITION_COLUMNS,
|
|
)
|
|
from plugins.joinquant.symbols import normalize_symbol_pair, to_joinquant_symbol
|
|
|
|
|
|
def _clean_name(name: object) -> str:
|
|
text = str(name).strip().lower()
|
|
text = re.sub(r"[\s\-.()/]+", "_", text)
|
|
return re.sub(r"[^0-9a-z_]+", "", text).strip("_")
|
|
|
|
|
|
def _clean_columns(df: pd.DataFrame) -> pd.DataFrame:
|
|
out = df.copy()
|
|
out.columns = [_clean_name(col) for col in out.columns]
|
|
return out
|
|
|
|
|
|
def _pick(df: pd.DataFrame, candidates: list[str]) -> str | None:
|
|
for candidate in candidates:
|
|
clean = _clean_name(candidate)
|
|
if clean in df.columns:
|
|
return clean
|
|
return None
|
|
|
|
|
|
def _series_or_default(df: pd.DataFrame, candidates: list[str], default: object) -> pd.Series:
|
|
col = _pick(df, candidates)
|
|
if col is None:
|
|
return pd.Series([default] * len(df), index=df.index)
|
|
return df[col]
|
|
|
|
|
|
def _date_series(df: pd.DataFrame) -> pd.Series:
|
|
values = _series_or_default(
|
|
df,
|
|
["date", "trade_date", "datetime", "time", "created_at"],
|
|
pd.NaT,
|
|
)
|
|
parsed = pd.to_datetime(values, errors="coerce").dt.normalize()
|
|
return parsed.dt.strftime("%Y-%m-%d").fillna("")
|
|
|
|
|
|
def _numeric(values: pd.Series, default: float = 0.0) -> pd.Series:
|
|
return pd.to_numeric(values, errors="coerce").replace([np.inf, -np.inf], np.nan).fillna(default)
|
|
|
|
|
|
def _text(values: pd.Series, default: str = "") -> pd.Series:
|
|
return values.fillna(default).astype(str)
|
|
|
|
|
|
def _portfolio_series(df: pd.DataFrame, portfolio_name: str) -> pd.Series:
|
|
return _text(_series_or_default(df, ["portfolio_name", "portfolio", "strategy"], portfolio_name), portfolio_name)
|
|
|
|
|
|
def _symbol_frame(df: pd.DataFrame) -> pd.DataFrame:
|
|
internal_col = _pick(df, ["symbol_id", "internal_symbol"])
|
|
jq_col = _pick(df, ["jq_symbol", "security", "stock", "symbol", "code", "order_book_id"])
|
|
|
|
symbol_ids: list[str] = []
|
|
jq_symbols: list[str] = []
|
|
for idx in df.index:
|
|
internal = df.at[idx, internal_col] if internal_col else None
|
|
jq_value = df.at[idx, jq_col] if jq_col else None
|
|
value = internal if internal is not None and str(internal).strip() else jq_value
|
|
if value is None or not str(value).strip():
|
|
symbol_ids.append("")
|
|
jq_symbols.append("")
|
|
continue
|
|
symbol_id, jq_symbol = normalize_symbol_pair(value)
|
|
if jq_value is not None and str(jq_value).strip():
|
|
try:
|
|
_, jq_symbol = normalize_symbol_pair(jq_value)
|
|
except ValueError:
|
|
jq_symbol = to_joinquant_symbol(symbol_id)
|
|
symbol_ids.append(symbol_id)
|
|
jq_symbols.append(jq_symbol)
|
|
|
|
return pd.DataFrame({"symbol_id": symbol_ids, "jq_symbol": jq_symbols}, index=df.index)
|
|
|
|
|
|
def _signed_shares(shares: pd.Series, side: pd.Series) -> pd.Series:
|
|
signed = _numeric(shares, 0.0)
|
|
side_text = side.fillna("").astype(str).str.lower()
|
|
sell = side_text.str.contains("sell|short|close|reduce|-", regex=True)
|
|
buy = side_text.str.contains("buy|long|open|add|\\+", regex=True)
|
|
signed = signed.abs()
|
|
signed = signed.mask(sell, -signed)
|
|
signed = signed.mask(~(sell | buy), _numeric(shares, 0.0))
|
|
return signed
|
|
|
|
|
|
def normalize_fills_csv(path: str | Path, portfolio_name: str) -> pd.DataFrame:
|
|
"""Read a JoinQuant fills CSV and return ``JOINQUANT_FILL_COLUMNS``."""
|
|
raw = _clean_columns(pd.read_csv(path))
|
|
symbols = _symbol_frame(raw)
|
|
side = _text(_series_or_default(raw, ["side", "action", "direction"], ""))
|
|
requested = _signed_shares(
|
|
_series_or_default(raw, ["requested_shares", "target_shares", "amount", "order_amount"], 0),
|
|
side,
|
|
)
|
|
filled = _signed_shares(
|
|
_series_or_default(raw, ["filled_shares", "filled", "filled_amount", "deal_amount", "traded_shares"], 0),
|
|
side,
|
|
)
|
|
price = _numeric(_series_or_default(raw, ["fill_price", "price", "avg_cost", "avg_price"], np.nan), np.nan)
|
|
trade_value = _numeric(
|
|
_series_or_default(raw, ["trade_value", "value", "filled_value", "turnover"], np.nan),
|
|
np.nan,
|
|
)
|
|
trade_value = trade_value.fillna((filled * price).abs()).fillna(0.0)
|
|
|
|
out = pd.DataFrame({
|
|
"date": _date_series(raw),
|
|
"portfolio_name": _portfolio_series(raw, portfolio_name),
|
|
"symbol_id": symbols["symbol_id"],
|
|
"jq_symbol": symbols["jq_symbol"],
|
|
"order_id": _text(_series_or_default(raw, ["order_id", "id"], "")),
|
|
"side": side,
|
|
"requested_shares": requested.astype(float),
|
|
"filled_shares": filled.astype(float),
|
|
"fill_price": price.astype(float),
|
|
"trade_value": trade_value.astype(float),
|
|
"trade_cost": _numeric(_series_or_default(raw, ["trade_cost", "cost", "commission", "fee"], 0.0), 0.0),
|
|
"blocked": _numeric(_series_or_default(raw, ["blocked", "is_blocked"], 0), 0).astype("int64"),
|
|
"raw_status": _text(_series_or_default(raw, ["raw_status", "status", "order_status"], "")),
|
|
})
|
|
return out[JOINQUANT_FILL_COLUMNS]
|
|
|
|
|
|
def normalize_positions_csv(path: str | Path, portfolio_name: str) -> pd.DataFrame:
|
|
"""Read a JoinQuant positions CSV and return ``JOINQUANT_POSITION_COLUMNS``."""
|
|
raw = _clean_columns(pd.read_csv(path))
|
|
symbols = _symbol_frame(raw)
|
|
out = pd.DataFrame({
|
|
"date": _date_series(raw),
|
|
"portfolio_name": _portfolio_series(raw, portfolio_name),
|
|
"symbol_id": symbols["symbol_id"],
|
|
"jq_symbol": symbols["jq_symbol"],
|
|
"position_shares": _numeric(
|
|
_series_or_default(raw, ["position_shares", "shares", "amount", "quantity", "total_amount"], 0),
|
|
0,
|
|
),
|
|
"position_value": _numeric(
|
|
_series_or_default(raw, ["position_value", "market_value", "value"], 0.0),
|
|
0.0,
|
|
),
|
|
"cash": _numeric(_series_or_default(raw, ["cash", "available_cash"], np.nan), np.nan),
|
|
"total_value": _numeric(
|
|
_series_or_default(raw, ["total_value", "portfolio_value", "total_asset"], np.nan),
|
|
np.nan,
|
|
),
|
|
})
|
|
return out[JOINQUANT_POSITION_COLUMNS]
|
|
|
|
|
|
def normalize_pnl_csv(path: str | Path, portfolio_name: str) -> pd.DataFrame:
|
|
"""Read a JoinQuant daily PnL CSV and return ``JOINQUANT_PNL_COLUMNS``."""
|
|
raw = _clean_columns(pd.read_csv(path))
|
|
total_value = _numeric(
|
|
_series_or_default(raw, ["total_value", "portfolio_value", "total_asset"], np.nan),
|
|
np.nan,
|
|
)
|
|
pnl = _numeric(_series_or_default(raw, ["pnl", "daily_pnl", "profit", "returns_value"], np.nan), np.nan)
|
|
if pnl.isna().all() and total_value.notna().any():
|
|
pnl = total_value.diff().fillna(0.0)
|
|
|
|
out = pd.DataFrame({
|
|
"date": _date_series(raw),
|
|
"portfolio_name": _portfolio_series(raw, portfolio_name),
|
|
"gross_exposure": _numeric(
|
|
_series_or_default(raw, ["gross_exposure", "gross", "positions_value", "market_value"], np.nan),
|
|
np.nan,
|
|
),
|
|
"net_exposure": _numeric(
|
|
_series_or_default(raw, ["net_exposure", "net"], np.nan),
|
|
np.nan,
|
|
),
|
|
"cash": _numeric(_series_or_default(raw, ["cash", "available_cash"], np.nan), np.nan),
|
|
"total_value": total_value,
|
|
"pnl": pnl.fillna(0.0),
|
|
"cost": _numeric(_series_or_default(raw, ["cost", "trade_cost", "commission", "fee"], 0.0), 0.0),
|
|
"turnover": _numeric(_series_or_default(raw, ["turnover"], 0.0), 0.0),
|
|
})
|
|
return out[JOINQUANT_PNL_COLUMNS]
|
|
|
|
|
|
def ingest_joinquant_outputs(
|
|
*,
|
|
portfolio_name: str,
|
|
fills_csv: str | Path,
|
|
positions_csv: str | Path,
|
|
pnl_csv: str | Path,
|
|
out_dir: str | Path = "plugins_output/joinquant/ingested",
|
|
) -> dict[str, Path]:
|
|
"""Normalize JoinQuant CSV exports and write parquet outputs."""
|
|
out_root = Path(out_dir) / portfolio_name
|
|
out_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
fills = normalize_fills_csv(fills_csv, portfolio_name)
|
|
positions = normalize_positions_csv(positions_csv, portfolio_name)
|
|
pnl = normalize_pnl_csv(pnl_csv, portfolio_name)
|
|
|
|
paths = {
|
|
"fills": out_root / "fills.pq",
|
|
"positions": out_root / "positions.pq",
|
|
"pnl": out_root / "pnl.pq",
|
|
}
|
|
fills.to_parquet(paths["fills"], index=False)
|
|
positions.to_parquet(paths["positions"], index=False)
|
|
pnl.to_parquet(paths["pnl"], index=False)
|
|
return paths
|
|
|