f/fundlab/harvest.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

101 lines
3.6 KiB
Python

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