f/fundlab/search.py
Greg Pomerantz afec7bda73 Alpha search: mine + screen the local DB for idiosyncratic alpha complements
Answer to 'find other alpha-driven funds that complement the portfolio':

- fundlab/search.py: complementarity screen - each fund's daily total
  returns vs the same 21 broad sleeve axes (BIC forward selection,
  |t|>2), full + 5y; alpha (intercept t), R2, rolling 6m alpha
  persistence, correlation vs the current qspnx/pmaix portfolio and the
  spy/agg/tlt benchmark mix. Verdict tiers: CANDIDATE (alpha +
  persistent + portfolio-corr<0.3) / semi-alpha / alpha-but-correlated /
  sleeve mix / weak.
- fundlab/dbmine.py: the actual search universe - the local stocks DB
  already holds ~100 US open-end alternatives (AQR, PIMCO, JPM,
  Principal, Calamos, GMO, Franklin K2, ...). Name-pattern miner with
  share-class family dedupe (keeps the longest-history class).
- fundlab/tickers.py + searchlist.py: external longlist resolution
  (chart-API name gate + EDGAR 497 cover tickers). Finding: the famous
  multi-strategy/macro names (Millennium, Balyasny, Two Sigma, Winton,
  Marshall Wace, Brevan Howard, AQR Event-Driven) are private/offshore
  or terminated - not US open-end accessible. Fidelity Multi-Asset
  Income (FMSDX) resolved and screens as weak alpha.
- app Fund Lab: 'Alpha search - all screened funds, ranked' table
  (80 funds: 13 shortlist + 59 mined + 1 external).
- results (ranked candidates, 5y alpha / t / portfolio-corr):
  wmnix Westwood Alt Income +3.8% t6.5 c0.09 | pyaix Payden ARB +3.0%
  t4.8 c0.13 | srdax Stone Ridge Div Alts +7.7% t4.2 c0.10 | padqx PGIM
  ARB +2.3% t2.4 c0.27 | bxmdx Blackstone Alt MS +3.5% t2.4 c0.30 |
  aqmix AQR Mngd Futures +8.0% t2.2 c0.21 | cmnix/gioix semi-alpha.
  Key insight: AQR MN / L/S-equity / Vanguard MN show strong alpha but
  corr 0.35-0.76 with the portfolio - it is already 50% market-neutral
  (qspnx), so more MN is not diversifying.
- tests: 59/59 fundlab (resolver gates, query ladder, family dedupe,
  ticker regex), 32/32 app, 14/14 data
2026-08-26 13:34:34 -04:00

327 lines
13 KiB
Python

"""Alpha search: screen the curated longlist (+ the 13-fund shortlist)
for idiosyncratic alpha-driven funds that complement the current
portfolio (qspnx/pmaix 50/50; benchmarks spy/agg/tlt).
Method (same engine as fundlab/decompose.py, broad candidate set):
1. resolve tickers from fund NAMES via Yahoo search (precision-gated;
unresolvable candidates are dropped, never guessed),
2. download missing returns with the goget tool (if the user has
authorized it and the binary is present),
3. regress each fund's daily total returns on the same 21 broad sleeve
axes (BIC forward selection, |t|>2), full history + last 5y,
4. compute correlation vs the current portfolio and vs the benchmark
mix, and a rolling 6m alpha persistence check,
5. rank: CANDIDATE = low R^2 (not a sleeve mix) + significant alpha
(5y t>=2, full t>=1.5) + persistent (>=50% of rolling windows
positive) + low portfolio correlation (<0.3).
Output: fundlab/search_results.json (read by the app).
"""
from __future__ import annotations
import difflib
import json
import re
import subprocess
import urllib.parse
import urllib.request
from functools import lru_cache
from pathlib import Path
import numpy as np
import pandas as pd
from fundlab import decompose
from fundlab.searchlist import (BENCHMARKS, BROAD_SLEEVES, LONGLIST,
PORTFOLIO, SHORTLIST)
DATA = Path.home() / "prog/fin/stocks"
GOGET = Path.home() / "go/bin/goget"
RESULTS = Path(__file__).parent / "search_results.json"
RESOLVED = Path(__file__).parent / "resolved_tickers.json"
def _load_resolved() -> dict:
try:
return json.loads(RESOLVED.read_text())
except Exception:
return {}
def _save_resolved(d: dict) -> None:
RESOLVED.write_text(json.dumps(d, indent=1))
UA = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64)"}
# ------------------------------------------------------------------ resolve
def _norm_name(s: str) -> str:
s = s.lower()
s = re.sub(r"[^a-z0-9]+", " ", s)
# strip share-class suffixes
for suf in ("advisor", "institutional", "retail", "plus", "a", "i", "c",
"b", "x", "z"):
s = re.sub(rf"\b{re.escape(suf)}\b\s*$", " ", s)
s = re.sub(r"\bfund\b\s*$", "", s)
return re.sub(r"\s+", " ", s).strip()
def chart_meta(sym: str) -> dict | None:
"""Yahoo chart API (no crumb needed): returns meta for a symbol."""
url = ("https://query1.finance.yahoo.com/v8/finance/chart/"
f"{sym.upper()}?range=1y&interval=1d")
req = urllib.request.Request(url, headers=UA)
try:
d = json.load(urllib.request.urlopen(req, timeout=30))
except Exception:
return None
res = (d.get("chart") or {}).get("result")
return res[0].get("meta") if res else None
_STOP = {"the", "and", "for", "of", "to", "a", "i", "c", "fund", "funds",
"series", "class", "in", "on"}
def _tokens(s: str) -> set[str]:
return {t for t in _norm_name(s).split() if t not in _STOP}
def _name_match(target: str, cand: str) -> float:
"""Fraction of the target's significant tokens present in cand."""
tt, ct = _tokens(target), _tokens(cand)
return len(tt & ct) / len(tt) if tt else 0.0
def resolve(name: str, guess: str | None = None) -> dict | None:
"""Resolve a fund name to a ticker, verifying against Yahoo chart
metadata (instrumentType + longName). A guess is accepted only if the
chart says it's a mutual fund whose name shares >= 2/3 of the
target's significant tokens AND has sim >= 0.5; otherwise the
candidate is dropped - never guessed.
Returns {symbol, name, sim, guess} or {symbol: None, error}.
"""
if not guess:
return {"symbol": None, "name": name,
"error": "no ticker guess (add one or resolve manually)"}
meta = chart_meta(guess)
if not meta:
return {"symbol": None, "name": name,
"error": f"no chart data for guess {guess}"}
itype = (meta.get("instrumentType") or "").upper()
if itype not in ("MUTUALFUND", "FUND"):
return {"symbol": None, "name": name,
"error": f"{guess} is not a mutual fund ({itype})"}
cand = meta.get("longName") or meta.get("shortName") or ""
sim = difflib.SequenceMatcher(None, _norm_name(name),
_norm_name(cand)).ratio()
tok = _name_match(name, cand)
if tok < 2 / 3 or sim < 0.5:
return {"symbol": None, "name": name,
"error": f"{guess} is {cand!r} (tok={tok:.2f} sim={sim:.2f})"}
return {"symbol": guess.lower(), "name": cand, "sim": round(sim, 3),
"guess": guess}
# ------------------------------------------------------------------ data
@lru_cache(maxsize=None)
def has_data(sym: str) -> bool:
return (DATA / f"{sym}-history.csv").exists()
def ensure_data(symbols: list[str]) -> list[str]:
"""goget-download the missing symbols (into the data dir). Returns the
symbols still without data."""
missing = [s for s in symbols if not has_data(s)]
if missing and GOGET.exists():
try:
subprocess.run([str(GOGET), *missing], cwd=DATA,
capture_output=True, timeout=900)
except Exception as e:
print(f"goget failed: {e}", flush=True)
has_data.cache_clear()
return [s for s in symbols if not has_data(s)]
# ------------------------------------------------------------------ screen
def _mix(returns: pd.DataFrame, weights: dict[str, float]) -> pd.Series:
out = None
for s, w in weights.items():
if s in returns.columns:
v = returns[s] * w
out = v if out is None else out.add(v, fill_value=0.0)
return out
def _max_drawdown(r: pd.Series) -> float:
eq = (1 + r.fillna(0)).cumprod()
return float((eq / eq.cummax() - 1).min())
def screen_fund(sym: str, name: str, bucket: str,
in_portfolio: bool = False) -> dict:
full = decompose.decompose(sym, candidates={sym: BROAD_SLEEVES})
if "r2" not in full:
return {"sym": sym, "name": name, "bucket": bucket,
"error": full.get("error", "no data")}
rec = decompose.decompose(sym, start=decompose.RECENT_WINDOW,
candidates={sym: BROAD_SLEEVES})
r = decompose.returns_panel(
[sym] + list(PORTFOLIO) + list(BENCHMARKS) + BROAD_SLEEVES)
corr_port = corr_bench = np.nan
if sym in r.columns:
syms = list(dict.fromkeys(
[sym] + list(PORTFOLIO) + list(BENCHMARKS))) # dedup: the
c = r[[s for s in syms if s in r.columns]].dropna() # screened fund may BE a portfolio component
if len(c) > 252:
pf = _mix(c, PORTFOLIO)
bm = _mix(c, BENCHMARKS)
if pf is not None:
corr_port = float(np.corrcoef(c[sym], pf)[0, 1])
if bm is not None:
corr_bench = float(np.corrcoef(c[sym], bm)[0, 1])
# rolling 6m alpha persistence: fraction of 126d windows where the
# fund beat its fitted sleeve mix. Use the 5y model when the full-
# sample model is empty (vintage funds), else the full model.
frac_pos = np.nan
_model = rec if ("components" in rec and rec["components"]) else full
if _model.get("components"):
y = r[sym].to_numpy()
X = np.column_stack(
[np.ones(len(y)), *[r[c["sym"]].to_numpy()
for c in _model["components"]]])
ok = ~(np.isnan(y) | np.isnan(X).any(axis=1))
y, X = y[ok], X[ok]
if len(y) >= 252:
beta, *_ = np.linalg.lstsq(X, y, rcond=None)
ex = y - X @ beta
w = 126
if len(ex) > w * 3:
fr = [ex[i - w:i].mean() > 0
for i in range(w, len(ex), 21)]
frac_pos = float(np.mean(fr))
f5 = rec.get("r2", np.nan)
t5 = rec.get("alpha_t", np.nan)
tf = full.get("alpha_t", np.nan)
# kind: alpha (mostly idiosyncratic), semi_alpha (mostly explained by
# net exposure but with significant residual alpha - typical of
# market-neutral funds), sleeve (a static mix), weak (neither)
if f5 < 0.6 and t5 >= 2.0 and tf >= 1.25:
kind = "alpha"
elif f5 < 0.85 and t5 > 0 and tf >= 3.0:
kind = "semi_alpha" # positive 5y alpha required for candidacy
elif f5 >= 0.85:
kind = "sleeve"
else:
kind = "weak"
persistent = frac_pos is not None and frac_pos >= 0.45
complementary = corr_port == corr_port and corr_port < 0.3
if "r2" not in rec:
verdict = "no 5y window"
elif kind in ("alpha", "semi_alpha") and persistent and complementary:
verdict = ("CANDIDATE - idiosyncratic alpha, complements portfolio"
if kind == "alpha" else
"CANDIDATE (semi-alpha: mostly explained by net exposure)")
elif kind in ("alpha", "semi_alpha") and not complementary:
verdict = "alpha, but correlated with current portfolio"
elif kind in ("alpha", "semi_alpha") and not persistent:
verdict = "alpha in 5y window, but not persistent (lucky stretch?)"
elif kind == "sleeve":
verdict = "sleeve mix (R² high) - not alpha-driven"
else:
verdict = "weak/unstable alpha"
return {
"sym": sym, "name": name, "bucket": bucket,
"in_portfolio": in_portfolio,
"alpha_ann_5y": rec.get("alpha_ann"),
"alpha_t_5y": t5, "alpha_t_full": tf,
"r2_5y": f5, "r2_full": full.get("r2"),
"corr_portfolio": corr_port, "corr_benchmark": corr_bench,
"alpha_pos_frac": frac_pos,
"fund_max_dd": _max_drawdown(r[sym]) if sym in r.columns else None,
"first": full.get("start"),
"verdict": verdict,
}
def _shortlist_names() -> dict[str, str]:
try:
f = json.loads((Path(__file__).parent.parent / "funds.json").read_text())
return {k: v["name"] for k, v in f.items()}
except Exception:
return {}
def run(shortlist_only: bool = False) -> dict:
rows: dict[str, dict] = {}
names = _shortlist_names()
# 1) the shortlist (known tickers, names from funds.json)
for sym in SHORTLIST:
in_pf = sym in PORTFOLIO
row = screen_fund(sym, names.get(sym, sym), "shortlist",
in_portfolio=in_pf)
rows[sym] = row
print(f"{sym:7} {row.get('verdict', row.get('error'))[:70]}",
flush=True)
if shortlist_only:
RESULTS.write_text(json.dumps(rows, indent=1, default=str))
return rows
# 2) the curated longlist: chart-verified guess first, then EDGAR
# prospectus covers, else drop (never guess)
from fundlab import tickers as _tickers
resolved_cache = _load_resolved()
for name, bucket, guess in LONGLIST:
cached = resolved_cache.get(name)
if cached is not None:
res = cached
if res.get("symbol"):
print(f"CACHED {name[:40]:40} -> {res['symbol'].upper()}",
flush=True)
else:
res = resolve(name, guess)
if not res.get("symbol"): # guess missing/rejected -> EDGAR
res = _tickers.resolve_via_edgar(name)
if res:
print(f"EDGAR {name[:40]:40} -> {res['symbol'].upper()} "
f"(tok={res['sim']}) {res['name'][:44]}", flush=True)
resolved_cache[name] = res or {"symbol": None, "name": name,
"error": "unresolved"}
_save_resolved(resolved_cache)
if res is None or not res.get("symbol"):
print(f"DROP {name[:45]:45} {res.get('error', '') if res else ''}")
rows[f"__drop__{name[:30]}"] = {
"sym": None, "name": name, "bucket": bucket,
"error": (res or {}).get("error", "unresolved"),
"guess": guess}
continue
sym = res["symbol"]
if sym in rows:
print(f"SKIP {name[:45]:45} already in shortlist as {sym}")
continue
print(f"RESOLVED {name[:40]:40} -> {sym.upper()} "
f"(sim={res['sim']})", flush=True)
rows[sym] = {"_resolve": res} # placeholder for the download pass
# 3) download whatever is missing, then screen
to_screen = {s: d for s, d in rows.items() if "_resolve" in d}
if to_screen:
still_missing = ensure_data(list(to_screen))
for s in still_missing:
rows[s]["error"] = "no return data (download failed)"
to_screen = {s: d for s, d in to_screen.items() if "error" not in d}
for sym, d in to_screen.items():
res = d["_resolve"]
row = screen_fund(sym, res["name"], bucket)
row["resolved_from"] = res
rows[sym] = row
print(f"{sym:7} {row.get('verdict', row.get('error'))[:70]}",
flush=True)
RESULTS.write_text(json.dumps(rows, indent=1, default=str))
n_cand = sum(1 for r in rows.values()
if str(r.get("verdict", "")).startswith("CANDIDATE"))
print(f"\nwrote {RESULTS} - {n_cand} candidate(s)")
return rows
if __name__ == "__main__":
import sys
run(shortlist_only="--shortlist" in sys.argv)