diff --git a/app.py b/app.py index 2b3f749..b567dbc 100644 --- a/app.py +++ b/app.py @@ -615,6 +615,66 @@ with tab_fundlab: }) st.dataframe(pd.DataFrame(_sum_rows), width="stretch") + # --- alpha search: all screened funds (shortlist + longlist + harvest) + with st.expander("Alpha search — all screened funds, ranked"): + _all_rows: list[dict] = [] + for _src in ("search_results.json", "search_mined.json"): + try: + _j = json.loads((_dc.RESULTS.parent / _src).read_text()) + except Exception: + continue + for _k, _v in _j.items(): + if isinstance(_v, dict) and _v.get("sym"): + _v.setdefault("source", _src) + _all_rows.append(_v) + if _all_rows: + def _tier(r) -> int: + v = str(r.get("verdict", "")) + if v.startswith("CANDIDATE"): + return 0 + if "correlated" in v: + return 1 + if "not persistent" in v: + return 2 + if "sleeve" in v: + return 3 + if "weak" in v: + return 4 + return 5 + _all_rows.sort(key=lambda r: (_tier(r), + -(r.get("alpha_t_5y") + if isinstance(r.get("alpha_t_5y"), + (int, float)) else -9))) + _tbl = [] + for _v in _all_rows: + _a5 = _v.get("alpha_ann_5y") + _tbl.append({ + "fund": f"{_v['sym'].upper()} — {_v.get('name', '')[:50]}", + "bucket": _v.get("bucket", ""), + "R² 5y": (f"{_v['r2_5y']:.2f}" + if isinstance(_v.get("r2_5y"), (int, float)) + else "—"), + "alpha 5y": (f"{_a5*100:+.1f}% (t={_v['alpha_t_5y']:+.1f})" + if isinstance(_a5, (int, float)) else "—"), + "corr port": (f"{_v['corr_portfolio']:.2f}" + if isinstance(_v.get("corr_portfolio"), + (int, float)) else "—"), + "6m + %": (f"{_v['alpha_pos_frac']:.0%}" + if isinstance(_v.get("alpha_pos_frac"), + (int, float)) else "—"), + "verdict": _v.get("verdict", _v.get("error", "")), + }) + st.dataframe(pd.DataFrame(_tbl), width="stretch") + st.caption( + "Screen: daily total returns vs 21 broad sleeve axes (same " + "set for every fund); 'alpha 5y' = OLS intercept over the " + "last 5 years (t-stat); 'corr port' = correlation with your " + "current qspnx/pmaix portfolio; '6m + %' = share of rolling " + "6-month windows where the fund beat its fitted sleeve mix. " + "CANDIDATE = R²5y < 0.6 (or < 0.85 with strong residual " + "alpha), t5y ≥ 2, t-full ≥ 1.25, ≥ 45% positive windows, " + "portfolio correlation < 0.3.") + _f = _FUNDS.get(_fl_pick, {}) _man = _MAN.get(_fl_pick, {}) st.subheader(f"{_f.get('name', _fl_pick)} · {_fl_pick.upper()}") diff --git a/fundlab/dbmine.py b/fundlab/dbmine.py new file mode 100644 index 0000000..b5af314 --- /dev/null +++ b/fundlab/dbmine.py @@ -0,0 +1,128 @@ +"""Mine the local stocks DB for alpha-leaning open-end funds and screen +them with the same engine (fundlab.search.screen_fund). + +The DB already holds ~8k symbols incl. a rich set of US open-end +alternatives (AQR, PIMCO, JPM, Principal, Calamos, GMO, Franklin K2, ...). +The miner: + 1. scans every .json for alpha-leaning fund names + (market-neutral / long-short / multi-strategy / macro / managed + futures / absolute return / multi-asset TA / risk premia / + alternatives), + 2. dedupes share classes (same fund, A/I/N/R6/Z/Instl...) keeping the + class with the longest history, + 3. screens each survivor (requires >= 5y of data). +""" +from __future__ import annotations + +import glob +import json +import re +import sys +from pathlib import Path + +DATA = Path.home() / "prog/fin/stocks" +OUT = Path(__file__).parent / "search_mined.json" + +PATTERN = re.compile( + r"(market neutral|long.?/?.?short|multi.?strateg|managed futures|" + r"macro opportunit|global macro|alternative (strateg|strats|risk|asset|" + r"income|core|allocation)|alternatives fund|absolute return|" + r"risk premia|multi.?asset (income|absolute|ult|balanced)|" + r"tactical allocation|trends fund|market trend|opportunistic (equity|long)|" + r"multi.?manager|diversified (alternatives|income))", re.I) + +# skip anything that is really an ETF wrapper or index +SKIP = re.compile( + r"(exchange.?traded|etf trust|index fund|s&p 500|nasdaq 100|" + r"real estate trust|reit\b|grayscale|liquidation|royalty|bitcoin|" + r"litecoin|ethereum|multimanager (20|lifestyle)|core plus)", re.I) + +# trailing share-class tokens for family dedupe +CLASS_TOK = re.compile( + r"\b(a|i|n|c|b|z|x|r6|r5|r4|svc|inst|instl|institutional|advisor|" + r"retail|investor|plus|class [a-z]?\d?|series [a-z]?)\b\.?$", re.I) + +KNOWN = set(json.loads( + (Path(__file__).parent.parent / "funds.json").read_text())) + + +def _known_families() -> set[str]: + """Family keys of the shortlist, so other share classes of the SAME + fund (qspix vs the shortlist's qspnx) are not mined as new funds.""" + out = set() + for name in json.loads( + (Path(__file__).parent.parent / "funds.json").read_text()): + out.add(family_key(name)) + return out + + +def family_key(name: str) -> str: + t = re.sub(r"[^a-z0-9 ]+", " ", name.lower()) + prev = None + while prev != t: + prev = t + t = CLASS_TOK.sub(" ", t) + t = re.sub(r"\s+", " ", t).strip() + # also strip leading trust/series wrappers ("trust for professional...") + t = re.sub(r"^(trust for |investment managers series [a-z0-9 ]*-?\s*)", + "", t) + return t + + +KNOWN_FAMS = _known_families() + + +def mine() -> list[dict]: + fams: dict[str, dict] = {} + for f in glob.glob(str(DATA / "*.json")): + sym = Path(f).name[:-5].lower() + # KNOWN = the shortlist in funds.json; eigmx is the I class of the + # shortlist's eagmx (EV Global Macro) - don't double-count it + if sym in KNOWN or sym == "eigmx": + continue + try: + d = json.load(open(f)) + res = d["chart"]["result"][0] + meta = res["meta"] + except Exception: + continue + itype = (meta.get("instrumentType") or "").upper() + if itype == "ETF": + continue + name = meta.get("longName") or meta.get("shortName") or "" + if not PATTERN.search(name) or SKIP.search(name): + continue + days = len(res.get("timestamp", []) or []) + if days < 1250: # need >= 5y + continue + key = family_key(name) + if len(key) < 12 or key in KNOWN_FAMS: + continue + if key not in fams or days > fams[key]["days"]: + fams[key] = {"sym": sym, "name": name, "days": days} + out = sorted(fams.values(), key=lambda x: x["name"]) + return out + + +def run(screen: bool = True) -> dict: + fams = mine() + print(f"mined {len(fams)} distinct fund families", flush=True) + results = {} + if screen: + from fundlab import search + for c in fams: + row = search.screen_fund(c["sym"], c["name"], "mined") + results[c["sym"]] = row + print(f"{c['sym']:7} {c['name'][:44]:44} " + f"{row.get('verdict', row.get('error'))[:44]}", + flush=True) + else: + for c in fams: + print(f" {c['sym']:7} {c['days']:5}d {c['name'][:60]}") + OUT.write_text(json.dumps(results or fams, indent=1, default=str)) + print(f"wrote {OUT}") + return results + + +if __name__ == "__main__": + run(screen="--no-screen" not in sys.argv) diff --git a/fundlab/decompose.py b/fundlab/decompose.py index e6cc314..41e41fd 100644 --- a/fundlab/decompose.py +++ b/fundlab/decompose.py @@ -55,7 +55,11 @@ def returns_panel(symbols: list[str], start: str | None = None) -> pd.DataFrame: candidate histories have different vintages (and some end early, e.g. finux stops in 2017), so a global inner join can be empty.""" cols = [] - for s in symbols: + seen = set() + for s in symbols: # de-dup: a symbol may appear + if s in seen: # in both the benchmark mix and + continue # the sleeve set + seen.add(s) a = adj_close(s) if a is not None: cols.append(a) diff --git a/fundlab/harvest.py b/fundlab/harvest.py new file mode 100644 index 0000000..45c5775 --- /dev/null +++ b/fundlab/harvest.py @@ -0,0 +1,100 @@ +"""Harvest candidate multi-asset / alternatives fund tickers from the SEC +company_tickers file, verify each resolves on Yahoo with real history, +download the survivors with goget, and screen them with the same engine. + +The SEC file lists exchange symbols; Yahoo's chart API resolves many of +them to the underlying mutual fund (instrumentType + longName) AND serves +full NAV history for them (verified: PDI -> 2012..). CEFs (", Inc.", +"Trust, Inc.") are excluded - the screen targets open-end share classes. +""" +from __future__ import annotations + +import json +import re +import subprocess +import urllib.request +from pathlib import Path + +from fundlab import decompose, search +from fundlab.searchlist import BROAD_SLEEVES + +SEC_TICKERS = Path("/tmp/company_tickers.json") +DATA = Path.home() / "prog/fin/stocks" +GOGET = Path.home() / "go/bin/goget" +OUT = Path(__file__).parent / "search_harvest.json" + +FUND_KW = re.compile(r"\b(fund|funds|trust)\b", re.I) +CEF_KW = re.compile(r",\s*Inc\.|Trust,\s*Inc\.|, Inc\b|TRUST\s+[IVX]+,?\s*$", + re.I) + + +def harvest_candidates() -> list[dict]: + d = json.loads(SEC_TICKERS.read_text()) + out = [] + for v in d.values(): + t, title = v.get("ticker", ""), v.get("title", "") + if not t or len(t) < 4 or not FUND_KW.search(title): + continue + if CEF_KW.search(title): + continue + out.append({"ticker": t, "title": title}) + return out + + +def history_length(ticker: str) -> int: + """Days of history Yahoo serves for a ticker; 0 if none, or if it's an + exchange-listed instrument (ETF/stock - we want OTC fund classes).""" + url = (f"https://query1.finance.yahoo.com/v8/finance/chart/" + f"{ticker}?range=20y&interval=1d") + req = urllib.request.Request(url, headers=search.UA) + try: + d = json.load(urllib.request.urlopen(req, timeout=30)) + res = (d.get("chart") or {}).get("result") + if not res: + return 0 + meta = res[0].get("meta") or {} + exch = (meta.get("fullExchangeName") or "").upper() + if any(e in exch for e in ("NASDAQ", "NYSE", "ARCA")): + return 0 + return len(res[0].get("timestamp", [])) + except Exception: + return 0 + + +def run(min_days: int = 1250) -> dict: + cands = harvest_candidates() + print(f"harvested {len(cands)} fund-like SEC tickers", flush=True) + verified = [] + for c in cands: + n = history_length(c["ticker"]) + if n >= min_days: + verified.append({**c, "days": n}) + print(f" keep {c['ticker']:6} {n:5}d {c['title'][:46]}", + flush=True) + print(f"{len(verified)} with >= {min_days}d history", flush=True) + + missing = [c["ticker"].lower() for c in verified + if not (DATA / f"{c['ticker'].lower()}-history.csv").exists()] + if missing and GOGET.exists(): + print(f"goget downloading {len(missing)} symbols...", flush=True) + subprocess.run([str(GOGET), *missing], cwd=DATA, + capture_output=True, timeout=1800) + + results = {} + for c in verified: + sym = c["ticker"].lower() + if not (DATA / f"{sym}-history.csv").exists(): + results[sym] = {"sym": sym, "title": c["title"], + "error": "no history after download"} + continue + row = search.screen_fund(sym, c["title"], "harvest") + results[sym] = row + print(f"{sym:7} {row.get('verdict', row.get('error'))[:60]}", + flush=True) + OUT.write_text(json.dumps(results, indent=1, default=str)) + print(f"wrote {OUT}") + return results + + +if __name__ == "__main__": + run() diff --git a/fundlab/resolved_tickers.json b/fundlab/resolved_tickers.json new file mode 100644 index 0000000..97cc74d --- /dev/null +++ b/fundlab/resolved_tickers.json @@ -0,0 +1,128 @@ +{ + "Fidelity Multi-Asset Income Fund": { + "symbol": "fmsdx", + "name": "Fidelity Multi-Asset Income", + "sim": 1.0, + "guess": "FMSDX" + }, + "AQR Diversified Event-Driven Fund": { + "symbol": null, + "name": "AQR Diversified Event-Driven Fund", + "error": "unresolved" + }, + "Bridgewater Pure Alpha II Fund": { + "symbol": null, + "name": "Bridgewater Pure Alpha II Fund", + "error": "unresolved" + }, + "Winton Global Quantitative Fund": { + "symbol": null, + "name": "Winton Global Quantitative Fund", + "error": "unresolved" + }, + "Two Sigma Dynamic Strategy Fund": { + "symbol": null, + "name": "Two Sigma Dynamic Strategy Fund", + "error": "unresolved" + }, + "Brevan Howard Dymon Asia Fund": { + "symbol": null, + "name": "Brevan Howard Dymon Asia Fund", + "error": "unresolved" + }, + "Marshall Wace Global Opportunities Fund": { + "symbol": null, + "name": "Marshall Wace Global Opportunities Fund", + "error": "unresolved" + }, + "ExodusPoint Diversified Fund": { + "symbol": null, + "name": "ExodusPoint Diversified Fund", + "error": "unresolved" + }, + "Verition Dynamic Risk Fund": { + "symbol": null, + "name": "Verition Dynamic Risk Fund", + "error": "unresolved" + }, + "Millennium Focus Fund": { + "symbol": null, + "name": "Millennium Focus Fund", + "error": "unresolved" + }, + "Balyasny Absolute Return Multi-Strategy Fund": { + "symbol": null, + "name": "Balyasny Absolute Return Multi-Strategy Fund", + "error": "unresolved" + }, + "Schonfeld Strategic Opportunities Fund": { + "symbol": null, + "name": "Schonfeld Strategic Opportunities Fund", + "error": "unresolved" + }, + "Bridgewater All Weather Fund": { + "symbol": null, + "name": "Bridgewater All Weather Fund", + "error": "unresolved" + }, + "Oak Hill Tactical Allocation Fund": { + "symbol": null, + "name": "Oak Hill Tactical Allocation Fund", + "error": "unresolved" + }, + "Janus Henderson Global Dynamic Dividend Fund": { + "symbol": null, + "name": "Janus Henderson Global Dynamic Dividend Fund", + "error": "unresolved" + }, + "Wellington Dynamic Global Diversified Fund": { + "symbol": null, + "name": "Wellington Dynamic Global Diversified Fund", + "error": "unresolved" + }, + "Lord Abbett Global Opportunities Fund": { + "symbol": null, + "name": "Lord Abbett Global Opportunities Fund", + "error": "unresolved" + }, + "BlackRock Multi-Asset Income Fund": { + "symbol": null, + "name": "BlackRock Multi-Asset Income Fund", + "error": "unresolved" + }, + "PIMCO Income Strategy Fund": { + "symbol": null, + "name": "PIMCO Income Strategy Fund", + "error": "unresolved" + }, + "JPMorgan Diversified Return Fund": { + "symbol": null, + "name": "JPMorgan Diversified Return Fund", + "error": "unresolved" + }, + "Invesco Diversified Equity and Income Fund": { + "symbol": null, + "name": "Invesco Diversified Equity and Income Fund", + "error": "unresolved" + }, + "T. Rowe Price Global Allocation Fund": { + "symbol": null, + "name": "T. Rowe Price Global Allocation Fund", + "error": "unresolved" + }, + "Morgan Stanley Global Multi Asset Fund": { + "symbol": null, + "name": "Morgan Stanley Global Multi Asset Fund", + "error": "unresolved" + }, + "Fidelity Diversified Multi-Asset Fund": { + "symbol": null, + "name": "Fidelity Diversified Multi-Asset Fund", + "error": "unresolved" + }, + "PIMCO Dynamic Income Fund": { + "symbol": null, + "name": "PIMCO Dynamic Income Fund", + "error": "unresolved" + } +} \ No newline at end of file diff --git a/fundlab/search.py b/fundlab/search.py new file mode 100644 index 0000000..dce2300 --- /dev/null +++ b/fundlab/search.py @@ -0,0 +1,326 @@ +"""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) diff --git a/fundlab/search_harvest.json b/fundlab/search_harvest.json new file mode 100644 index 0000000..2a8e717 --- /dev/null +++ b/fundlab/search_harvest.json @@ -0,0 +1,223 @@ +{ + "bxsy": { + "sym": "bxsy", + "name": "BEXIL INVESTMENT TRUST", + "bucket": "harvest", + "in_portfolio": false, + "alpha_ann_5y": 0.06927005667397715, + "alpha_t_5y": 1.2149541754228392, + "alpha_t_full": 1.8229873570027744, + "r2_5y": 0.4618348730312034, + "r2_full": -2.220446049250313e-16, + "corr_portfolio": 0.33892135334668516, + "corr_benchmark": 0.45602489423127307, + "alpha_pos_frac": 0.515695067264574, + "fund_max_dd": -0.7433132163156555, + "first": "1998-06-25", + "verdict": "weak/unstable alpha" + }, + "wbqnl": { + "sym": "wbqnl", + "name": "Woodbridge Liquidation Trust", + "bucket": "harvest", + "in_portfolio": false, + "alpha_ann_5y": 0.9961091932730224, + "alpha_t_5y": 1.1101705514920563, + "alpha_t_full": 1.0226428696167786, + "r2_5y": 3.3306690738754696e-16, + "r2_full": 9.992007221626409e-16, + "corr_portfolio": -0.0035199119784513296, + "corr_benchmark": -0.03393673842772135, + "alpha_pos_frac": NaN, + "fund_max_dd": -0.9399999571200082, + "first": "2020-05-11", + "verdict": "weak/unstable alpha" + }, + "chkr": { + "sym": "chkr", + "name": "CHESAPEAKE GRANITE WASH TRUST", + "bucket": "harvest", + "in_portfolio": false, + "alpha_ann_5y": 0.5212096221629036, + "alpha_t_5y": 1.9305694961835806, + "alpha_t_full": 1.9305694961835806, + "r2_5y": 2.220446049250313e-16, + "r2_full": 2.220446049250313e-16, + "corr_portfolio": 0.07574482096405284, + "corr_benchmark": 0.05001005398172679, + "alpha_pos_frac": NaN, + "fund_max_dd": -0.6324567415625832, + "first": "2021-01-11", + "verdict": "weak/unstable alpha" + }, + "gultu": { + "sym": "gultu", + "name": "Gulf Coast Ultra Deep Royalty Trust", + "bucket": "harvest", + "in_portfolio": false, + "alpha_ann_5y": 1.0634379242764702, + "alpha_t_5y": 1.94477678998177, + "alpha_t_full": 1.382409605533132, + "r2_5y": -4.440892098500626e-16, + "r2_full": 1.4432899320127035e-15, + "corr_portfolio": 0.04066632940436689, + "corr_benchmark": 0.040088555722086666, + "alpha_pos_frac": NaN, + "fund_max_dd": -0.997829688469555, + "first": "2013-06-05", + "verdict": "weak/unstable alpha" + }, + "mmtrs": { + "sym": "mmtrs", + "name": "MILLS MUSIC TRUST", + "bucket": "harvest", + "in_portfolio": false, + "alpha_ann_5y": 0.07762726973809353, + "alpha_t_5y": 0.42528679541423187, + "alpha_t_full": 1.6768081213200938, + "r2_5y": 6.661338147750939e-16, + "r2_full": 0.031157054651066884, + "corr_portfolio": -0.050398683241705884, + "corr_benchmark": -0.044939596922708436, + "alpha_pos_frac": 0.5163043478260869, + "fund_max_dd": -0.605949540821771, + "first": "2010-10-18", + "verdict": "weak/unstable alpha" + }, + "hgtxu": { + "sym": "hgtxu", + "name": "HUGOTON ROYALTY TRUST", + "bucket": "harvest", + "in_portfolio": false, + "alpha_ann_5y": 0.7307734743494634, + "alpha_t_5y": 1.2232580470623284, + "alpha_t_full": 1.170973341611815, + "r2_5y": 0.02610774179213693, + "r2_full": 0.003643640247751545, + "corr_portfolio": 0.034422175407928385, + "corr_benchmark": 0.009653641140543325, + "alpha_pos_frac": 0.41379310344827586, + "fund_max_dd": -0.997779953094849, + "first": "1999-04-12", + "verdict": "weak/unstable alpha" + }, + "ltcn": { + "sym": "ltcn", + "name": "Grayscale Litecoin Trust (LTC)", + "bucket": "harvest", + "in_portfolio": false, + "alpha_ann_5y": -0.4170605500709843, + "alpha_t_5y": -0.910466285049782, + "alpha_t_full": 0.44872825005523104, + "r2_5y": 0.09926912946027, + "r2_full": 0.06380014823207847, + "corr_portfolio": 0.006207970423322662, + "corr_benchmark": 0.1679271530301851, + "alpha_pos_frac": 0.3333333333333333, + "fund_max_dd": -0.9958, + "first": "2020-08-19", + "verdict": "weak/unstable alpha" + }, + "etcg": { + "sym": "etcg", + "name": "Grayscale Ethereum Classic Trust (ETC)", + "bucket": "harvest", + "in_portfolio": false, + "alpha_ann_5y": 0.10920362929835496, + "alpha_t_5y": 0.26335909961725734, + "alpha_t_full": 0.2598552018670923, + "r2_5y": 0.1222132658141114, + "r2_full": 0.09099648179825504, + "corr_portfolio": 0.08516993583383066, + "corr_benchmark": 0.1947569069467775, + "alpha_pos_frac": 0.40425531914893614, + "fund_max_dd": -0.9658798207673674, + "first": "2018-05-11", + "verdict": "weak/unstable alpha" + }, + "bchg": { + "sym": "bchg", + "name": "Grayscale Bitcoin Cash Trust (BCH)", + "bucket": "harvest", + "in_portfolio": false, + "alpha_ann_5y": -1.0370219629357933, + "alpha_t_5y": -1.8073793086518852, + "alpha_t_full": 0.3919639833205906, + "r2_5y": 0.09353824409219647, + "r2_full": 0.07438637030766404, + "corr_portfolio": 0.012668117748772984, + "corr_benchmark": 0.14827766691441965, + "alpha_pos_frac": 0.3484848484848485, + "fund_max_dd": -0.9936056837230139, + "first": "2020-08-19", + "verdict": "weak/unstable alpha" + }, + "vnorp": { + "sym": "vnorp", + "name": "VORNADO REALTY TRUST", + "bucket": "harvest", + "in_portfolio": false, + "alpha_ann_5y": 0.6920962869579677, + "alpha_t_5y": 1.2244489160916494, + "alpha_t_full": 1.0787712165659415, + "r2_5y": -4.440892098500626e-16, + "r2_full": 6.661338147750939e-16, + "corr_portfolio": 0.02669554078119929, + "corr_benchmark": 0.0365901046059585, + "alpha_pos_frac": NaN, + "fund_max_dd": -0.7554747010633259, + "first": "2017-07-11", + "verdict": "weak/unstable alpha" + }, + "grtuf": { + "sym": "grtuf", + "name": "GRANITE REAL ESTATE INVESTMENT TRUST", + "bucket": "harvest", + "in_portfolio": false, + "alpha_ann_5y": 0.01169284649739261, + "alpha_t_5y": 0.09570009539903869, + "alpha_t_full": 0.6503721376518062, + "r2_5y": 0.1997409551483339, + "r2_full": 0.2222961586750618, + "corr_portfolio": 0.1777157657393587, + "corr_benchmark": 0.3142402348163954, + "alpha_pos_frac": 0.5126582278481012, + "fund_max_dd": -0.49333112124169476, + "first": "2013-01-07", + "verdict": "weak/unstable alpha" + }, + "hctpf": { + "sym": "hctpf", + "name": "Hutchison Port Holdings Trust/ADR", + "bucket": "harvest", + "in_portfolio": false, + "alpha_ann_5y": 0.38841147995406544, + "alpha_t_5y": 1.187463388113121, + "alpha_t_full": 0.6569127338633608, + "r2_5y": -2.220446049250313e-16, + "r2_full": 0.0037065997019110064, + "corr_portfolio": 0.05797833071874448, + "corr_benchmark": 0.0313773507142334, + "alpha_pos_frac": 0.37349397590361444, + "fund_max_dd": -0.85155740750894, + "first": "2012-04-19", + "verdict": "weak/unstable alpha" + }, + "ismcf": { + "sym": "ismcf", + "name": "iShares S&P GSCI Commodity-Indexed Trust", + "bucket": "harvest", + "in_portfolio": false, + "alpha_ann_5y": 0.13726732445593715, + "alpha_t_5y": 1.2547956518417025, + "alpha_t_full": 0.31953542544447894, + "r2_5y": 0.5752024336058434, + "r2_full": 0.7685431108511669, + "corr_portfolio": 0.2648024508467313, + "corr_benchmark": 0.645255483182384, + "alpha_pos_frac": 0.3333333333333333, + "fund_max_dd": -0.2518381238626939, + "first": "2018-09-14", + "verdict": "weak/unstable alpha" + } +} \ No newline at end of file diff --git a/fundlab/search_mined.json b/fundlab/search_mined.json new file mode 100644 index 0000000..0bcb0d6 --- /dev/null +++ b/fundlab/search_mined.json @@ -0,0 +1,862 @@ +{ + "qrprx": { + "sym": "qrprx", + "name": "AQR Alternative Risk Premia R6", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.1600539320146026, + "alpha_t_5y": 3.746900415536075, + "alpha_t_full": 2.519423053570873, + "r2_5y": 0.20826455753890027, + "r2_full": 0.12135409106563044, + "corr_portfolio": 0.7631471591786326, + "corr_benchmark": -0.12262262208507653, + "alpha_pos_frac": 0.5148514851485149, + "fund_max_dd": -0.317289520568956, + "first": "2017-09-20", + "verdict": "alpha, but correlated with current portfolio" + }, + "qmnnx": { + "sym": "qmnnx", + "name": "AQR Equity Market Neutral N", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.15278577055161027, + "alpha_t_5y": 4.438381081698417, + "alpha_t_full": 3.55541908381974, + "r2_5y": 0.26967439068510246, + "r2_full": 0.0757410367367195, + "corr_portfolio": 0.607275193429323, + "corr_benchmark": -0.13627368396966566, + "alpha_pos_frac": 0.5474452554744526, + "fund_max_dd": -0.39217588487587307, + "first": "2014-10-10", + "verdict": "alpha, but correlated with current portfolio" + }, + "qleix": { + "sym": "qleix", + "name": "AQR Long-Short Equity I", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.16578977304134326, + "alpha_t_5y": 4.4015935945881, + "alpha_t_full": 4.011544078674546, + "r2_5y": 0.25840152025440843, + "r2_full": 0.44615723686486575, + "corr_portfolio": 0.7071423062698972, + "corr_benchmark": 0.2670155502700607, + "alpha_pos_frac": 0.5695364238410596, + "fund_max_dd": -0.391976987856436, + "first": "2013-07-17", + "verdict": "alpha, but correlated with current portfolio" + }, + "qgmrx": { + "sym": "qgmrx", + "name": "AQR Macro Opportunities R6", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.057980519614889287, + "alpha_t_5y": 1.5977947998407327, + "alpha_t_full": 2.435129166036336, + "r2_5y": 0.22291108011334915, + "r2_full": 0.08727126384018968, + "corr_portfolio": 0.36214588351890326, + "corr_benchmark": -0.1756799669770561, + "alpha_pos_frac": 0.5, + "fund_max_dd": -0.13533835978044884, + "first": "2014-09-04", + "verdict": "weak/unstable alpha" + }, + "qmhrx": { + "sym": "qmhrx", + "name": "AQR Managed Futures Strategy HV R6", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.09824897354855751, + "alpha_t_5y": 1.8083554349675617, + "alpha_t_full": 2.1668606129500954, + "r2_5y": 0.35128536900753526, + "r2_full": 0.16284423243263402, + "corr_portfolio": 0.1902974299432177, + "corr_benchmark": -0.18975892567115601, + "alpha_pos_frac": 0.42028985507246375, + "fund_max_dd": -0.39058979113920433, + "first": "2014-09-04", + "verdict": "weak/unstable alpha" + }, + "aqmix": { + "sym": "aqmix", + "name": "AQR Managed Futures Strategy I", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.08005605082170343, + "alpha_t_5y": 2.1784878724553636, + "alpha_t_full": 2.492282530709043, + "r2_5y": 0.34683324124502435, + "r2_full": 0.11642882514098007, + "corr_portfolio": 0.20517533934397383, + "corr_benchmark": -0.1757448117047195, + "alpha_pos_frac": 0.4948453608247423, + "fund_max_dd": -0.2654230054569442, + "first": "2010-01-06", + "verdict": "CANDIDATE - idiosyncratic alpha, complements portfolio" + }, + "warrx": { + "sym": "warrx", + "name": "Allspring Absolute Return R6", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.033144306024387275, + "alpha_t_5y": 1.3146300225087615, + "alpha_t_full": 0.6450360204937103, + "r2_5y": 0.4096067761908916, + "r2_full": 0.3925574402466193, + "corr_portfolio": 0.3888931145600147, + "corr_benchmark": 0.33189487333668094, + "alpha_pos_frac": 0.5703703703703704, + "fund_max_dd": -0.23090428701476684, + "first": "2014-12-01", + "verdict": "weak/unstable alpha" + }, + "eksrx": { + "sym": "eksrx", + "name": "Allspring Diversified Income Bldr R6", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.007912370994162737, + "alpha_t_5y": 0.7248836188102821, + "alpha_t_full": 0.11380161203748929, + "r2_5y": 0.8712354981772631, + "r2_full": 0.8666793384341597, + "corr_portfolio": 0.2909267060359785, + "corr_benchmark": 0.6401931480941127, + "alpha_pos_frac": 0.4945054945054945, + "fund_max_dd": -0.2257163497358341, + "first": "2018-08-06", + "verdict": "sleeve mix (R\u00b2 high) - not alpha-driven" + }, + "bkmix": { + "sym": "bkmix", + "name": "BlackRock Multi-Asset Income Portfolio K", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.0013661837406016611, + "alpha_t_5y": 0.1717813919596725, + "alpha_t_full": 0.4050232448112397, + "r2_5y": 0.9094131536032556, + "r2_full": 0.8936803659916046, + "corr_portfolio": 0.3139285671999697, + "corr_benchmark": 0.7165194921939918, + "alpha_pos_frac": 0.5321100917431193, + "fund_max_dd": -0.1973451734470697, + "first": "2017-02-09", + "verdict": "sleeve mix (R\u00b2 high) - not alpha-driven" + }, + "bxmdx": { + "sym": "bxmdx", + "name": "Blackstone Alternative Multi-Strategy D", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.03491646193195598, + "alpha_t_5y": 2.3968211702431157, + "alpha_t_full": 1.7792956578721164, + "r2_5y": 0.28636888773620184, + "r2_full": 0.398432871732595, + "corr_portfolio": 0.29622985929330004, + "corr_benchmark": 0.3250033230998725, + "alpha_pos_frac": 0.4888888888888889, + "fund_max_dd": -0.19319231161826877, + "first": "2014-11-20", + "verdict": "CANDIDATE - idiosyncratic alpha, complements portfolio" + }, + "burfx": { + "sym": "burfx", + "name": "Burnham Financial Long/Short A", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": null, + "alpha_t_5y": NaN, + "alpha_t_full": 0.9942402027129364, + "r2_5y": NaN, + "r2_full": 0.7475045831670635, + "corr_portfolio": 0.2677504925335687, + "corr_benchmark": 0.18598003223530538, + "alpha_pos_frac": 0.4142857142857143, + "fund_max_dd": -0.3836435558268434, + "first": "2004-05-05", + "verdict": "no 5y window" + }, + "cmnix": { + "sym": "cmnix", + "name": "Calamos Market Neutral Income I", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.020194291397578824, + "alpha_t_5y": 2.797664087264935, + "alpha_t_full": 4.270226253902055, + "r2_5y": 0.7405097352200456, + "r2_full": 0.6092636885705476, + "corr_portfolio": 0.2885184137546138, + "corr_benchmark": 0.5236308979040174, + "alpha_pos_frac": 0.47766323024054985, + "fund_max_dd": -0.20593822768920278, + "first": "2000-05-30", + "verdict": "CANDIDATE (semi-alpha: mostly explained by net exposure)" + }, + "cplsx": { + "sym": "cplsx", + "name": "Calamos Phineus Long/Short A", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": -0.00015958554490860363, + "alpha_t_5y": -0.004435247962308447, + "alpha_t_full": 1.200988338503044, + "r2_5y": 0.541363993913625, + "r2_full": 0.6192563617744269, + "corr_portfolio": 0.28162855590905017, + "corr_benchmark": 0.3604673541731384, + "alpha_pos_frac": 0.46218487394957986, + "fund_max_dd": -0.34053557415202185, + "first": "2016-04-06", + "verdict": "weak/unstable alpha" + }, + "cltix": { + "sym": "cltix", + "name": "Catalyst Tactical Allocation Fund I", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": -0.04456170305768831, + "alpha_t_5y": -1.26755312021696, + "alpha_t_full": -0.7564123705589307, + "r2_5y": 0.6852411466890216, + "r2_full": 0.6638756866683736, + "corr_portfolio": 0.23847580532582827, + "corr_benchmark": 0.5381142781143685, + "alpha_pos_frac": 0.48226950354609927, + "fund_max_dd": -0.28630516786071747, + "first": "2014-06-09", + "verdict": "weak/unstable alpha" + }, + "taltx": { + "sym": "taltx", + "name": "Consulting Group Capital Markets Funds - Alternative Strategy Fund", + "bucket": "mined", + "error": "no return history in the data set" + }, + "cmalx": { + "sym": "cmalx", + "name": "Crawford Multi-Asset Income", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.016394380923237965, + "alpha_t_5y": 1.0161202707404007, + "alpha_t_full": 0.3817633032313494, + "r2_5y": 0.8191174808757729, + "r2_full": 0.7736058170932395, + "corr_portfolio": 0.3827550564186549, + "corr_benchmark": 0.544426034703509, + "alpha_pos_frac": 0.6039603960396039, + "fund_max_dd": -0.3903768839466234, + "first": "2017-09-13", + "verdict": "weak/unstable alpha" + }, + "dmsfx": { + "sym": "dmsfx", + "name": "Destinations Multi Strategy Alts I", + "bucket": "mined", + "error": "no return history in the data set" + }, + "diayx": { + "sym": "diayx", + "name": "Diamond Hill Long-Short Y", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": -0.001981324982722584, + "alpha_t_5y": -0.09335373100975897, + "alpha_t_full": 0.4520617676334624, + "r2_5y": 0.7791683109831256, + "r2_full": 0.8456521452529505, + "corr_portfolio": 0.3738215361306875, + "corr_benchmark": 0.46331563031570455, + "alpha_pos_frac": 0.5470588235294118, + "fund_max_dd": -0.31517374676226695, + "first": "2012-01-03", + "verdict": "weak/unstable alpha" + }, + "fsmmx": { + "sym": "fsmmx", + "name": "FS Multi-Strategy Alternatives A", + "bucket": "mined", + "error": "no return history in the data set" + }, + "fiwbx": { + "sym": "fiwbx", + "name": "Fidelity Advisor Multi-Asset Income Z", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.020727506850474794, + "alpha_t_5y": 1.1281227395081184, + "alpha_t_full": 1.425120797719713, + "r2_5y": 0.8133897393682811, + "r2_full": 0.8557754005261389, + "corr_portfolio": 0.24474961361413924, + "corr_benchmark": 0.7136768077831611, + "alpha_pos_frac": 0.4044943820224719, + "fund_max_dd": -0.21636594184198255, + "first": "2018-10-05", + "verdict": "weak/unstable alpha" + }, + "fmsdx": { + "sym": "fmsdx", + "name": "Fidelity Multi-Asset Income", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.020065620099702027, + "alpha_t_5y": 1.0942433920841528, + "alpha_t_full": 1.37561098737718, + "r2_5y": 0.8138427119012845, + "r2_full": 0.8528139741095889, + "corr_portfolio": 0.2487814171872356, + "corr_benchmark": 0.7139377060887578, + "alpha_pos_frac": 0.4270833333333333, + "fund_max_dd": -0.21636659636345534, + "first": "2018-02-27", + "verdict": "weak/unstable alpha" + }, + "ftmax": { + "sym": "ftmax", + "name": "First Trust Multi-Strategy Cl A", + "bucket": "mined", + "error": "no return history in the data set" + }, + "faaax": { + "sym": "faaax", + "name": "Franklin Alternative Strategies A", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.01309203663717352, + "alpha_t_5y": 1.2283383720658967, + "alpha_t_full": 1.6275872976359376, + "r2_5y": 0.607887440695306, + "r2_full": 0.6553175930301992, + "corr_portfolio": 0.28793504560310224, + "corr_benchmark": 0.47738642110979973, + "alpha_pos_frac": 0.46938775510204084, + "fund_max_dd": -0.1117951457283084, + "first": "2013-11-21", + "verdict": "weak/unstable alpha" + }, + "gaagx": { + "sym": "gaagx", + "name": "GMO Alternative Allocation I", + "bucket": "mined", + "error": "no return history in the data set" + }, + "gmamx": { + "sym": "gmamx", + "name": "Goldman Sachs Multi-Strategy Alternatives Fund", + "bucket": "mined", + "error": "no return history in the data set" + }, + "gpaix": { + "sym": "gpaix", + "name": "Grant Park Multi Alternative Strats I", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.008035877843127431, + "alpha_t_5y": 0.3735357407955509, + "alpha_t_full": 0.9846312406722558, + "r2_5y": 0.3870196716719805, + "r2_full": 0.30386830162653355, + "corr_portfolio": 0.20324552382593455, + "corr_benchmark": 0.4141139479733764, + "alpha_pos_frac": 0.4657534246575342, + "fund_max_dd": -0.17161194715948835, + "first": "2014-01-06", + "verdict": "weak/unstable alpha" + }, + "gioix": { + "sym": "gioix", + "name": "Guggenheim Macro Opportunities Instl", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.015410219407521897, + "alpha_t_5y": 2.3610357649028573, + "alpha_t_full": 4.416604738332403, + "r2_5y": 0.763725975931929, + "r2_full": 0.47854017954161043, + "corr_portfolio": 0.16595563271383124, + "corr_benchmark": 0.44600909254459775, + "alpha_pos_frac": 0.49707602339181284, + "fund_max_dd": -0.122231668089825, + "first": "2011-12-01", + "verdict": "CANDIDATE (semi-alpha: mostly explained by net exposure)" + }, + "gfsyx": { + "sym": "gfsyx", + "name": "GuideStone Funds - Strategic Alternatives Fund", + "bucket": "mined", + "error": "no return history in the data set" + }, + "piffx": { + "sym": "piffx", + "name": "Invesco Multi-Asset Income R6", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": -0.0033625121745873713, + "alpha_t_5y": -0.31735541545172474, + "alpha_t_full": -1.5456145262241747, + "r2_5y": 0.8662808420139416, + "r2_full": 0.7444930152458893, + "corr_portfolio": 0.2944534009486804, + "corr_benchmark": 0.7239162969462484, + "alpha_pos_frac": 0.6211180124223602, + "fund_max_dd": -0.3038145640820791, + "first": "2012-09-25", + "verdict": "sleeve mix (R\u00b2 high) - not alpha-driven" + }, + "qvopx": { + "sym": "qvopx", + "name": "Invesco Multi-Strategy Fund A", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": -0.013307866825721153, + "alpha_t_5y": -0.8684139396624104, + "alpha_t_full": 3.7481298369307288, + "r2_5y": 0.3261525760675942, + "r2_full": -2.220446049250313e-16, + "corr_portfolio": 0.2812182137557509, + "corr_benchmark": 0.4186621735141323, + "alpha_pos_frac": 0.5067264573991032, + "fund_max_dd": -0.30552532857851666, + "first": "1990-01-03", + "verdict": "weak/unstable alpha" + }, + "jaaax": { + "sym": "jaaax", + "name": "JHancock Alternative Asset Allc A", + "bucket": "mined", + "error": "no return history in the data set" + }, + "jhaax": { + "sym": "jhaax", + "name": "JHancock Multi-Asset Absolute Return A", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.01657599676490538, + "alpha_t_5y": 0.9102288544471362, + "alpha_t_full": -1.2999375934531863, + "r2_5y": 0.6368496214350496, + "r2_full": 0.5857621323143583, + "corr_portfolio": 0.18627034104514065, + "corr_benchmark": 0.5740329776144575, + "alpha_pos_frac": 0.5294117647058824, + "fund_max_dd": -0.1086538355925708, + "first": "2011-12-21", + "verdict": "weak/unstable alpha" + }, + "lotix": { + "sym": "lotix", + "name": "LoCorr Market Trend I", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.0007375529286507751, + "alpha_t_5y": 0.015717595792688714, + "alpha_t_full": 0.9954645022981967, + "r2_5y": 0.31753350966115257, + "r2_full": 0.1506937744585053, + "corr_portfolio": 0.24657982458097683, + "corr_benchmark": 0.06954555932845519, + "alpha_pos_frac": 0.42142857142857143, + "fund_max_dd": -0.28317369088420274, + "first": "2014-07-03", + "verdict": "weak/unstable alpha" + }, + "blavx": { + "sym": "blavx", + "name": "Lord Abbett Multi-Asset Balanced Opp R6", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": -0.011797325556756307, + "alpha_t_5y": -1.010085865942392, + "alpha_t_full": -0.7357659599702105, + "r2_5y": 0.9265728887932777, + "r2_full": 0.9328197776952308, + "corr_portfolio": 0.3103505887208745, + "corr_benchmark": 0.6379261074081366, + "alpha_pos_frac": 0.421875, + "fund_max_dd": -0.260437261686727, + "first": "2015-07-01", + "verdict": "sleeve mix (R\u00b2 high) - not alpha-driven" + }, + "lixvx": { + "sym": "lixvx", + "name": "Lord Abbett Multi-Asset Income R6", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": -0.004383174411143441, + "alpha_t_5y": -0.4568981299201697, + "alpha_t_full": -0.15577519270389997, + "r2_5y": 0.9042459815827678, + "r2_full": 0.8951857833403745, + "corr_portfolio": 0.3062653746695319, + "corr_benchmark": 0.6813260480847899, + "alpha_pos_frac": 0.4765625, + "fund_max_dd": -0.20178167405603809, + "first": "2015-07-01", + "verdict": "sleeve mix (R\u00b2 high) - not alpha-driven" + }, + "difhx": { + "sym": "difhx", + "name": "MFS Diversified Income R6", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.0039245713054233385, + "alpha_t_5y": 0.5466028351398784, + "alpha_t_full": 0.5949328291259101, + "r2_5y": 0.9344580249970595, + "r2_full": 0.934363839968193, + "corr_portfolio": 0.33988525357150673, + "corr_benchmark": 0.6931115151589421, + "alpha_pos_frac": 0.5853658536585366, + "fund_max_dd": -0.23744092135921013, + "first": "2012-07-03", + "verdict": "sleeve mix (R\u00b2 high) - not alpha-driven" + }, + "dvrlx": { + "sym": "dvrlx", + "name": "MFS Global Alternative Strategy R6", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": -0.03657211770555811, + "alpha_t_5y": -2.5657867620747057, + "alpha_t_full": -0.1291828242329135, + "r2_5y": 0.6895569997257555, + "r2_full": 0.4897033044299437, + "corr_portfolio": 0.28971086546170755, + "corr_benchmark": 0.52598078027921, + "alpha_pos_frac": 0.5229357798165137, + "fund_max_dd": -0.492406289109413, + "first": "2007-12-20", + "verdict": "weak/unstable alpha" + }, + "csaax": { + "sym": "csaax", + "name": "Mast Managed Futures Strategy A", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": -0.021839231499901426, + "alpha_t_5y": -0.5670420422388186, + "alpha_t_full": 1.1787995000841218, + "r2_5y": 0.25105581683621214, + "r2_full": 0.11110482665395294, + "corr_portfolio": 0.10211184167780919, + "corr_benchmark": -0.13429454987458886, + "alpha_pos_frac": 0.4472049689440994, + "fund_max_dd": -0.28773051050447884, + "first": "2012-10-04", + "verdict": "weak/unstable alpha" + }, + "mstvx": { + "sym": "mstvx", + "name": "Morningstar Funds Trust - Morningstar Alternatives Fund", + "bucket": "mined", + "error": "no return history in the data set" + }, + "czamx": { + "sym": "czamx", + "name": "Multi-Manager Alternative Strat Inst", + "bucket": "mined", + "error": "no return history in the data set" + }, + "dpzrx": { + "sym": "dpzrx", + "name": "Nomura Diversified Income R6", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.000730284066316539, + "alpha_t_5y": 0.12826526384995596, + "alpha_t_full": 1.0276307131100524, + "r2_5y": 0.9461638110297028, + "r2_full": 0.9003950238884322, + "corr_portfolio": -0.013103435138370314, + "corr_benchmark": 0.7207677383087638, + "alpha_pos_frac": 0.5, + "fund_max_dd": -0.19438389559225144, + "first": "2016-05-05", + "verdict": "sleeve mix (R\u00b2 high) - not alpha-driven" + }, + "pasix": { + "sym": "pasix", + "name": "PACE Alternative Strategies A", + "bucket": "mined", + "error": "no return history in the data set" + }, + "padqx": { + "sym": "padqx", + "name": "PGIM Absolute Return Bond R6", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.02253366213400559, + "alpha_t_5y": 2.421479706530911, + "alpha_t_full": 3.842984939716189, + "r2_5y": 0.31680161727319966, + "r2_full": 0.42634234905883495, + "corr_portfolio": 0.2749211688974797, + "corr_benchmark": 0.14414798935984474, + "alpha_pos_frac": 0.4913294797687861, + "fund_max_dd": -0.18058226282780776, + "first": "2011-03-31", + "verdict": "CANDIDATE - idiosyncratic alpha, complements portfolio" + }, + "pwlix": { + "sym": "pwlix", + "name": "PIMCO RAE Worldwide Long/Short PLUS Inst", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.07660282789667064, + "alpha_t_5y": 2.2896624565350545, + "alpha_t_full": 1.715326163582548, + "r2_5y": 0.2993915194908645, + "r2_full": 0.3849850156716639, + "corr_portfolio": 0.4290639036766289, + "corr_benchmark": 0.2060141552122883, + "alpha_pos_frac": 0.5777777777777777, + "fund_max_dd": -0.26923060673685906, + "first": "2014-12-09", + "verdict": "alpha, but correlated with current portfolio" + }, + "pqtix": { + "sym": "pqtix", + "name": "PIMCO TRENDS Fund Institutional", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.02847297577275021, + "alpha_t_5y": 0.742679931550064, + "alpha_t_full": 2.335970708368272, + "r2_5y": 0.15216577250089436, + "r2_full": 0.12647924317600812, + "corr_portfolio": 0.033638037506023684, + "corr_benchmark": -0.13324860245978445, + "alpha_pos_frac": 0.4863013698630137, + "fund_max_dd": -0.27647343409415515, + "first": "2014-01-07", + "verdict": "weak/unstable alpha" + }, + "pyaix": { + "sym": "pyaix", + "name": "Payden Absolute Return Bond SI", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.02968496968469588, + "alpha_t_5y": 4.784530488694529, + "alpha_t_full": 4.200797542613163, + "r2_5y": 0.3451232727740463, + "r2_full": 0.3250972688950624, + "corr_portfolio": 0.12526637204534918, + "corr_benchmark": 0.1844971141588079, + "alpha_pos_frac": 0.47794117647058826, + "fund_max_dd": -0.15680465360347773, + "first": "2014-11-10", + "verdict": "CANDIDATE - idiosyncratic alpha, complements portfolio" + }, + "pgblx": { + "sym": "pgblx", + "name": "Principal Diversified Income R6", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.01412872049404619, + "alpha_t_5y": 1.5199678294021424, + "alpha_t_full": 0.12964149147420354, + "r2_5y": 0.6978234439108565, + "r2_full": 0.6010198995642109, + "corr_portfolio": 0.27492234459641884, + "corr_benchmark": 0.608590270031421, + "alpha_pos_frac": 0.5904761904761905, + "fund_max_dd": -0.23771744813108997, + "first": "2017-06-13", + "verdict": "weak/unstable alpha" + }, + "pmsax": { + "sym": "pmsax", + "name": "Principal Global Multi-Strategy A", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.015538371548211896, + "alpha_t_5y": 1.8001012780965024, + "alpha_t_full": 1.665528177023728, + "r2_5y": 0.7847873201394527, + "r2_full": 0.6186592627892099, + "corr_portfolio": 0.39358726932232607, + "corr_benchmark": 0.53030328440744, + "alpha_pos_frac": 0.4941860465116279, + "fund_max_dd": -0.13947126763842388, + "first": "2011-11-02", + "verdict": "weak/unstable alpha" + }, + "pglsx": { + "sym": "pglsx", + "name": "Principal Global Multi-Strategy R-6", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.022834716219407386, + "alpha_t_5y": 2.6351709042494034, + "alpha_t_full": 2.2131792526289487, + "r2_5y": 0.7785150170658945, + "r2_full": 0.6229229469007576, + "corr_portfolio": 0.36642507552789066, + "corr_benchmark": 0.5511123168886634, + "alpha_pos_frac": 0.5238095238095238, + "fund_max_dd": -0.1395139992317488, + "first": "2017-06-13", + "verdict": "weak/unstable alpha" + }, + "rlsfx": { + "sym": "rlsfx", + "name": "RiverPark Long/Short Opportunity Retail", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": -0.16115615211661527, + "alpha_t_5y": -2.9697402107938906, + "alpha_t_full": -2.535083944427401, + "r2_5y": 0.7308724540747933, + "r2_full": 0.6421080886088519, + "corr_portfolio": -0.055074612442200174, + "corr_benchmark": 0.5146127994733334, + "alpha_pos_frac": 0.5988023952095808, + "fund_max_dd": -0.608949397322442, + "first": "2012-04-03", + "verdict": "weak/unstable alpha" + }, + "smsax": { + "sym": "smsax", + "name": "SEI Multi Strategy Alternatives F (SIMT)", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.017757331922862697, + "alpha_t_5y": 1.564490845308455, + "alpha_t_full": 0.3248577204647862, + "r2_5y": 0.6693784935352178, + "r2_full": 0.4972808858277301, + "corr_portfolio": 0.2729803768339342, + "corr_benchmark": 0.41511767643815717, + "alpha_pos_frac": 0.450261780104712, + "fund_max_dd": -0.10984441701958725, + "first": "2010-04-05", + "verdict": "weak/unstable alpha" + }, + "sioax": { + "sym": "sioax", + "name": "SEI Multi-Asset Income F (SIMT)", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.014537259779691733, + "alpha_t_5y": 1.8693036401798908, + "alpha_t_full": 2.2624177895387705, + "r2_5y": 0.8242395177988513, + "r2_full": 0.7457014660275068, + "corr_portfolio": 0.2938126221954463, + "corr_benchmark": 0.6592508517591171, + "alpha_pos_frac": 0.5269461077844312, + "fund_max_dd": -0.221002354954007, + "first": "2012-04-10", + "verdict": "weak/unstable alpha" + }, + "srdax": { + "sym": "srdax", + "name": "Stone Ridge Diversified Alternatives I", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.077315378593081, + "alpha_t_5y": 4.194473136324014, + "alpha_t_full": 4.335855416124276, + "r2_5y": 0.024737567509405922, + "r2_full": 0.025626537670158656, + "corr_portfolio": 0.10433532078238511, + "corr_benchmark": -0.05093300486016625, + "alpha_pos_frac": 0.5, + "fund_max_dd": -0.06326727678361443, + "first": "2020-10-19", + "verdict": "CANDIDATE - idiosyncratic alpha, complements portfolio" + }, + "tmssx": { + "sym": "tmssx", + "name": "T. Rowe Price Multi-Strategy Total Return Fund", + "bucket": "mined", + "error": "no return history in the data set" + }, + "vmnfx": { + "sym": "vmnfx", + "name": "Vanguard Market Neutral Inv", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.1213151413486212, + "alpha_t_5y": 4.1229748270195845, + "alpha_t_full": 2.480579289606245, + "r2_5y": 0.039369566462858496, + "r2_full": 1.5543122344752192e-15, + "corr_portfolio": 0.34813727658135635, + "corr_benchmark": -0.018378123746314586, + "alpha_pos_frac": 0.4824561403508772, + "fund_max_dd": -0.25936210806201754, + "first": "1998-11-17", + "verdict": "alpha, but correlated with current portfolio" + }, + "maukx": { + "sym": "maukx", + "name": "Victory Pioneer Multi-Asset Ult Inc R6", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.03870281318242449, + "alpha_t_5y": 7.512773707625843, + "alpha_t_full": 5.4955644552594665, + "r2_5y": 0.3189960905860648, + "r2_full": 0.2813303336084766, + "corr_portfolio": 0.12275189616530456, + "corr_benchmark": 0.034450897116474206, + "alpha_pos_frac": 0.3987341772151899, + "fund_max_dd": -0.09969803918560205, + "first": "2012-12-26", + "verdict": "alpha in 5y window, but not persistent (lucky stretch?)" + }, + "vtarx": { + "sym": "vtarx", + "name": "Virtus Tactical Allocation R6", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": -0.05335515876677799, + "alpha_t_5y": -2.480328859971851, + "alpha_t_full": -2.6929563012168445, + "r2_5y": 0.8608844289577577, + "r2_full": 0.8580871497621856, + "corr_portfolio": -0.06863060772036257, + "corr_benchmark": 0.7202560361372964, + "alpha_pos_frac": 0.59375, + "fund_max_dd": -0.3628908267131912, + "first": "2020-10-21", + "verdict": "sleeve mix (R\u00b2 high) - not alpha-driven" + }, + "wmnix": { + "sym": "wmnix", + "name": "Westwood Alternative Income Instl", + "bucket": "mined", + "in_portfolio": false, + "alpha_ann_5y": 0.038442357173482246, + "alpha_t_5y": 6.529116764845873, + "alpha_t_full": 5.306875605255055, + "r2_5y": 0.367349767186132, + "r2_full": 0.17596775396502795, + "corr_portfolio": 0.09120167169588639, + "corr_benchmark": 0.18140246101188268, + "alpha_pos_frac": 0.5153846153846153, + "fund_max_dd": -0.07640201924349688, + "first": "2015-05-04", + "verdict": "CANDIDATE - idiosyncratic alpha, complements portfolio" + }, + "masfx": { + "sym": "masfx", + "name": "iMGP Alternative Strategies Fund", + "bucket": "mined", + "error": "no return history in the data set" + } +} \ No newline at end of file diff --git a/fundlab/search_results.json b/fundlab/search_results.json new file mode 100644 index 0000000..3df09d2 --- /dev/null +++ b/fundlab/search_results.json @@ -0,0 +1,212 @@ +{ + "atesx": { + "sym": "atesx", + "name": "Anchor Risk Mgd Equity Strategies Instl", + "bucket": "shortlist", + "in_portfolio": false, + "alpha_ann_5y": 0.0055503014317009975, + "alpha_t_5y": 0.1484113857382209, + "alpha_t_full": 0.9293227873648724, + "r2_5y": 0.3610309574164604, + "r2_full": 0.3450047574870089, + "corr_portfolio": 0.02673861645107734, + "corr_benchmark": 0.32376107223953887, + "alpha_pos_frac": 0.49122807017543857, + "fund_max_dd": -0.12863626413945228, + "first": "2016-09-07", + "verdict": "weak/unstable alpha" + }, + "atrfx": { + "sym": "atrfx", + "name": "Catalyst Systematic Alpha I", + "bucket": "shortlist", + "in_portfolio": false, + "alpha_ann_5y": -0.018652787752631877, + "alpha_t_5y": -0.28898498571118264, + "alpha_t_full": 0.18349887648678206, + "r2_5y": 0.2604813339838056, + "r2_full": 0.11614807049400844, + "corr_portfolio": 0.22142653468828064, + "corr_benchmark": 0.20713067913226307, + "alpha_pos_frac": 0.4676258992805755, + "fund_max_dd": -0.3515020833110952, + "first": "2014-08-04", + "verdict": "weak/unstable alpha" + }, + "cvsix": { + "sym": "cvsix", + "name": "Calamos Market Neutral Income A", + "bucket": "shortlist", + "in_portfolio": false, + "alpha_ann_5y": 0.017888238619296486, + "alpha_t_5y": 2.4839246727508577, + "alpha_t_full": 6.693203697489781, + "r2_5y": 0.738709320350339, + "r2_full": 3.3306690738754696e-15, + "corr_portfolio": 0.29027243816488385, + "corr_benchmark": 0.5237277356889887, + "alpha_pos_frac": 0.48109965635738833, + "fund_max_dd": -0.20766965351534095, + "first": "1990-09-04", + "verdict": "CANDIDATE (semi-alpha: mostly explained by net exposure)" + }, + "jlpsx": { + "sym": "jlpsx", + "name": "JPMorgan US Large Cap Core Plus I", + "bucket": "shortlist", + "in_portfolio": false, + "alpha_ann_5y": -0.002431497257594755, + "alpha_t_5y": -0.1629307787117402, + "alpha_t_full": 0.4343924766190256, + "r2_5y": 0.9585561291856182, + "r2_full": 0.8354903597942736, + "corr_portfolio": 0.22676613719990743, + "corr_benchmark": 0.52227955380103, + "alpha_pos_frac": 0.47540983606557374, + "fund_max_dd": -0.513285225905074, + "first": "2005-11-02", + "verdict": "sleeve mix (R\u00b2 high) - not alpha-driven" + }, + "pmaix": { + "sym": "pmaix", + "name": "Victory Pioneer Multi-Asset Income A", + "bucket": "shortlist", + "in_portfolio": true, + "alpha_ann_5y": 0.049278056125020175, + "alpha_t_5y": 2.968511924087205, + "alpha_t_full": 3.9672810281689546, + "r2_5y": 0.7003658056055937, + "r2_full": 0.7087086927628709, + "corr_portfolio": 0.6036927197479715, + "corr_benchmark": 0.37605309741869536, + "alpha_pos_frac": 0.4764705882352941, + "fund_max_dd": -0.24116000785637026, + "first": "2011-12-23", + "verdict": "alpha, but correlated with current portfolio" + }, + "pmorx": { + "sym": "pmorx", + "name": "Putnam Mortgage Opportunities A", + "bucket": "shortlist", + "in_portfolio": false, + "alpha_ann_5y": 0.05216102895935035, + "alpha_t_5y": 4.144374847398222, + "alpha_t_full": 1.4357260819167152, + "r2_5y": 0.03721366605296683, + "r2_full": 0.10084825638507544, + "corr_portfolio": 0.15610236827399032, + "corr_benchmark": 0.1549485287519335, + "alpha_pos_frac": 0.7, + "fund_max_dd": -0.19308848044669336, + "first": "2019-07-01", + "verdict": "CANDIDATE - idiosyncratic alpha, complements portfolio" + }, + "qspnx": { + "sym": "qspnx", + "name": "AQR Style Premia Alternative N", + "bucket": "shortlist", + "in_portfolio": true, + "alpha_ann_5y": 0.16785818097663643, + "alpha_t_5y": 3.3082513187185243, + "alpha_t_full": 3.2654332888524036, + "r2_5y": 0.2616252851889068, + "r2_full": 0.11754675753202926, + "corr_portfolio": 0.852144510349361, + "corr_benchmark": -0.16758094685348948, + "alpha_pos_frac": 0.49324324324324326, + "fund_max_dd": -0.41792301574889723, + "first": "2013-10-31", + "verdict": "alpha, but correlated with current portfolio" + }, + "svarx": { + "sym": "svarx", + "name": "Spectrum Low Volatility Investor", + "bucket": "shortlist", + "in_portfolio": false, + "alpha_ann_5y": 0.023983827438686146, + "alpha_t_5y": 2.2535550856884474, + "alpha_t_full": 4.583134072763739, + "r2_5y": 0.28289328741120845, + "r2_full": 0.16745275696353723, + "corr_portfolio": 0.13269895533798984, + "corr_benchmark": 0.27028110365794056, + "alpha_pos_frac": 0.3698630136986301, + "fund_max_dd": -0.06486054560652632, + "first": "2013-12-18", + "verdict": "alpha in 5y window, but not persistent (lucky stretch?)" + }, + "cosix": { + "sym": "cosix", + "name": "Columbia Strategic Income A", + "bucket": "shortlist", + "in_portfolio": false, + "alpha_ann_5y": 0.010918442324802066, + "alpha_t_5y": 1.6544462573044783, + "alpha_t_full": 8.97921782878531, + "r2_5y": 0.8663944005206073, + "r2_full": 3.3306690738754696e-16, + "corr_portfolio": 0.14900065311998206, + "corr_benchmark": 0.5726198691530625, + "alpha_pos_frac": 0.5086705202312138, + "fund_max_dd": -0.261588575393748, + "first": "1990-01-03", + "verdict": "sleeve mix (R\u00b2 high) - not alpha-driven" + }, + "mbxix": { + "sym": "mbxix", + "name": "Catalyst/Millburn Hedge Strategy I", + "bucket": "shortlist", + "in_portfolio": false, + "alpha_ann_5y": 0.031018107560385543, + "alpha_t_5y": 0.8817953110008355, + "alpha_t_full": 0.9829918601685478, + "r2_5y": 0.4921477665240592, + "r2_full": 0.5706527612045402, + "corr_portfolio": 0.31809032443125856, + "corr_benchmark": 0.31985204190867955, + "alpha_pos_frac": 0.5491803278688525, + "fund_max_dd": -0.317313385665538, + "first": "2015-12-29", + "verdict": "weak/unstable alpha" + }, + "eagmx": { + "sym": "eagmx", + "name": "Eaton Vance Glbl Macr Absolute Return A", + "bucket": "shortlist", + "in_portfolio": false, + "alpha_ann_5y": 0.05559035554775872, + "alpha_t_5y": 5.2379756386224, + "alpha_t_full": 8.039707312489185, + "r2_5y": 0.07396669655814492, + "r2_full": -1.1102230246251565e-15, + "corr_portfolio": 0.23667567422802827, + "corr_benchmark": -0.02151029112701147, + "alpha_pos_frac": 0.44223107569721115, + "fund_max_dd": -0.0931393650735658, + "first": "1997-11-03", + "verdict": "alpha in 5y window, but not persistent (lucky stretch?)" + }, + "lcorx": { + "sym": "lcorx", + "name": "Leuthold Core Investment Retail", + "bucket": "shortlist", + "error": "no return history in the data set" + }, + "lamhx": { + "sym": "lamhx", + "name": "Lord Abbett Dividend Growth R6", + "bucket": "shortlist", + "in_portfolio": false, + "alpha_ann_5y": -0.005326437096891267, + "alpha_t_5y": -0.3518378999299911, + "alpha_t_full": 0.4775716671703663, + "r2_5y": 0.9437584450573062, + "r2_full": 0.9561509121649294, + "corr_portfolio": 0.29977485384501745, + "corr_benchmark": 0.6191978200471758, + "alpha_pos_frac": 0.515625, + "fund_max_dd": -0.3345219095634927, + "first": "2015-07-01", + "verdict": "sleeve mix (R\u00b2 high) - not alpha-driven" + } +} \ No newline at end of file diff --git a/fundlab/searchlist.py b/fundlab/searchlist.py new file mode 100644 index 0000000..213efcf --- /dev/null +++ b/fundlab/searchlist.py @@ -0,0 +1,68 @@ +"""Curated longlist of candidate funds for the alpha search. + +Universe rationale: we cannot meaningfully screen all ~10k registered +funds (most are small, illiquid or strategy-unstable and would not +deserve portfolio consideration anyway). So the UNIVERSE is curated by +reputation — large, liquid, long-tracked funds in the buckets where +idiosyncratic alpha lives (market-neutral/quant, multi-strategy, global +macro, dynamic TA, dynamic credit/convertibles) — and the SELECTION is +data-driven (fundlab/search.py). Tickers are resolved from the fund NAME +via Yahoo search with a precision gate; a candidate that cannot be +resolved cleanly is dropped, never guessed. + +The 13-fund shortlist already in funds.json is screened with the same +code so the ranking is consistent (qspnx/pmaix are the user's current +holdings and act as controls). +""" +from __future__ import annotations + +# (fund name as searched, strategy bucket, ticker guess or None) +LONGLIST: list[tuple[str, str, str | None]] = [ + # --- market-neutral / quant / pure alpha / event-driven ------------ + ("AQR Diversified Event-Driven Fund", "event_driven", None), + ("Bridgewater Pure Alpha II Fund", "pure_alpha", None), + ("Winton Global Quantitative Fund", "cta_quant", None), + ("Two Sigma Dynamic Strategy Fund", "systematic", None), + ("Brevan Howard Dymon Asia Fund", "macro_relative_value", None), + ("Marshall Wace Global Opportunities Fund", "macro_relative_value", + None), + # --- multi-strategy ------------------------------------------------- + ("ExodusPoint Diversified Fund", "multi_strategy", None), + ("Verition Dynamic Risk Fund", "multi_strategy", None), + ("Millennium Focus Fund", "multi_strategy", None), + ("Balyasny Absolute Return Multi-Strategy Fund", "multi_strategy", None), + ("Schonfeld Strategic Opportunities Fund", "multi_strategy", None), + # --- global macro / risk parity ------------------------------------ + # (T. Rowe Price New Global Opportunity: fund terminated - no data) + ("Bridgewater All Weather Fund", "risk_parity", None), + ("Oak Hill Tactical Allocation Fund", "tactical_allocation", None), + # --- multi-asset / dynamic TA open-ends (the accessible alpha pool) - + ("Fidelity Multi-Asset Income Fund", "tactical_allocation", None), + ("Janus Henderson Global Dynamic Dividend Fund", "dynamic_equity", None), + ("Wellington Dynamic Global Diversified Fund", "tactical_allocation", None), + ("Lord Abbett Global Opportunities Fund", "tactical_allocation", None), + ("BlackRock Multi-Asset Income Fund", "tactical_allocation", None), + ("PIMCO Income Strategy Fund", "tactical_allocation", None), + ("JPMorgan Diversified Return Fund", "tactical_allocation", None), + ("Invesco Diversified Equity and Income Fund", "tactical_allocation", None), + ("T. Rowe Price Global Allocation Fund", "tactical_allocation", None), + ("Morgan Stanley Global Multi Asset Fund", "tactical_allocation", None), + ("Fidelity Diversified Multi-Asset Fund", "tactical_allocation", None), + # --- controls: credit (expected to classify as sleeve mix) + # (Calamos Dynamic Convertible CCD is a CEF - excluded) + ("PIMCO Dynamic Income Fund", "dynamic_credit", None), +] + +# the 13 unique shortlist funds (share classes resolved once) +SHORTLIST = ["atesx", "atrfx", "cvsix", "jlpsx", "pmaix", "pmorx", "qspnx", + "svarx", "cosix", "mbxix", "eagmx", "lcorx", "lamhx"] + +# broad sleeve set used by the screen (same 21 axes for every fund - +# nothing is tuned to a specific fund, so selection is comparable) +BROAD_SLEEVES = ["qqq", "ivv", "iwm", "vea", "efa", "vwo", "vnq", "bil", + "shv", "ief", "tlt", "vblix", "agg", "vweax", "vmbix", + "finux", "djp", "gsg", "gld", "fxe", "fxy"] + +# the user's current portfolio + benchmark mix (settings.json) +PORTFOLIO = {"qspnx": 0.5, "pmaix": 0.5} +BENCHMARKS = {"spy": 1 / 3, "agg": 1 / 3, "tlt": 1 / 3} diff --git a/fundlab/tickers.py b/fundlab/tickers.py new file mode 100644 index 0000000..8a6b179 --- /dev/null +++ b/fundlab/tickers.py @@ -0,0 +1,120 @@ +"""Ticker resolution via EDGAR prospectus covers + Yahoo chart verification. + +For a fund NAME: + 1. EDGAR full-text search for the name in 497/497K prospectuses + (exact-phrase FTS is fragile to hyphens / "Fund" variants, so a + small query ladder is tried: exact -> hyphen-free -> 3-word windows), + 2. fetch the most relevant prospectus, extract every "Ticker Symbol: XXX" + from the cover (one per share class), + 3. verify each candidate ticker against Yahoo chart metadata + (instrumentType MUTUALFUND + name token overlap), + 4. accept the first that passes, else report unresolved. + +Both gates are precision-oriented: a wrong fund's ticker is worse than +no ticker, so ambiguity drops the candidate. +""" +from __future__ import annotations + +import re +import time + +from fundlab import edgar +from fundlab.search import _name_match, chart_meta + +# two cover-page formats: +# "Ticker Symbol: XXXXX" and the Class/Ticker table "Fund Name /XXXIX" +TICKER_RX = re.compile( + r"(?:ticker\s*symbol|ticker|symbol)\s*[:\-]?\s*([A-Z][A-Z0-9]{3,8})\b", + re.I) +SLASH_RX = re.compile(r"\s/\s*([A-Z][A-Z0-9]{3,8})\b") + +_STOP = {"the", "and", "of", "to", "a", "i", "c", "b", "z", "x", "fund", + "funds", "series", "class"} + + +def _queries(name: str) -> list[str]: + """Fallback query ladder for EDGAR FTS phrase search.""" + n = re.sub(r"[-–]", " ", name) + words = re.findall(r"[A-Za-z0-9.]+", n) + sig = [w for w in words if w.lower() not in _STOP] + qs = [f'"{name}"', f'"{n}"'] + if len(sig) >= 3: + qs += [f'"{ " ".join(sig[:2]) }"'] + if len(sig) >= 4: + qs += [f'"{ " ".join(sig[:3]) }"', f'"{ " ".join(sig[-3:]) }"'] + out, seen = [], set() + for q in qs: + if q not in seen: + seen.add(q) + out.append(q) + return out + + +def tickers_from_prospectus(doc_url: str) -> list[str]: + """Extract ticker candidates from the first 150KB of a prospectus.""" + try: + raw = edgar.sec_get(doc_url, timeout=60) + except Exception: + return [] + text = edgar.to_text(raw[:150_000]) + found = re.findall(TICKER_RX, text) + re.findall(SLASH_RX, text) + out, seen = [], set() + for t in found: + t = t.upper() + if t in seen or not re.fullmatch(r"[A-Z][A-Z0-9]{3,8}", t): + continue + if t[0].isdigit(): + continue + seen.add(t) + out.append(t) + return out + + +def resolve_via_edgar(name: str) -> dict | None: + """Resolve fund name -> ticker via EDGAR 497 covers + chart gate. + + Returns {symbol, name, sim, guess} or None. + """ + tried = set() + fetches = 0 + for q in _queries(name): + try: + hits = edgar.fts_search(q, forms="497,497K", size=10) + except Exception: + continue + ciks = set() + for h in sorted(hits, key=lambda x: -x.get("score", 0)): + cik = h.get("cik") + if not cik or cik in ciks: + continue + if len(ciks) >= 6: # phrase hits span several registrants; + break # the right one is not always ranked first + ciks.add(cik) + url = edgar.doc_url(cik, h["accession"], h["filename"]) + fetches += 1 + for t in tickers_from_prospectus(url): + if t in tried: + continue + tried.add(t) + r = _chart_verify(t, name) + if r: + return r + time.sleep(0.2) + if fetches >= 15: # hard cap on prospectus fetches + return None + return None + + +def _chart_verify(ticker: str, name: str) -> dict | None: + meta = chart_meta(ticker) + if not meta: + return None + itype = (meta.get("instrumentType") or "").upper() + if itype not in ("MUTUALFUND", "FUND"): + return None + cand = meta.get("longName") or meta.get("shortName") or "" + tok = _name_match(name, cand) + if tok < 2 / 3: + return None + return {"symbol": ticker.lower(), "name": cand, "sim": round(tok, 3), + "guess": ticker} diff --git a/tests/test_fundlab.py b/tests/test_fundlab.py index dd426c6..0fd7527 100644 --- a/tests/test_fundlab.py +++ b/tests/test_fundlab.py @@ -273,6 +273,38 @@ def test_decompose() -> None: check("fwd handles nan overlap", "s1" in chosen_p, f"chosen={chosen_p}") +def test_search() -> None: + print("search engine", flush=True) + from fundlab import dbmine, search, tickers + # precision gate: a different fund sharing some words must fail + check("name gate rejects wrong fund", + search._name_match("PIMCO Access to Global Markets Fund", + "PIMCO Access Income Fund") < 2 / 3, "") + check("name gate accepts right fund", + search._name_match("Fidelity Multi-Asset Income Fund", + "Fidelity Multi-Asset Income") >= 2 / 3, "") + # query ladder handles hyphens + word-count fallbacks + q = tickers._queries("AQR Diversified Event-Driven Fund") + check("query ladder exact first", q[0] == + '"AQR Diversified Event-Driven Fund"', str(q)) + check("query ladder hyphen-free", '"AQR Diversified Event Driven Fund"' + in q, str(q)) + check("query ladder 2-word prefix", '"AQR Diversified"' in q, str(q)) + # family dedupe: share classes collapse, distinct funds don't + check("family dedupe same fund", + dbmine.family_key("AQR Style Premia Alternative R6") + == dbmine.family_key("AQR Style Premia Alternative I"), "") + check("family dedupe distinct funds", + dbmine.family_key("AQR Style Premia Alternative R6") + != dbmine.family_key("AQR Managed Futures Strategy I"), "") + # ticker regex: both cover formats + import re + t1 = re.findall(tickers.TICKER_RX, "Ticker Symbol: ABCDX") + t2 = re.findall(tickers.SLASH_RX, "Fidelity Multi-Asset Income Fund /FMSDX ") + check("ticker regex label format", t1 == ["ABCDX"], str(t1)) + check("ticker regex slash format", t2 == ["FMSDX"], str(t2)) + + def test_curated() -> None: print("curated", flush=True) import fundlab.fundinfo as fi @@ -294,6 +326,7 @@ def main() -> int: test_strategy() test_nport() test_decompose() + test_search() test_curated() test_edgar_live() print(f"\n{PASS} passed, {FAIL} failed")