Files
quant-trading/research/lab/fund_eval.py
Gahow Wang 2c19e5efe5 feat: US 3-sleeve portfolio and lowrisk_top20; US portfolio research harness
Register two live strategies from the 2026-09-29 US research:
- lowrisk_top20: idiosyncratic vol + low beta, top 20, monthly, buffer 2
  (new `low_risk` signal in strategies/robust_momentum.py)
- us_three_sleeve: 1/3 each of overnight_recovery_top10, lowrisk_top20 and
  trend_rider_v7_vt24 in one account (strategies/sleeve_portfolio.py, a
  mixed stock+ETF strategy). Sleeve shares drift with their returns and reset
  at a month start once any is >25% off its share.

Research (research/lab, S&P 500 PIT, next-open fills):
- rotation.py / persistence.py: walk-forward factor weighting (factor
  momentum, rolling IC, market state) and last-year's-winner selection do not
  beat a fixed composite
- us_sweep.py: holdings count, z/vol weights, dynamic N, tranching and market
  overlays (Faber SMA, de-risk vol target, statistical jump model,
  momentum state); engine.py gains zvol weighting and dyn_n
- fetch_us_fundamentals.py / fundamentals.py / fund_eval.py: SEC EDGAR
  point-in-time fundamentals (first-filed values) and factor tests
- trend_rider_long.py / us_final.py: 2010-2026 TrendRider curves and the
  3-sleeve check (2011-2026 Sharpe 1.21, CAGR 21.9%, MaxDD -23%)

Web-UI engine, 2017-09..2026-09, $2/order: us_three_sleeve CAGR 28.9%,
Sharpe 1.49, MaxDD -24.8%.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:03:50 +08:00

121 lines
4.3 KiB
Python

"""
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()