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}
diff --git a/fundlab/pool/benchmarks.txt b/fundlab/pool/benchmarks.txt
new file mode 100644
index 0000000..0803aaf
--- /dev/null
+++ b/fundlab/pool/benchmarks.txt
@@ -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
diff --git a/tests/test_fundlab.py b/tests/test_fundlab.py
new file mode 100644
index 0000000..330b927
--- /dev/null
+++ b/tests/test_fundlab.py
@@ -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"Fund Summary
Investment Objective
"
+ b"The Fund seeks to track the performance of a benchmark index "
+ b"that measures the investment return of large-capitalization "
+ b"stocks.
Fees and Expenses
")
+
+
+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 "" 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())