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
129 lines
4.5 KiB
Python
129 lines
4.5 KiB
Python
"""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 <sym>.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)
|