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
121 lines
4.0 KiB
Python
121 lines
4.0 KiB
Python
"""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}
|