f/fundlab/edgar_universe.py
Greg Pomerantz 592d12958f Find candidate funds NOT in the DB: exhaustive EDGAR 497-universe pass
fundlab/edgar_universe.py - the 'search' for funds we don't have:
  1. SEC full-index (Archives/edgar/full-index/YYYY/QTRn/company.gz)
     lists every filing; CIKs that filed a base 497/497K in the past 4
     quarters = every currently-active US open-end fund (1,668).
  2. one small fetch per CIK: the full-submission .txt carries the
     line-based SGML prospectus cover (<SERIES-NAME> ... unclosed
     <CLASS-CONTRACT-TICKER-SYMBOL> tags) - fund name + every class
     ticker, often several funds per filing.
  3. alpha-leaning name filter (expanded dbmine PATTERN: +relative
     value, risk allocation, dynamic global, real return, hedged),
     drop local-DB + shortlist tickers,
  4. Yahoo chart verify: instrumentType MUTUALFUND (OTC open-end;
     exchange name is useless - OTC funds report 'Nasdaq'),
     >=5y daily history,
  5. share-class dedupe (longest history), goget download, same
     screen_fund engine.
  Resumable (per-CIK covers cache), 4-thread, Range-free small files.

First pass results (46 funds screened, 5 NEW candidates):
  egrix/ecgmx Eaton Vance Global Macro Absolute Return: R2 0.07,
    +7.9%/+4.8% 5y alpha, t 4.9/4.6, corr-port 0.22 - pure macro idio
  dmszx Destinations Multi-Strategy Alternatives: R2 0.57, +3.3%, t3.5
  cbhax Victory Market Neutral Income: R2 0.07, +4.6%, t2.9, corr 0.11
  pdinx Putnam Diversified Income: semi-alpha (full t5.8, 62% 6m+)
  (+ wmnux/gioax = 2nd share classes of already-known candidates)
  vmnix Vanguard MN: alpha but corr 0.35 (portfolio already 50% MN)

app Fund Lab alpha table now also reads search_external.json.
tests: parse_cover unit tests (unclosed-tag SGML, ticker series
attach, malformed rejected). 65/65 fundlab, 32/32 app.
2026-08-26 15:22:52 -04:00

228 lines
8.8 KiB
Python

"""Find candidate funds NOT in the local database - exhaustive pass over
the SEC's full filing index.
Source: the EDGAR full-index (https://www.sec.gov/Archives/edgar/
full-index/YYYY/QTRn/company.gz) lists every filing. Unique CIKs that
filed a 497-family form in the quarter = currently registered US
open-end funds (1,732 in 2026 Q2). One fetch per CIK - the full
submission .txt (~10-30KB) contains the SGML prospectus cover with
<SERIES-NAME> (fund name) and <CLASS-CONTRACT-TICKER-SYMBOL> (ticker)
tags, often several series per filing.
Pass:
1. full-index -> latest 497 filing per CIK
2. fetch the .txt (small), extract series names + tickers from the
SGML cover
3. keep alpha-leaning series, drop local-DB and shortlist tickers
4. verify on Yahoo chart metadata (OTC fund class), require >=5y
history, goget-download, screen with the same engine.
Politeness: 0.15s between requests; resumable (per-CIK cache).
"""
from __future__ import annotations
import gzip
import json
import re
import subprocess
import time
import urllib.request
from concurrent.futures import as_completed
from pathlib import Path
from fundlab import edgar, search
from fundlab.dbmine import PATTERN as ALPHA_KW, SKIP
# A fund files its base 497 (and 497K) about once a year, in whatever
# quarter its re-filing lands - so the FULL universe is the union over
# ~4 consecutive quarters.
QUARTERS = [("2026", "2"), ("2026", "1"), ("2025", "4"), ("2025", "3")]
def _index_url(year: str, qtr: str) -> str:
return (f"https://www.sec.gov/Archives/edgar/full-index/{year}"
f"/QTR{qtr}/company.gz")
# index paths are relative to /Archives/ (edgar/data/<cik>/<acc>.txt)
ARCH = "https://www.sec.gov/Archives/"
CACHE = Path(__file__).parent / "universe_cache"
COVERS = CACHE / "covers.json"
RESULTS = Path(__file__).parent / "search_external.json"
DATA = Path.home() / "prog/fin/stocks"
GOGET = Path.home() / "go/bin/goget"
UA = {"User-Agent": "research test@example.com"}
# The prospectus cover is line-based SGML with UNCLOSED tags:
# <SERIES-NAME>1290 GAMCO Small/Mid Cap Value Fund
# <CLASS-CONTRACT-TICKER-SYMBOL>TNVAX
# so parse line by line, not with closed-tag regexes.
def fetch_index(quarters: list[tuple[str, str]] = QUARTERS) -> dict:
"""cik -> path of its LATEST 497/497K filing's .txt across quarters."""
rx = re.compile(r"^(.*?)\s+(497\w*)\s+(\d+)\s+(\d{4}-\d{2}-\d{2})\s+"
r"(edgar/data/\d+/[0-9\-]+)\.txt\s*$")
best: dict[str, tuple[tuple, str]] = {}
for year, qtr in quarters:
req = urllib.request.Request(_index_url(year, qtr), headers=UA)
text = gzip.decompress(urllib.request.urlopen(req, timeout=120)
.read()).decode("utf-8", "ignore")
for line in text.splitlines():
m = rx.match(line)
if not m:
continue
_name, form, cik, date, path = m.groups()
if form not in ("497", "497K"): # base/annual carry tickers
continue
key = (year, qtr, date)
if cik not in best or key > best[cik][0]:
best[cik] = (key, path)
return {c: p + ".txt" for c, (_k, p) in best.items()}
def parse_cover(text: str) -> list[dict]:
"""Line-based SGML cover -> [{name, tickers[]}].
The 497 cover uses UNCLOSED tags, one per line:
<SERIES-NAME>1290 Multi-Alternative Strategies Fund
<CLASS-CONTRACT-TICKER-SYMBOL>TNMAX
Each ticker attaches to the most recent <SERIES-NAME>.
"""
series: list[dict] = []
cur: dict | None = None
for line in text.splitlines():
if line.startswith("<SERIES-NAME>"):
cur = {"name": line[len("<SERIES-NAME>"):].strip(),
"tickers": []}
series.append(cur)
elif line.startswith("<CLASS-CONTRACT-TICKER-SYMBOL>") \
and cur is not None:
t = line[len("<CLASS-CONTRACT-TICKER-SYMBOL>"):]
t = t.split("<")[0].strip()
if re.fullmatch(r"[A-Z][A-Z0-9]{3,8}", t):
cur["tickers"].append(t)
return series
def _fetch_one(cik: str, path: str) -> tuple[str, dict]:
url = ARCH + path
try:
req = urllib.request.Request(url, headers=UA)
raw = urllib.request.urlopen(req, timeout=90).read()
text = raw.decode("utf-8", "ignore")
return cik, {"series": parse_cover(text)}
except Exception as e:
return cik, {"error": str(e)}
def fetch_covers(max_ciks: int | None = None,
workers: int = 4) -> dict:
from concurrent.futures import ThreadPoolExecutor
ciks = fetch_index()
if max_ciks:
ciks = dict(list(ciks.items())[:max_ciks])
CACHE.mkdir(exist_ok=True)
cache = (json.loads(COVERS.read_text()) if COVERS.exists() else {})
todo = sorted(c for c, p in ciks.items()
if c not in cache or "error" in cache.get(c, {}))
print(f"{len(ciks)} registered funds, {len(todo)} covers to fetch",
flush=True)
done = 0
with ThreadPoolExecutor(max_workers=workers) as ex:
futs = {ex.submit(_fetch_one, c, ciks[c]): c for c in todo}
for fut in as_completed(futs):
cik, res = fut.result()
cache[cik] = res
done += 1
if done % 100 == 0:
COVERS.write_text(json.dumps(cache))
print(f" {done}/{len(todo)} covers", flush=True)
COVERS.write_text(json.dumps(cache))
n_err = sum(1 for v in cache.values() if "error" in v)
print(f"covers done: {len(cache)} ({n_err} errors)", flush=True)
return cache
def run(max_ciks: int | None = None) -> dict:
covers = fetch_covers(max_ciks)
# candidate (name, ticker) pairs, alpha-leaning, not already known
local = {p.name[:-5].lower() for p in DATA.glob("*.json")}
shortlist = set(json.loads(
(Path(__file__).parent.parent / "funds.json").read_text()))
known = local | shortlist
cands: dict[str, str] = {}
for v in covers.values():
for s in v.get("series", []):
name = s.get("name", "")
if not name or ALPHA_KW.search(name) is None:
continue
if SKIP.search(name):
continue
for t in s.get("tickers", []):
if t.lower() not in known:
cands.setdefault(t, name)
print(f"{len(cands)} alpha-leaning tickers not in local DB", flush=True)
# Yahoo verify: OTC fund class + >=5y history
verified = []
for t, name in sorted(cands.items()):
meta = search.chart_meta(t)
if not meta:
continue
# keep OTC open-end fund classes; drop exchange-listed ETFs
# (Yahoo shows OTC funds as exchange "Nasdaq" - instrumentType
# is the real discriminator)
if (meta.get("instrumentType") or "").upper() not in ("MUTUALFUND", ""):
continue
n = 0
try:
req = urllib.request.Request(
f"https://query1.finance.yahoo.com/v8/finance/chart/"
f"{t}?range=20y&interval=1d", headers=search.UA)
d = json.load(urllib.request.urlopen(req, timeout=30))
res = (d.get("chart") or {}).get("result")
n = len(res[0].get("timestamp", [])) if res else 0
except Exception:
pass
if n >= 1250:
verified.append({"ticker": t, "name": name, "days": n})
time.sleep(0.15)
print(f"{len(verified)} with >=5y daily history", flush=True)
# one class per fund (share classes share the series name): keep the
# longest-history class
by_name: dict[str, dict] = {}
for v in verified:
cur = by_name.get(v["name"])
if cur is None or v["days"] > cur["days"]:
by_name[v["name"]] = v
verified = list(by_name.values())
print(f"{len(verified)} after share-class dedupe", flush=True)
missing = [v["ticker"].lower() for v in verified
if not (DATA / f"{v['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=3600)
results = {}
for v in verified:
sym = v["ticker"].lower()
if not (DATA / f"{sym}-history.csv").exists():
results[sym] = {"sym": sym, "name": v["name"],
"error": "no history after download"}
continue
row = search.screen_fund(sym, v["name"], "external")
results[sym] = row
print(f"{sym:7} {v['name'][:44]:44} "
f"{row.get('verdict', row.get('error'))[:40]}", flush=True)
RESULTS.write_text(json.dumps(results, indent=1, default=str))
print(f"wrote {RESULTS}")
return results
if __name__ == "__main__":
import sys
run(max_ciks=int(sys.argv[1]) if len(sys.argv) > 1 else None)