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