| """Factor evaluation utilities compatible with qlib and GP outputs.""" |
|
|
| from __future__ import annotations |
|
|
| import numpy as np |
| import pandas as pd |
| import scipy.stats |
|
|
|
|
| def cross_sectional_rank(series: pd.Series) -> pd.Series: |
| return series.rank(pct=True) |
|
|
|
|
| def daily_rank_ic(factor_df: pd.DataFrame, label_col: str = "label", factor_col: str = "factor") -> pd.Series: |
| """Compute daily Rank IC from long-format panel (date, symbol, factor, label).""" |
| ics = [] |
| dates = [] |
| for dt, group in factor_df.groupby("date"): |
| if group[factor_col].nunique() <= 1 or group[label_col].nunique() <= 1: |
| continue |
| ic, _ = scipy.stats.spearmanr(group[factor_col], group[label_col]) |
| if np.isfinite(ic): |
| ics.append(ic) |
| dates.append(dt) |
| return pd.Series(ics, index=dates, name="rank_ic") |
|
|
|
|
| def ic_summary(ic_series: pd.Series) -> dict: |
| if ic_series.empty: |
| return {"ic_mean": np.nan, "ic_std": np.nan, "icir": np.nan, "pos_ratio": np.nan} |
| return { |
| "ic_mean": float(ic_series.mean()), |
| "ic_std": float(ic_series.std()), |
| "icir": float(ic_series.mean() / (ic_series.std() + 1e-8)), |
| "pos_ratio": float((ic_series > 0).mean()), |
| "n_days": len(ic_series), |
| } |
|
|
|
|
| def quantile_spread( |
| factor_df: pd.DataFrame, |
| label_col: str = "label", |
| factor_col: str = "factor", |
| n_groups: int = 5, |
| ) -> pd.DataFrame: |
| """Long-short spread by factor quantile groups.""" |
| rows = [] |
| for dt, group in factor_df.groupby("date"): |
| if len(group) < n_groups * 2: |
| continue |
| group = group.copy() |
| group["group"] = pd.qcut(group[factor_col].rank(method="first"), n_groups, labels=False) |
| grp_ret = group.groupby("group")[label_col].mean() |
| rows.append({"date": dt, "long_short": grp_ret.iloc[-1] - grp_ret.iloc[0]}) |
| return pd.DataFrame(rows) |
|
|
|
|
| def evaluate_factor_panel(factor_df: pd.DataFrame) -> dict: |
| ic = daily_rank_ic(factor_df) |
| spread = quantile_spread(factor_df) |
| result = ic_summary(ic) |
| if not spread.empty: |
| result["long_short_ann_return"] = float(spread["long_short"].mean() * 252) |
| result["long_short_sharpe"] = float( |
| spread["long_short"].mean() / (spread["long_short"].std() + 1e-8) * np.sqrt(252) |
| ) |
| return result |
|
|