- extract_strategy(): finds the 'Principal Investment Strategies' / 'main investment strategies' section, scores candidates (strategy prose +3, Q&A heading +2, TOC -5, risk subheading -5, stop-heading -2), truncates at the next section heading; falls back to the prose after the objective sentence when no heading exists - fundinfo --strategy [--refresh]: populates the strategy field of funds.json from each fund's EDGAR document - funds.json now carries objective + strategy for 20 funds (the 9 curated index funds have no strategy: their objective is the strategy)
167 lines
5.8 KiB
Python
167 lines
5.8 KiB
Python
"""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 extract_strategy, fetch_fund, sec_get, to_text
|
|
|
|
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 (required unless --strategy)")
|
|
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("--strategy", action="store_true",
|
|
help="fill in the 'strategy' field for funds.json entries "
|
|
"that have an EDGAR url (no objective fetching)")
|
|
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)
|
|
|
|
if args.strategy:
|
|
if not FUNDS_FILE.exists():
|
|
print("no funds.json", file=sys.stderr)
|
|
return 1
|
|
funds = json.loads(FUNDS_FILE.read_text())
|
|
rc = 0
|
|
for sym, rec in funds.items():
|
|
url = rec.get("url")
|
|
if not url:
|
|
continue
|
|
if rec.get("strategy") and not args.refresh:
|
|
print(f"= {sym}: strategy cached")
|
|
continue
|
|
try:
|
|
txt = to_text(sec_get(url))
|
|
except Exception as e:
|
|
print(f"= {sym}: fetch failed: {type(e).__name__}", file=sys.stderr)
|
|
rc = 1
|
|
continue
|
|
strat = extract_strategy(txt)
|
|
if strat:
|
|
rec["strategy"] = strat
|
|
print(f"= {sym}: strategy {len(strat)} chars")
|
|
else:
|
|
print(f"= {sym}: no strategy section found", file=sys.stderr)
|
|
FUNDS_FILE.write_text(json.dumps(funds, indent=2))
|
|
print(f"\n{FUNDS_FILE}")
|
|
return rc
|
|
|
|
if not args.strategy and not args.symbols:
|
|
ap.error("symbols are required (or use --strategy)")
|
|
|
|
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())
|