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

121 lines
4.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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}