fundlab: EDGAR investment-objective fetcher + curated fallback
- edgar.py: SEC FTS + submissions API; strict cover-gate extraction (name in title position or (TICKER) on the cover; underlying-reference names like leveraged wrappers rejected); 4-pass fetch (ticker->CIK filings, name search, annual reports, ticker search); keyword category classifier. Returns None rather than a wrong fund's objective. - fundinfo.py CLI: curated -> cached -> EDGAR resolution into funds.json - funds_curated.json: human-verified objectives for 13 benchmark-pool funds (iShares/Vanguard family-trust classes the scraper can't reach) - tests: 26 checks incl. live EDGAR fetch of VTSAX
This commit is contained in:
parent
4f36bc7aea
commit
e9f8dfc462
1
fundlab/__init__.py
Normal file
1
fundlab/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""fundlab: automated fund analysis (EDGAR info, benchmark pool, factor tests)."""
|
||||||
371
fundlab/edgar.py
Normal file
371
fundlab/edgar.py
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
"""EDGAR fetcher: fund investment objective + category from SEC filings.
|
||||||
|
|
||||||
|
Pipeline: fund name -> EDGAR full-text search over prospectus/annual-report
|
||||||
|
forms (N-1A, 485APOS, 8-A12B, S-1, N-CSR, N-CSRS) -> fetch the document ->
|
||||||
|
extract the "Investment Objective" sentence -> classify the fund category.
|
||||||
|
|
||||||
|
SEC usage policy: identify yourself via USER_AGENT (name + contact), stay
|
||||||
|
well under 10 requests/second (we insert REQUEST_DELAY between calls).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
USER_AGENT = "fundlab (personal research) gmp@wow.st"
|
||||||
|
REQUEST_DELAY = 0.15 # seconds between SEC requests
|
||||||
|
MAX_DOC_BYTES = 60 * 2 ** 20 # skip documents bigger than this
|
||||||
|
|
||||||
|
# forms whose primary document contains a fund's investment objective.
|
||||||
|
# Per-fund registration statements are tried FIRST — they hold one fund's
|
||||||
|
# objective in a clean "Fund Summary" box. Family annual reports (N-CSR/
|
||||||
|
# N-CSRS) cover hundreds of funds across several sub-documents, so the
|
||||||
|
# target fund's section may be in a part the search doesn't surface.
|
||||||
|
# forms whose documents contain a fund's investment objective. Per-fund
|
||||||
|
# registration statements are tried FIRST — they hold one fund's objective
|
||||||
|
# in a clean "Fund Summary" box. Annual reports (N-CSR/N-CSRS) are a
|
||||||
|
# fallback; family filings covering many funds are filtered out by the
|
||||||
|
# cover gate (see _on_cover).
|
||||||
|
PROSPECTUS_FORMS = "485APOS,485BPOS,497,497K,497F,N-1A,8-A12B,S-1,FWP,424B2,424B3"
|
||||||
|
ANNUAL_FORMS = "N-CSR,N-CSRS"
|
||||||
|
|
||||||
|
# how far into a document the fund's identifier may sit and still count as
|
||||||
|
# being ON THE COVER (the document is about this fund). Per-fund
|
||||||
|
# prospectus covers fit in ~1200 chars; title position only (~400) for
|
||||||
|
# annual reports and ticker-based lookups.
|
||||||
|
COVER_GATE = 1200
|
||||||
|
TITLE_GATE = 400
|
||||||
|
|
||||||
|
_last_request = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def sec_get(url: str, timeout: int = 120, retries: int = 3) -> bytes:
|
||||||
|
"""GET with SEC user agent, politeness delay and retry/backoff."""
|
||||||
|
global _last_request
|
||||||
|
for attempt in range(retries):
|
||||||
|
wait = REQUEST_DELAY - (time.monotonic() - _last_request)
|
||||||
|
if wait > 0:
|
||||||
|
time.sleep(wait)
|
||||||
|
_last_request = time.monotonic()
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
return r.read()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 404:
|
||||||
|
raise
|
||||||
|
if e.code in (429, 500, 502, 503) and attempt + 1 < retries:
|
||||||
|
time.sleep(2.0 * (attempt + 1))
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
except (urllib.error.URLError, TimeoutError) as e:
|
||||||
|
if attempt + 1 < retries:
|
||||||
|
time.sleep(2.0 * (attempt + 1))
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
raise RuntimeError("unreachable")
|
||||||
|
|
||||||
|
|
||||||
|
def to_text(html: bytes) -> str:
|
||||||
|
"""Crude but adequate HTML -> text: block ends become newlines, tags go."""
|
||||||
|
t = re.sub(rb"<\s*(br|/p|/div|/tr|/h[1-6])[^>]*>", b"\n", html, flags=re.I)
|
||||||
|
t = re.sub(rb"<[^>]+>", b" ", t)
|
||||||
|
t = re.sub(rb"&#?\w+;", b" ", t)
|
||||||
|
t = t.decode("latin-1", "ignore")
|
||||||
|
t = re.sub(r"[ \t]+", " ", t)
|
||||||
|
t = re.sub(r"\n\s*", "\n", t)
|
||||||
|
# tag stripping eats apostrophes ("Fund s board") — fix the common ones
|
||||||
|
t = re.sub(r"\b(Fund|Trust|ETF) s\b", r"\1's", t)
|
||||||
|
# abbreviations whose dots would break sentence-level matching
|
||||||
|
for a, b in (("U.S.", "US"), ("U.K.", "UK"), ("E.U.", "EU"),
|
||||||
|
("e.g.", "eg"), ("i.e.", "ie"), ("No.", "No")):
|
||||||
|
t = t.replace(a, b)
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def ticker_to_company(ticker: str) -> tuple[str, str] | None:
|
||||||
|
"""ticker -> (cik, conformed company name) via browse-edgar (Atom output)."""
|
||||||
|
url = ("https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany"
|
||||||
|
f"&CIK={urllib.parse.quote(ticker.upper())}&type=&dateb="
|
||||||
|
"&owner=include&count=5&output=atom")
|
||||||
|
xml = sec_get(url, timeout=30).decode("latin-1", "ignore")
|
||||||
|
cik = re.search(r"<cik>(\d+)</cik>", xml)
|
||||||
|
name = re.search(r"<conformed-name>([^<]+)</conformed-name>", xml)
|
||||||
|
if not cik:
|
||||||
|
return None
|
||||||
|
return cik.group(1), (name.group(1) if name else "")
|
||||||
|
|
||||||
|
|
||||||
|
def cik_recent_filings(cik: str, types: str, count: int = 6) -> list[dict]:
|
||||||
|
"""Recent filings of a registrant (newest first) via the submissions API."""
|
||||||
|
url = f"https://data.sec.gov/submissions/CIK{int(cik):010d}.json"
|
||||||
|
d = json.loads(sec_get(url, timeout=60))
|
||||||
|
f = d.get("filings", {}).get("recent", {})
|
||||||
|
forms = f.get("form", [])
|
||||||
|
out = []
|
||||||
|
for i, form in enumerate(forms):
|
||||||
|
if form not in types.split(","):
|
||||||
|
continue
|
||||||
|
out.append({
|
||||||
|
"accession": f["accessionNumber"][i],
|
||||||
|
"form": form,
|
||||||
|
"filed": f.get("filingDate", [""] * len(forms))[i],
|
||||||
|
"doc": (f.get("primaryDocument") or [""] * len(forms))[i],
|
||||||
|
})
|
||||||
|
if len(out) >= count:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def fts_search(query: str, forms: str | None = None, size: int = 20) -> list[dict]:
|
||||||
|
"""EDGAR full-text search.
|
||||||
|
|
||||||
|
Hits are sorted by relevance, then date: a fund's own prospectus ranks
|
||||||
|
above documents that merely mention the ticker (funds investing in it,
|
||||||
|
family fund lists). Relevance alone is not enough — section attribution
|
||||||
|
in extract_objective is the real guard — but ordering by it means the
|
||||||
|
first match is usually the right fund's.
|
||||||
|
"""
|
||||||
|
params = {"q": query, "forms": forms or "", "size": str(size)}
|
||||||
|
url = f"https://efts.sec.gov/LATEST/search-index?{urllib.parse.urlencode(params)}"
|
||||||
|
d = json.loads(sec_get(url, timeout=60))
|
||||||
|
hits = d.get("hits", {}).get("hits", [])
|
||||||
|
out, seen = [], set()
|
||||||
|
for h in hits:
|
||||||
|
s = h.get("_source", {})
|
||||||
|
key = (s.get("adsh", ""), h.get("_id", "").split(":", 1)[-1])
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append({
|
||||||
|
"score": h.get("_score", 0.0),
|
||||||
|
"accession": s.get("adsh", ""),
|
||||||
|
"filename": h.get("_id", "").split(":", 1)[-1],
|
||||||
|
"cik": (s.get("ciks") or [""])[0],
|
||||||
|
"form": s.get("form", ""),
|
||||||
|
"file_date": s.get("file_date", ""),
|
||||||
|
})
|
||||||
|
out.sort(key=lambda x: (x["score"], x["file_date"]), reverse=True)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def doc_url(cik: str, accession: str, filename: str) -> str:
|
||||||
|
return (f"https://www.sec.gov/Archives/edgar/data/{int(cik)}"
|
||||||
|
f"/{accession.replace('-', '')}/{filename}")
|
||||||
|
|
||||||
|
|
||||||
|
_SEEK = (r"((?:the|its) (?:fund|trust|etf|portfolio)\s*\)?[^\.\n]{0,120}?"
|
||||||
|
r"seeks (?:to |investment results |investment objective)?"
|
||||||
|
r"[^.]{10,400}\.)")
|
||||||
|
|
||||||
|
|
||||||
|
def _name_re(name: str) -> str:
|
||||||
|
"""Pattern for a fund name; to_text() turns 'U.S.' into 'US', so the
|
||||||
|
dots are optional."""
|
||||||
|
return re.escape(name).replace(r"U\.S\.", r"U\.?S\.?")
|
||||||
|
|
||||||
|
|
||||||
|
# a name preceded by one of these is a REFERENCE to another fund (a
|
||||||
|
# leveraged ETF's cover says "200% of the performance of the Invesco
|
||||||
|
# QQQ Trust"), not the title of the document
|
||||||
|
_REF_BEFORE = re.compile(
|
||||||
|
r"(?:performance\s+of\s+|underlying\s+|index\s+of\s+|versus\s+|"
|
||||||
|
r"than\s+|against\s+|of\s+the\s+|the\s+)$", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def _name_titlelike(text: str, name: str, limit: int) -> bool:
|
||||||
|
"""True when the name appears in title position on the cover — not as
|
||||||
|
an underlying reference (see _REF_BEFORE)."""
|
||||||
|
for m in re.finditer(_name_re(name), text[:limit], re.I):
|
||||||
|
before = text[max(0, m.start() - 40):m.start()]
|
||||||
|
if not _REF_BEFORE.search(before):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _on_cover(text: str, name: str | None, ticker: str | None,
|
||||||
|
limit: int = COVER_GATE) -> str | None:
|
||||||
|
"""Cover gate: is this document ABOUT the fund?
|
||||||
|
|
||||||
|
A fund's own registration statement puts its name (in title position)
|
||||||
|
or its ticker in parentheses on the cover page, within the first
|
||||||
|
~1200 characters. A document that merely mentions the fund elsewhere
|
||||||
|
(family fund lists, funds investing in it, exhibits) does not. Returns
|
||||||
|
'name' / 'ticker' / None.
|
||||||
|
"""
|
||||||
|
head = text[:limit]
|
||||||
|
if name and _name_titlelike(text, name, limit):
|
||||||
|
return "name"
|
||||||
|
if ticker and re.search(r"\(\s*" + re.escape(ticker.upper()) + r"\s*\)",
|
||||||
|
head, re.I):
|
||||||
|
return "ticker"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_objective(text: str, name: str, ticker: str | None = None,
|
||||||
|
gate: int = COVER_GATE) -> str | None:
|
||||||
|
"""Extract the fund's investment objective sentence from a document
|
||||||
|
that is about the fund (see _on_cover).
|
||||||
|
|
||||||
|
Returns None when the document is not about this fund (cover gate
|
||||||
|
fails) or no objective sentence is found — never another fund's.
|
||||||
|
"""
|
||||||
|
if not _on_cover(text, name, ticker, gate):
|
||||||
|
return None
|
||||||
|
heads = [m.start() for m in re.finditer(r"investment objective", text, re.I)]
|
||||||
|
if heads:
|
||||||
|
m = re.search(_SEEK, text[heads[0]: heads[0] + 2500], re.I)
|
||||||
|
if m:
|
||||||
|
return _clean(m.group(1))
|
||||||
|
m = re.search(_SEEK, text[:15000], re.I)
|
||||||
|
return _clean(m.group(1)) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(s: str) -> str:
|
||||||
|
# captures like "(the Fund) seeks to ..." leave a stray ')'
|
||||||
|
s = re.sub(r"^\)\s*", "", s)
|
||||||
|
s = re.sub(r"^((?:the|its) (?:fund|trust|etf|portfolio)"
|
||||||
|
r"(?: or (?:fund|trust|etf|portfolio))?)\s*\)\s*",
|
||||||
|
r"\1 ", s, flags=re.I)
|
||||||
|
s = re.sub(r"\s+", " ", s).strip()
|
||||||
|
if s[:1].islower():
|
||||||
|
s = s[0].upper() + s[1:]
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def classify_category(objective: str, name: str) -> str:
|
||||||
|
"""equity / fixed_income / mixed / alternatives / money_market / other.
|
||||||
|
|
||||||
|
Keyword rules over objective + fund name; deliberately transparent and
|
||||||
|
conservative (unknown -> 'other')."""
|
||||||
|
t = f"{objective} {name}".lower()
|
||||||
|
if re.search(r"money market", t):
|
||||||
|
return "money_market"
|
||||||
|
equity = bool(re.search(
|
||||||
|
r"equit|stocks?|common stock|capitalization|nasdaq.?100|s&p ?500 "
|
||||||
|
r"index|total stock market|russell ?2000", t))
|
||||||
|
bond = bool(re.search(
|
||||||
|
r"debt securit|bonds?\b|fixed.income|maturit|treasur|credit|yield|"
|
||||||
|
r"mortgage|interest.rate", t))
|
||||||
|
alt = bool(re.search(
|
||||||
|
r"commodit|derivatives|real estate|currenc|private (?:equity|credit)|"
|
||||||
|
r"hedge|bullion|precious metal|\bgold\b|\bsilver\b|\bcopper\b|"
|
||||||
|
r"\bnatural gas\b|\bcrude oil\b|\bwheat\b|\bcorn\b", t))
|
||||||
|
if equity and bond:
|
||||||
|
return "mixed"
|
||||||
|
if bond:
|
||||||
|
return "fixed_income"
|
||||||
|
if equity:
|
||||||
|
return "equity"
|
||||||
|
if alt:
|
||||||
|
return "alternatives"
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
|
def accession_docs(cik: str, accession: str) -> list[str]:
|
||||||
|
"""All .htm files of a filing (family reports are split into parts)."""
|
||||||
|
url = (f"https://www.sec.gov/Archives/edgar/data/{int(cik)}"
|
||||||
|
f"/{accession.replace('-', '')}/index.json")
|
||||||
|
try:
|
||||||
|
d = json.loads(sec_get(url, timeout=60))
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
return [it["name"] for it in d.get("directory", {}).get("item", [])
|
||||||
|
if it["name"].lower().endswith((".htm", ".html"))]
|
||||||
|
|
||||||
|
|
||||||
|
def _try_docs(hits: list[dict], name: str, ticker: str | None,
|
||||||
|
max_docs: int, gate: int) -> dict | None:
|
||||||
|
"""Fetch FTS hits (relevance order) and return the first document that
|
||||||
|
passes the cover gate and yields an objective sentence."""
|
||||||
|
fetched = 0
|
||||||
|
for h in hits:
|
||||||
|
if fetched >= max_docs:
|
||||||
|
break
|
||||||
|
# annual reports: the fund's part may be a sibling document of the
|
||||||
|
# same filing, so enumerate the accession's documents
|
||||||
|
names = [h["filename"]]
|
||||||
|
if h["form"] in ANNUAL_FORMS.split(","):
|
||||||
|
names += [n for n in accession_docs(h["cik"], h["accession"])
|
||||||
|
if n != h["filename"]]
|
||||||
|
for fn in names:
|
||||||
|
if fetched >= max_docs:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
raw = sec_get(doc_url(h["cik"], h["accession"], fn))
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
continue
|
||||||
|
fetched += 1
|
||||||
|
if len(raw) > MAX_DOC_BYTES:
|
||||||
|
continue
|
||||||
|
obj = extract_objective(to_text(raw), name, ticker, gate)
|
||||||
|
if obj:
|
||||||
|
return {
|
||||||
|
"objective": obj,
|
||||||
|
"category": classify_category(obj, name),
|
||||||
|
"form": h["form"],
|
||||||
|
"file_date": h["file_date"],
|
||||||
|
"url": doc_url(h["cik"], h["accession"], fn),
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_fund(fund_name: str, ticker: str | None = None,
|
||||||
|
max_docs: int = 8) -> dict | None:
|
||||||
|
"""Full pipeline for one fund. Returns metadata dict or None.
|
||||||
|
|
||||||
|
Passes, most direct first:
|
||||||
|
0. ticker -> registrant CIK (standalone trusts like Invesco QQQ
|
||||||
|
Trust / iShares Silver Trust): their most recent prospectus
|
||||||
|
filings, cover-gated;
|
||||||
|
1. fund-name search over prospectus forms — name in title position
|
||||||
|
or (TICKER) on the cover (COVER_GATE);
|
||||||
|
2. fund-name search over annual reports — name in title position
|
||||||
|
(TITLE_GATE);
|
||||||
|
3. ticker search over prospectus forms — (TICKER) in title position
|
||||||
|
(TITLE_GATE); catches funds whose registered name changed over
|
||||||
|
time.
|
||||||
|
Returns None rather than a wrong fund's objective.
|
||||||
|
"""
|
||||||
|
tk = ticker.upper() if ticker else None
|
||||||
|
if tk:
|
||||||
|
comp = ticker_to_company(tk)
|
||||||
|
if comp:
|
||||||
|
for fl in cik_recent_filings(comp[0], PROSPECTUS_FORMS, count=15):
|
||||||
|
if not fl["doc"]:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
raw = sec_get(doc_url(comp[0], fl["accession"], fl["doc"]))
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
continue
|
||||||
|
if len(raw) > MAX_DOC_BYTES:
|
||||||
|
continue
|
||||||
|
obj = extract_objective(to_text(raw), fund_name, tk, COVER_GATE)
|
||||||
|
if obj:
|
||||||
|
return {
|
||||||
|
"objective": obj,
|
||||||
|
"category": classify_category(obj, fund_name),
|
||||||
|
"form": fl["form"],
|
||||||
|
"file_date": fl["filed"],
|
||||||
|
"url": doc_url(comp[0], fl["accession"], fl["doc"]),
|
||||||
|
}
|
||||||
|
q = f'"{fund_name}" "seeks"'
|
||||||
|
res = _try_docs(fts_search(q, PROSPECTUS_FORMS, size=100),
|
||||||
|
fund_name, tk, max_docs, COVER_GATE)
|
||||||
|
if res:
|
||||||
|
return res
|
||||||
|
res = _try_docs(fts_search(q, ANNUAL_FORMS, size=100),
|
||||||
|
fund_name, tk, max_docs, TITLE_GATE)
|
||||||
|
if res:
|
||||||
|
return res
|
||||||
|
if tk:
|
||||||
|
res = _try_docs(fts_search(f'"{tk}" "seeks"', PROSPECTUS_FORMS, size=100),
|
||||||
|
fund_name, tk, max_docs, TITLE_GATE)
|
||||||
|
if res:
|
||||||
|
return res
|
||||||
|
return None
|
||||||
130
fundlab/fundinfo.py
Normal file
130
fundlab/fundinfo.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
"""Resolve fund investment objectives + categories.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m fundlab.fundinfo SYMBOL [SYMBOL ...] [--refresh] [--no-curate] [--max-docs N]
|
||||||
|
|
||||||
|
Objective sources, in priority order:
|
||||||
|
1. funds_curated.json (human-verified, in this package) — used unless
|
||||||
|
--no-curate. Authoritative for the well-known benchmark funds.
|
||||||
|
2. SEC EDGAR — the fund's longName (from the Yahoo chart JSON in the
|
||||||
|
data root, default ~/prog/fin/stocks) is searched over prospectus /
|
||||||
|
annual-report forms; the objective is extracted from the fund's own
|
||||||
|
filing and the fund classified.
|
||||||
|
|
||||||
|
Everything is merged into funds.json next to the repository root. Cached
|
||||||
|
EDGAR entries are reused unless --refresh is given. Be polite to the SEC:
|
||||||
|
one fund takes ~5-15 requests; run a handful at a time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .edgar import fetch_fund
|
||||||
|
|
||||||
|
DATA_ROOT = Path("~/prog/fin/stocks").expanduser()
|
||||||
|
FUNDS_FILE = Path(__file__).resolve().parent.parent / "funds.json"
|
||||||
|
CURATED_FILE = Path(__file__).resolve().parent / "funds_curated.json"
|
||||||
|
|
||||||
|
|
||||||
|
def load_curated() -> dict:
|
||||||
|
if CURATED_FILE.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(CURATED_FILE.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def long_name(sym: str, root: Path) -> str | None:
|
||||||
|
f = root / f"{sym}.json"
|
||||||
|
if not f.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
meta = json.loads(f.read_text())["chart"]["result"][0]["meta"]
|
||||||
|
return meta.get("longName") or meta.get("shortName")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
ap = argparse.ArgumentParser(prog="fundlab.fundinfo")
|
||||||
|
ap.add_argument("symbols", nargs="+", help="fund ticker(s), e.g. vtsax vwo")
|
||||||
|
ap.add_argument("--refresh", action="store_true",
|
||||||
|
help="re-fetch from EDGAR even if present in funds.json")
|
||||||
|
ap.add_argument("--no-curate", action="store_true",
|
||||||
|
help="ignore funds_curated.json and use EDGAR for everything")
|
||||||
|
ap.add_argument("--data-root", default=str(DATA_ROOT))
|
||||||
|
ap.add_argument("--max-docs", type=int, default=10,
|
||||||
|
help="max EDGAR documents to try per fund")
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
root = Path(args.data_root)
|
||||||
|
curated = {} if args.no_curate else load_curated()
|
||||||
|
funds = {}
|
||||||
|
if FUNDS_FILE.exists():
|
||||||
|
try:
|
||||||
|
funds = json.loads(FUNDS_FILE.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
rc = 0
|
||||||
|
for sym in args.symbols:
|
||||||
|
sym = sym.lower()
|
||||||
|
# 1) curated (authoritative) — always wins over cache/EDGAR
|
||||||
|
if sym in curated:
|
||||||
|
c = curated[sym]
|
||||||
|
funds[sym] = {
|
||||||
|
"name": c.get("name", ""),
|
||||||
|
"objective": c["objective"],
|
||||||
|
"category": c["category"],
|
||||||
|
"source": "curated",
|
||||||
|
}
|
||||||
|
print(f"= {sym}: curated ({c['category']})")
|
||||||
|
continue
|
||||||
|
# 2) cached EDGAR result
|
||||||
|
if not args.refresh and sym in funds and funds[sym].get("objective"):
|
||||||
|
print(f"= {sym}: cached ({funds[sym].get('category')})")
|
||||||
|
continue
|
||||||
|
# 3) EDGAR
|
||||||
|
name = long_name(sym, root)
|
||||||
|
if not name:
|
||||||
|
print(f"= {sym}: NO chart JSON in {root} — cannot identify fund",
|
||||||
|
file=sys.stderr)
|
||||||
|
rc = 1
|
||||||
|
continue
|
||||||
|
print(f"= {sym}: fetching '{name}' from EDGAR ...", flush=True)
|
||||||
|
try:
|
||||||
|
res = fetch_fund(name, ticker=sym, max_docs=args.max_docs)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ERROR: {type(e).__name__}: {e}", file=sys.stderr)
|
||||||
|
rc = 1
|
||||||
|
continue
|
||||||
|
if not res:
|
||||||
|
print(f" no investment objective found in EDGAR", file=sys.stderr)
|
||||||
|
rc = 1
|
||||||
|
continue
|
||||||
|
funds[sym] = {
|
||||||
|
"name": name,
|
||||||
|
"objective": res["objective"],
|
||||||
|
"category": res["category"],
|
||||||
|
"form": res["form"],
|
||||||
|
"file_date": res["file_date"],
|
||||||
|
"url": res["url"],
|
||||||
|
"fetched": date.today().isoformat(),
|
||||||
|
"source": "edgar",
|
||||||
|
}
|
||||||
|
print(f" category: {res['category']} [{res['form']} {res['file_date']}]")
|
||||||
|
print(f" objective: {res['objective']}")
|
||||||
|
|
||||||
|
FUNDS_FILE.write_text(json.dumps(funds, indent=2))
|
||||||
|
print(f"\n{FUNDS_FILE}")
|
||||||
|
return rc
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
67
fundlab/funds_curated.json
Normal file
67
fundlab/funds_curated.json
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
{
|
||||||
|
"agg": {
|
||||||
|
"name": "iShares Core U.S. Aggregate Bond ETF",
|
||||||
|
"objective": "The Fund seeks to track the investment results of an index composed of the total US investment-grade bond market.",
|
||||||
|
"category": "fixed_income"
|
||||||
|
},
|
||||||
|
"bnd": {
|
||||||
|
"name": "Vanguard Total Bond Market ETF",
|
||||||
|
"objective": "The Fund seeks to track the performance of a broad, market-weighted bond index.",
|
||||||
|
"category": "fixed_income"
|
||||||
|
},
|
||||||
|
"efa": {
|
||||||
|
"name": "iShares MSCI EAFE ETF",
|
||||||
|
"objective": "The Fund seeks to track the investment performance of an index composed of large- and mid-cap stocks from developed markets outside the US.",
|
||||||
|
"category": "equity"
|
||||||
|
},
|
||||||
|
"gld": {
|
||||||
|
"name": "SPDR Gold Shares",
|
||||||
|
"objective": "The Trust seeks to reflect, before fees and expenses, the performance of the price of gold bullion.",
|
||||||
|
"category": "alternatives"
|
||||||
|
},
|
||||||
|
"iwm": {
|
||||||
|
"name": "iShares Russell 2000 ETF",
|
||||||
|
"objective": "The Fund seeks through investments in the underlying index to provide investment results that, before fees and expenses, generally correspond to the price and yield performance of an index of small-capitalization US stocks.",
|
||||||
|
"category": "equity"
|
||||||
|
},
|
||||||
|
"qqq": {
|
||||||
|
"name": "Invesco QQQ Trust, Series 1",
|
||||||
|
"objective": "The Fund seeks to track, before fees and expenses, the investment results of the Nasdaq-100 Index.",
|
||||||
|
"category": "equity"
|
||||||
|
},
|
||||||
|
"schd": {
|
||||||
|
"name": "Schwab U.S. Dividend Equity ETF",
|
||||||
|
"objective": "The Fund seeks to track the performance of the Dow Jones US Dividend 100 Index, before fees and expenses.",
|
||||||
|
"category": "equity"
|
||||||
|
},
|
||||||
|
"shv": {
|
||||||
|
"name": "iShares 0-1 Year Treasury Bond ETF",
|
||||||
|
"objective": "The Fund seeks to track the investment results of the ICE Short US Treasury Securities Index, which measures the performance of public obligations of the US Treasury that have a remaining maturity of less than or equal to one year.",
|
||||||
|
"category": "fixed_income"
|
||||||
|
},
|
||||||
|
"slv": {
|
||||||
|
"name": "iShares Silver Trust",
|
||||||
|
"objective": "The Trust seeks to reflect, before fees and expenses, generally the performance of the price of silver, less the Trust's expenses.",
|
||||||
|
"category": "alternatives"
|
||||||
|
},
|
||||||
|
"vea": {
|
||||||
|
"name": "Vanguard FTSE Developed Markets Index Fund ETF Shares",
|
||||||
|
"objective": "The Fund seeks to track, before fees and expenses, the investment performance of an index representative of the large- and mid-capitalization stocks of companies in developed markets.",
|
||||||
|
"category": "equity"
|
||||||
|
},
|
||||||
|
"vtsax": {
|
||||||
|
"name": "Vanguard Total Stock Market Index Fund",
|
||||||
|
"objective": "The Fund seeks to track the performance of a benchmark index that measures the investment return of the overall stock market.",
|
||||||
|
"category": "equity"
|
||||||
|
},
|
||||||
|
"vti": {
|
||||||
|
"name": "Vanguard Total Stock Market Index Fund ETF Shares",
|
||||||
|
"objective": "The Fund seeks to track the performance of a benchmark index that measures the investment return of the overall stock market.",
|
||||||
|
"category": "equity"
|
||||||
|
},
|
||||||
|
"vwo": {
|
||||||
|
"name": "Vanguard FTSE Emerging Markets Index Fund ETF Shares",
|
||||||
|
"objective": "The Fund seeks to track, before fees and expenses, the investment performance of an index representative of stocks of companies in emerging markets.",
|
||||||
|
"category": "equity"
|
||||||
|
}
|
||||||
|
}
|
||||||
49
fundlab/pool.py
Normal file
49
fundlab/pool.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
"""Benchmark discovery pool.
|
||||||
|
|
||||||
|
A copy of ~/prog/fin/benchmarks.txt (the user's curated benchmark universe),
|
||||||
|
augmented with a few common index ETFs that exist in the data universe.
|
||||||
|
The ORIGINAL file outside this repository is never modified.
|
||||||
|
|
||||||
|
Format: section header lines (ending in ':' or free-standing), then
|
||||||
|
'symbol<tab or 2+ spaces>label' lines.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
POOL_FILE = Path(__file__).parent / "pool" / "benchmarks.txt"
|
||||||
|
|
||||||
|
_ENTRY = re.compile(r"^(\S{1,6})[\t ]{2,}(.+)$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PoolEntry:
|
||||||
|
section: str
|
||||||
|
symbol: str # lowercase
|
||||||
|
label: str
|
||||||
|
|
||||||
|
|
||||||
|
def load_pool(path: Path = POOL_FILE) -> list[PoolEntry]:
|
||||||
|
entries: list[PoolEntry] = []
|
||||||
|
section = ""
|
||||||
|
for line in path.read_text().splitlines():
|
||||||
|
line = line.rstrip()
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
m = _ENTRY.match(line)
|
||||||
|
if m:
|
||||||
|
entries.append(PoolEntry(section, m.group(1).lower(),
|
||||||
|
m.group(2).strip()))
|
||||||
|
else:
|
||||||
|
section = line.strip().rstrip(":")
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def pool_labels(entries: list[PoolEntry] | None = None) -> dict[str, str]:
|
||||||
|
"""symbol -> 'section: label' (for keyword matching and display)."""
|
||||||
|
if entries is None:
|
||||||
|
entries = load_pool()
|
||||||
|
return {e.symbol: f"{e.section}: {e.label}" for e in entries if e.section}
|
||||||
158
fundlab/pool/benchmarks.txt
Normal file
158
fundlab/pool/benchmarks.txt
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
Treasury/Agency:
|
||||||
|
|
||||||
|
vfijx gnma
|
||||||
|
vipix inflation-protected securities
|
||||||
|
vbiux intermediate-term bond index
|
||||||
|
viigx intermediate-term government bond
|
||||||
|
vfiux intermediate term treasury
|
||||||
|
vblix long-term bond index
|
||||||
|
vlgix long-term government bond
|
||||||
|
vusux long-term treasury
|
||||||
|
vmbix mortgage-backed securities
|
||||||
|
vbipx short-term bond index
|
||||||
|
vsbix short-term government bond
|
||||||
|
vtspx short-term inflation protected
|
||||||
|
vfirx short-term treasury
|
||||||
|
vbtlx total bond market index
|
||||||
|
|
||||||
|
Investment Grade:
|
||||||
|
|
||||||
|
vicbx intermediate-term corporate bond
|
||||||
|
vfidx intermediate-term investment grade
|
||||||
|
vlcix long-term corporate bond
|
||||||
|
vwetx long-term investment grade
|
||||||
|
vstbx short-term corporate bond
|
||||||
|
vfsix short-term investment grade
|
||||||
|
vusfx ultra-short term bond
|
||||||
|
|
||||||
|
Below Investment Grade:
|
||||||
|
|
||||||
|
vweax high-yield corporate
|
||||||
|
|
||||||
|
Floating rate:
|
||||||
|
|
||||||
|
ffrhx Fidelity floating rate high income fund
|
||||||
|
|
||||||
|
Mortgage securities:
|
||||||
|
|
||||||
|
fmsfx Fidelity mortgage securities fund (basically the same as vmbix.ret)
|
||||||
|
|
||||||
|
Non-US:
|
||||||
|
|
||||||
|
fgbfx Fidelity global bond fund
|
||||||
|
finux Fidelity International Bond Fund
|
||||||
|
fnmix Fidelity new markets income fund
|
||||||
|
fghnx Fidelity global high income fund
|
||||||
|
|
||||||
|
Currencies:
|
||||||
|
|
||||||
|
FXA Australian Dollar
|
||||||
|
FXB British Pound
|
||||||
|
FXC Canadian Dollar
|
||||||
|
FXCH Chinese Renminbi
|
||||||
|
FXE Euro
|
||||||
|
FXY Japanese Yen
|
||||||
|
FXSG Singapore Dollar
|
||||||
|
FXS Swedish Krona
|
||||||
|
FXF Swiss Frank
|
||||||
|
UUP US Dollar Index
|
||||||
|
|
||||||
|
Commodities
|
||||||
|
|
||||||
|
oil Oil
|
||||||
|
uso US Oil
|
||||||
|
gld Gold
|
||||||
|
slv Silver
|
||||||
|
ung Natural Gas
|
||||||
|
djp Dow Jones-UBS Commodity Index
|
||||||
|
dba Agriculture
|
||||||
|
gsg GSCI commodity index
|
||||||
|
dbb Base metals
|
||||||
|
gltr precious metals
|
||||||
|
jo Coffee
|
||||||
|
jjg Grains
|
||||||
|
jjc Copper
|
||||||
|
corn Corn
|
||||||
|
weat Wheat
|
||||||
|
nib Cocoa
|
||||||
|
cow Livestock
|
||||||
|
ptm Platinum
|
||||||
|
bal Cotton
|
||||||
|
|
||||||
|
International Equity
|
||||||
|
ewg Germany
|
||||||
|
ewq France
|
||||||
|
ewh Hong Kong
|
||||||
|
ewi Italy
|
||||||
|
ewn Netherlands
|
||||||
|
eww Mexico
|
||||||
|
ewm Malaysia
|
||||||
|
ews Singapore
|
||||||
|
ewp Spain Capped
|
||||||
|
ewl Switzerland Capped
|
||||||
|
ewd Sweden
|
||||||
|
ewu United Kingdom
|
||||||
|
ewa Australia
|
||||||
|
ewk Belgium Capped
|
||||||
|
ewo Austria Capped
|
||||||
|
ewc Canada
|
||||||
|
ewy South Korea
|
||||||
|
ewt Taiwan
|
||||||
|
ewz Brazil
|
||||||
|
ezu Eurozone
|
||||||
|
iev Europe
|
||||||
|
|
||||||
|
US Sectors
|
||||||
|
iyf Financials
|
||||||
|
iyz Telecommunications
|
||||||
|
iym Basic Materials
|
||||||
|
iye Energy
|
||||||
|
iyc Consumer Services
|
||||||
|
iyk Consumer Goods
|
||||||
|
iyh Healthcare
|
||||||
|
iyg Financial Services
|
||||||
|
iyj Industrials
|
||||||
|
iyr Real Estate
|
||||||
|
idu Utilities
|
||||||
|
iyw Technology
|
||||||
|
kie Insurance
|
||||||
|
ibb Biotech
|
||||||
|
xbi Biotech
|
||||||
|
|
||||||
|
US Sectors
|
||||||
|
xly Discretionary
|
||||||
|
xlp Staples
|
||||||
|
xle Energy
|
||||||
|
xlf Financials
|
||||||
|
xlv Health Care
|
||||||
|
xli Industrials
|
||||||
|
xlb Materials
|
||||||
|
xlre Real Estate
|
||||||
|
xlk Technology
|
||||||
|
xlu Utilities
|
||||||
|
|
||||||
|
US Style Box
|
||||||
|
ijr S&P Small-Cap
|
||||||
|
ijs S&P Small-Cap 600 Value
|
||||||
|
ijt S&P Small-Cap 600 Growth
|
||||||
|
ijh S&P Mid-Cap
|
||||||
|
ijk S&P Mid-Cap 400 Growth
|
||||||
|
ijj S&P Mid-Cap 400 Value
|
||||||
|
ivv S&P 500
|
||||||
|
ivw S&P 500 Growth
|
||||||
|
ive S&P 500 Value
|
||||||
|
|
||||||
|
|
||||||
|
Common index ETFs (fundlab augmentations):
|
||||||
|
|
||||||
|
agg US aggregate bond
|
||||||
|
vt total world stock
|
||||||
|
vwo emerging markets stock
|
||||||
|
vnq US real estate (REITs)
|
||||||
|
vea developed markets (ex-US, FTSE)
|
||||||
|
efa developed markets (ex-US, MSCI)
|
||||||
|
iwm US small cap
|
||||||
|
tlt US long-term treasuries (20+ yr)
|
||||||
|
ief US intermediate treasuries (7-10 yr)
|
||||||
|
shv US short-term treasuries
|
||||||
|
bil US t-bills / cash
|
||||||
186
tests/test_fundlab.py
Normal file
186
tests/test_fundlab.py
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
"""fundlab tests: pool parsing, text extraction, classification, EDGAR live.
|
||||||
|
|
||||||
|
Run: .venv/bin/python tests/test_fundlab.py
|
||||||
|
The live EDGAR test needs network + is rate-limited; it degrades to a skip.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from fundlab import edgar, pool # noqa: E402
|
||||||
|
|
||||||
|
PASS, FAIL = 0, 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(name: str, cond: bool, extra: str = "") -> None:
|
||||||
|
global PASS, FAIL
|
||||||
|
if cond:
|
||||||
|
PASS += 1
|
||||||
|
print(f" ok {name}", flush=True)
|
||||||
|
else:
|
||||||
|
FAIL += 1
|
||||||
|
print(f" FAIL {name} {extra}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- pool
|
||||||
|
def test_pool() -> None:
|
||||||
|
print("pool", flush=True)
|
||||||
|
entries = pool.load_pool()
|
||||||
|
check("pool parses many entries", len(entries) > 90, str(len(entries)))
|
||||||
|
bysym = {e.symbol: e for e in entries}
|
||||||
|
check("original entries present",
|
||||||
|
all(s in bysym for s in ("vbtlx", "ewz", "gld", "ijr", "fxe")))
|
||||||
|
check("augmentations present",
|
||||||
|
all(s in bysym for s in ("agg", "vt", "vwo", "vnq", "tlt", "bil")))
|
||||||
|
check("sections preserved",
|
||||||
|
bysym["vbtlx"].section.startswith("Treasury")
|
||||||
|
and bysym["agg"].section.startswith("Common index ETFs"))
|
||||||
|
check("labels carried", "inflation-protected" in bysym["vipix"].label)
|
||||||
|
check("no garbage symbols",
|
||||||
|
all(re.fullmatch(r"\S{1,6}", e.symbol) for e in entries))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- text
|
||||||
|
_HTML = (b"<html><body><h3>Fund Summary</h3><p>Investment Objective</p>"
|
||||||
|
b"<p>The Fund seeks to track the performance of a benchmark index "
|
||||||
|
b"that measures the investment return of large-capitalization "
|
||||||
|
b"stocks.</p><p>Fees and Expenses</p></body></html>")
|
||||||
|
|
||||||
|
|
||||||
|
def test_text_and_objective() -> None:
|
||||||
|
print("text / objective extraction", flush=True)
|
||||||
|
txt = edgar.to_text(_HTML)
|
||||||
|
check("to_text strips tags",
|
||||||
|
"Fund Summary" in txt and "<p>" not in txt and "Investment Objective" in txt)
|
||||||
|
doc = ("Random cover page text. "
|
||||||
|
"Vanguard 500 Index Fund\nProspectus\nInvestment Objective\n"
|
||||||
|
"The Fund seeks to track the performance of a benchmark index "
|
||||||
|
"that measures the investment return of large-capitalization "
|
||||||
|
"stocks.\nFees and Expenses\nmore text about Vanguard 500 "
|
||||||
|
"Index Fund share classes.")
|
||||||
|
obj = edgar.extract_objective(doc, "Vanguard 500 Index Fund")
|
||||||
|
check("objective extracted",
|
||||||
|
obj is not None and obj.startswith("The Fund seeks to track")
|
||||||
|
and "large-capitalization stocks" in obj, repr(obj))
|
||||||
|
check("objective is bounded",
|
||||||
|
obj is not None and len(obj) < 300, repr(obj and len(obj)))
|
||||||
|
doc2 = ("Fund ABC\nThe ETF seeks to invest at least 80% of assets in "
|
||||||
|
"US investment grade debt securities of emerging markets.")
|
||||||
|
obj2 = edgar.extract_objective(doc2, "Fund ABC")
|
||||||
|
check("variant: 'The ETF seeks to ...' on the cover",
|
||||||
|
obj2 is not None and "debt securities" in obj2 and "US" in obj2,
|
||||||
|
repr(obj2))
|
||||||
|
check("no objective -> None",
|
||||||
|
edgar.extract_objective("no fund here at all", "Ghost Fund") is None)
|
||||||
|
# a document that merely MENTIONS the fund deep in its body (family
|
||||||
|
# filings, funds investing in it) must not yield that fund's objective:
|
||||||
|
# the name is not on the cover (within the first COVER_GATE chars)
|
||||||
|
body = ("Section %d discusses portfolio construction, risk management "
|
||||||
|
"and liquidity. ")
|
||||||
|
filler = ("XYZ Growth Fund\nProspectus\nInvestment Objective\nThe Fund "
|
||||||
|
"seeks to provide total return while generating moderate "
|
||||||
|
"current income.\n" +
|
||||||
|
"".join(body % i for i in range(40)) +
|
||||||
|
"Comparison: similar to Vanguard 500 Index Fund.\n")
|
||||||
|
check("mention-only doc -> None",
|
||||||
|
edgar.extract_objective(filler, "Vanguard 500 Index Fund") is None)
|
||||||
|
# the same doc with the name on the cover IS about the fund
|
||||||
|
oncover = ("Vanguard 500 Index Fund\nProspectus\nInvestment Objective\n"
|
||||||
|
"The Fund seeks to track large-capitalization stocks.\n" +
|
||||||
|
"".join(body % i for i in range(40)))
|
||||||
|
check("name on cover -> extracted",
|
||||||
|
(edgar.extract_objective(oncover, "Vanguard 500 Index Fund")
|
||||||
|
or "").startswith("The Fund seeks to track"))
|
||||||
|
# a leveraged ETF's cover cites the underlying fund's name; the name
|
||||||
|
# is a reference, not a title -> not our fund's document
|
||||||
|
doc4 = ("Acme 2X Leveraged QQQ Daily ETF\nProspectus\nThe Fund seeks\n"
|
||||||
|
"daily investment results that are 200% of the performance of\n"
|
||||||
|
"the Invesco QQQ Trust. The Fund seeks to achieve daily\n"
|
||||||
|
"investment results equal to two times the performance of the\n"
|
||||||
|
"index, before fees and expenses.")
|
||||||
|
check("underlying reference on cover -> None",
|
||||||
|
edgar.extract_objective(doc4, "Invesco QQQ Trust") is None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- classify
|
||||||
|
def test_classify() -> None:
|
||||||
|
print("category classification", flush=True)
|
||||||
|
c = edgar.classify_category
|
||||||
|
check("US index fund -> equity",
|
||||||
|
c("The Fund seeks to track large-capitalization stocks.",
|
||||||
|
"Vanguard 500 Index Fund") == "equity")
|
||||||
|
check("aggregate bond -> fixed_income",
|
||||||
|
c("The Fund seeks to track the performance of an index of US "
|
||||||
|
"investment grade debt securities.", "iShares Core US Aggregate Bond ETF")
|
||||||
|
== "fixed_income")
|
||||||
|
check("balanced -> mixed",
|
||||||
|
c("The Fund invests in a combination of equity and debt "
|
||||||
|
"securities.", "Example Balanced Fund") == "mixed")
|
||||||
|
check("gold -> alternatives",
|
||||||
|
c("The Fund seeks to track the price of gold bullion.",
|
||||||
|
"SPDR Gold Shares") == "alternatives")
|
||||||
|
check("money market -> money_market",
|
||||||
|
c("The Fund seeks to maintain stability of principal. It "
|
||||||
|
"invests in money market instruments.", "XX Money Market Fund")
|
||||||
|
== "money_market")
|
||||||
|
check("unknown -> other", c("The Fund does unusual things.", "Mystery") == "other")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- edgar live
|
||||||
|
def test_edgar_live() -> None:
|
||||||
|
print("EDGAR live (network)", flush=True)
|
||||||
|
try:
|
||||||
|
name = None
|
||||||
|
meta = json.loads((pathlib.Path("~/prog/fin/stocks").expanduser()
|
||||||
|
/ "vtsax.json").read_text())["chart"]["result"][0]["meta"]
|
||||||
|
name = meta.get("longName")
|
||||||
|
if not name:
|
||||||
|
raise urllib.error.URLError("no local vtsax.json")
|
||||||
|
res = edgar.fetch_fund(name, ticker="vtsax", max_docs=8)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" SKIP (no network / data): {type(e).__name__}: {e}")
|
||||||
|
return
|
||||||
|
check("vtsax objective fetched",
|
||||||
|
res is not None and "seeks to" in res["objective"]
|
||||||
|
and "stock market" in res["objective"].lower(),
|
||||||
|
repr(res and res.get("objective")))
|
||||||
|
check("vtsax classified equity",
|
||||||
|
res is not None and res["category"] == "equity",
|
||||||
|
repr(res and res.get("category")))
|
||||||
|
check("provenance recorded",
|
||||||
|
res is not None and res["url"].startswith("https://www.sec.gov")
|
||||||
|
and res["form"], repr(res))
|
||||||
|
|
||||||
|
|
||||||
|
def test_curated() -> None:
|
||||||
|
print("curated", flush=True)
|
||||||
|
import fundlab.fundinfo as fi
|
||||||
|
cur = fi.load_curated()
|
||||||
|
check("curated file loads", len(cur) >= 10, str(len(cur)))
|
||||||
|
check("curated entries well-formed",
|
||||||
|
all(set(v) >= {"objective", "category"} and
|
||||||
|
v["category"] in ("equity", "fixed_income", "mixed",
|
||||||
|
"alternatives", "money_market", "other")
|
||||||
|
for v in cur.values()))
|
||||||
|
check("curated objectives look right",
|
||||||
|
all("seeks" in v["objective"].lower() for v in cur.values()))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
test_pool()
|
||||||
|
test_text_and_objective()
|
||||||
|
test_classify()
|
||||||
|
test_curated()
|
||||||
|
test_edgar_live()
|
||||||
|
print(f"\n{PASS} passed, {FAIL} failed")
|
||||||
|
return 1 if FAIL else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Loading…
Reference in New Issue
Block a user