quant_test / factor_engine /factor_evaluation.py
lucky-loster's picture
Upload folder using huggingface_hub
590a501 verified
Raw
History Blame Contribute Delete
2.34 kB
"""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