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>
121 lines
4.3 KiB
Python
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()
|