Files
quant-trading/research/lab/report.py
Gahow Wang 0e378efa67 research: point-in-time factor research harness (research/lab)
Event-driven long-only simulator with next-open fills, CN tradability
(halts, limit-up/down, stamp duty), PIT S&P 500 / CSI 300 membership,
rebalance-phase averaging, factor IC scans, maximin stepwise composite
search across 5-year blocks, walk-forward LightGBM/ridge combiners,
overlays, and deflated Sharpe. Fetchers pull 20y US OHLCV from Yahoo and
CSI 300/500 ever-members (membership snapshot from baostock, bars from
Yahoo). Research data lives in data/research/ (ignored).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 19:11:59 +08:00

119 lines
4.4 KiB
Python

"""Performance statistics, factor IC analysis and overfitting controls."""
from __future__ import annotations
import math
import numpy as np
import pandas as pd
from scipy import stats as sps
import metrics
def stats(equity: pd.Series, turnover: pd.Series | None = None) -> dict:
eq = equity.dropna()
r = eq.pct_change().dropna()
out = {
"cagr": metrics.annualized_return(eq),
"vol": metrics.annualized_volatility(r),
"sharpe": metrics.sharpe_ratio(r),
"sortino": metrics.sortino_ratio(r),
"mdd": metrics.max_drawdown(eq),
}
out["calmar"] = out["cagr"] / abs(out["mdd"]) if out["mdd"] < 0 else np.inf
if turnover is not None:
t = turnover.loc[eq.index[0]:eq.index[-1]]
years = max((eq.index[-1] - eq.index[0]).days / 365.25, 1e-9)
out["turnover"] = t.sum() / years # one-way, x per year
return out
def fmt(s: dict) -> str:
base = (f"CAGR {s['cagr']*100:6.1f}% vol {s['vol']*100:5.1f}% Sharpe {s['sharpe']:5.2f} "
f"Sortino {s['sortino']:5.2f} MDD {s['mdd']*100:6.1f}% Calmar {s['calmar']:5.2f}")
if "turnover" in s:
base += f" turn {s['turnover']:5.1f}x"
return base
def yearly(equity: pd.Series) -> pd.Series:
e = equity.dropna()
ye = e.resample("YE").last()
first = e.iloc[0]
return ye.pct_change().fillna(ye.iloc[0] / first - 1)
# ---------------------------------------------------------------------------
# Factor analytics
# ---------------------------------------------------------------------------
def forward_returns(panel, horizon: int = 5) -> pd.DataFrame:
"""Return from the open of t+1 to the open of t+1+horizon — the return a
signal formed at the close of t can actually earn."""
o = panel.open.where(panel.open > 0).ffill()
return o.shift(-(1 + horizon)) / o.shift(-1) - 1
def ic_series(score: pd.DataFrame, fwd: pd.DataFrame, member: pd.DataFrame,
every: int = 5) -> pd.Series:
"""Cross-sectional Spearman rank IC on every ``every``-th date."""
idx = score.index[::every]
out = {}
for d in idx:
s = score.loc[d].where(member.loc[d])
f = fwd.loc[d]
ok = s.notna() & f.notna()
if ok.sum() < 30:
continue
out[d] = sps.spearmanr(s[ok], f[ok]).statistic
return pd.Series(out)
def top_minus_universe(score: pd.DataFrame, fwd: pd.DataFrame, member: pd.DataFrame,
top_n: int = 20, every: int = 5) -> pd.Series:
"""Mean forward return of the top-N names minus the member average."""
idx = score.index[::every]
out = {}
for d in idx:
s = score.loc[d].where(member.loc[d]).dropna()
f = fwd.loc[d]
s = s[f.reindex(s.index).notna()]
if len(s) < top_n * 3:
continue
top = s.nlargest(top_n).index
out[d] = f[top].mean() - f[s.index].mean()
return pd.Series(out)
def ic_summary(ic: pd.Series, periods_per_year: float = 52) -> dict:
ic = ic.dropna()
if len(ic) < 5:
return {"ic": np.nan, "icir": np.nan, "t": np.nan, "hit": np.nan, "n": len(ic)}
return {"ic": ic.mean(), "icir": ic.mean() / ic.std() * math.sqrt(periods_per_year),
"t": ic.mean() / ic.std() * math.sqrt(len(ic)), "hit": (ic > 0).mean(), "n": len(ic)}
# ---------------------------------------------------------------------------
# Overfitting controls (Bailey & Lopez de Prado 2014)
# ---------------------------------------------------------------------------
def deflated_sharpe(returns: pd.Series, n_trials: int, sr_trials_var: float | None = None,
periods: int = 252) -> dict:
"""Probability that the true Sharpe exceeds the best Sharpe expected from
``n_trials`` unskilled strategies. Uses per-period Sharpe internally."""
r = returns.dropna()
T = len(r)
sr = r.mean() / r.std()
g3 = sps.skew(r)
g4 = sps.kurtosis(r, fisher=False)
if sr_trials_var is None:
sr_trials_var = 1.0 / T # variance of SR estimates under H0
emc = 0.5772156649
z1 = sps.norm.ppf(1 - 1.0 / n_trials) if n_trials > 1 else 0.0
z2 = sps.norm.ppf(1 - 1.0 / (n_trials * math.e)) if n_trials > 1 else 0.0
sr0 = math.sqrt(sr_trials_var) * ((1 - emc) * z1 + emc * z2)
denom = math.sqrt(max(1 - g3 * sr + (g4 - 1) / 4 * sr ** 2, 1e-12))
dsr = sps.norm.cdf((sr - sr0) * math.sqrt(T - 1) / denom)
return {"sr_annual": sr * math.sqrt(periods), "sr0_annual": sr0 * math.sqrt(periods), "dsr": dsr}