""" Evaluate the EDGAR fundamental factors (research/lab/fundamentals.py) on S&P 500 PIT members, 2011-2026: rank IC, long-only top-N backtests, and blends with the price champion. PYTHONPATH=. uv run python -m research.lab.fund_eval """ import argparse import warnings import numpy as np import pandas as pd from research.lab import fundamentals as fu from research.lab.factor_scan import load, phase_avg from research.lab.report import forward_returns, ic_series, ic_summary from research.lab.rotation import factor_ranks warnings.filterwarnings("ignore") SUBS = [("2011-01-01", "2014-12-31"), ("2015-01-01", "2018-12-31"), ("2019-01-01", "2022-12-31"), ("2023-01-01", "2026-09-24")] FIN_SECTORS = {"Financials", "Real Estate"} def rank(df, p, sector_neutral=False): df = df.where(p.member) if not sector_neutral: return df.rank(axis=1, pct=True) sec = p.extra["sector"] out = pd.DataFrame(np.nan, index=df.index, columns=df.columns) for s in sec.dropna().unique(): cols = [c for c in sec.index[sec == s] if c in df.columns] out[cols] = df[cols].rank(axis=1, pct=True) return out def mean_rank(parts): s = pd.concat(parts, keys=range(len(parts))).groupby(level=1).mean() return s def blend(*dfs): stack = np.stack([d.values for d in dfs]) with np.errstate(invalid="ignore"): m = np.nanmean(stack, axis=0) return pd.DataFrame(m, index=dfs[0].index, columns=dfs[0].columns) def evaluate(p, score, **kw): per = [phase_avg(p, score, a, b, **kw) for a, b in SUBS] full = phase_avg(p, score, SUBS[0][0], SUBS[-1][1], **kw) return full, per def line(name, full, per): return (f"{name:34s} sh {full['sharpe']:.2f} cagr {full['cagr']*100:5.1f}% mdd {full['mdd']*100:5.1f}% " f"turn {full['turnover']:4.1f}x min {min(x['sharpe'] for x in per):.2f} " f"[{' '.join(f'{x['sharpe']:.2f}' for x in per)}]") def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--top-n", type=int, default=20) ap.add_argument("--buffer", type=float, default=1.0) ap.add_argument("--every", type=int, default=10) args = ap.parse_args() kw = dict(top_n=args.top_n, every=args.every, buffer=args.buffer) p, _ = load("us") F = fu.load(p) pr = factor_ranks(p, "us") fwd20 = forward_returns(p, 20) a, b = SUBS[0][0], SUBS[-1][1] names = [k for k in F if k != "mcap"] R, RS = {}, {} print("=== single factors (IC20 and top-N), 2011-2026") for n in names: R[n] = rank(F[n], p) RS[n] = rank(F[n], p, sector_neutral=True) ic = ic_summary(ic_series(F[n].loc[a:b], fwd20.loc[a:b], p.member.loc[a:b], 20), 12) full, per = evaluate(p, R[n], **kw) fs, ps = evaluate(p, RS[n], **kw) print(f"{line(n, full, per)} | IC20 {ic['ic']:+.3f} t {ic['t']:+.1f} | secneu sh {fs['sharpe']:.2f} " f"cagr {fs['cagr']*100:5.1f}%", flush=True) R["size_big"] = rank(F["mcap"], p) full, per = evaluate(p, R["size_big"], **kw) print(line("size_big (largest caps)", full, per)) ew = p.close.notna().astype(float).where(p.member) full, per = evaluate(p, ew, top_n=10000, every=10) print(line("EW universe", full, per)) print("=== composites") Q = blend(RS["gp_a"], RS["cfo_a"], RS["roe"], RS["accruals"], RS["leverage"]) V = blend(RS["ep"], RS["cfp"], RS["fcfp"]) QV = blend(Q, V) champ = blend(pr["overnight_mom"], pr["rec_126"]) mom = blend(pr["mom_12_1"], pr["resid_mom"]) lowrisk = blend(pr["ivol"], pr["low_beta"]) combos = { "quality (gp,cfo,roe,accr,lev)": Q, "value (ep,cfp,fcfp)": V, "quality+value": QV, "champ overnight+rec": champ, "champ + quality": blend(champ, Q), "champ + quality + value": blend(champ, Q, V), "champ + qv(1 slot)": blend(champ, QV), "mom(12_1,resid) + quality": blend(mom, Q), "mom + quality + value": blend(mom, Q, V), "mom + quality + lowrisk": blend(mom, Q, lowrisk), "champ + gp_a": blend(pr["overnight_mom"], pr["rec_126"], RS["gp_a"]), "champ + shareholder_yield": blend(pr["overnight_mom"], pr["rec_126"], RS["shareholder_yield"]), } for name, s in combos.items(): full, per = evaluate(p, s.where(p.member), **kw) print(line(name, full, per), flush=True) if __name__ == "__main__": main()