"""CEF stage 2b: per-share financial highlights from the annual report. For each CEF, fetch the recent N-CSR/N-CSRS submissions and parse the per-share "Financial Highlights" table. CEF tables come in two families (verified on EMO / TWN): Less distributions to common shareholders from: (Franklin style) Dividends ... Return of capital ... Total distributions to common shareholders ... Less Distributions to Stockholders from: (classic CEF) Net investment income ... Net realized gains ... Total distributions to stockholders ... Gotchas handled: - Blanks are dropped when the table flattens: a short row's values can sit in ANY columns. EMO's ROC row (3.42, 0.93) belongs to columns 0 and 4, not 0 and 1; TWN's total-distributions row has 4 values for 5 columns (a zero year dropped). - The robust total per column comes from the NAV IDENTITY, not the printed total row: dist[c] = NAV_beg[c] + ops[c] + captch[c] - NAV_end[c]. The printed sub-rows (NII / gains / ROC) are then placed combinatorially against those derived totals. - The first "Net asset value, beginning of year" anchor in the doc is the most recent 5-year table (a continuation table for older years follows it). A "share" proximity guard skips dollar-basis mentions. - Annual vs semi-annual: try the 3 most recent shareholder reports and keep the one that parses with the most columns (annual = 5). Also captured (optional lines): market value end of year (-> discount series), leverage (debt + preferred vs net assets), tax-paying flag. Run: python -m fundlab.cef_annual [SYM ...] (default: stage-2a shortlist) Output: fundlab/cef_annual.json (cached per fund in cef_cache/) """ from __future__ import annotations import json import re from pathlib import Path import numpy as np from fundlab import edgar HERE = Path(__file__).parent CACHE = HERE / "cef_cache" OUT = HERE / "cef_annual.json" CHARACTER = HERE / "cef_character.json" # distribution-character fractions for the placement score (1 = fully # taxable-account favorable) FRAC_NII = 0.10 # ordinary income (equity-fund NII is partly QD) FRAC_GAINS = 0.70 # mix of STCG/LTCG - long-term equity funds skew LT FRAC_ROC = 1.0 # defers to the investor's own LTCG on a >1y sale FRAC_NII_MUNI = 1.0 # tax-exempt interest NUMTOK = re.compile(r"\$?\(?(?:\d[\d,]*(?:\.\d+)?|\.\d+)\)?") FOOTNOTE = re.compile(r"\([a-z0-9]{1,3}\)") def _nums(tokens: list[str]) -> list[float | None]: out = [] for t in tokens: t = t.strip().rstrip(":") if not re.fullmatch(r"\$?\(?(?:\d[\d,]*(?:\.\d+)?|\.\d+)\)?", t): out.append(None) continue neg = t.startswith("(") v = float(t.strip("$()").replace(",", "")) out.append(-v if neg else v) return out def _row(block: str, label_rx: str, span: int = 700) -> list[float | None]: """Numeric row following the first label match in the block.""" rm = re.search(label_rx, block, re.I) if not rm: return [] tail = block[rm.end(): rm.end() + span] tail = re.sub(r"\(\s+", "(", tail) tail = re.sub(r"\s+\)", ")", tail) tail = re.sub(r"(? list: """Footnote integers (bare 1-9) can inflate a row (RMT '1 , 2', NICHX/VCRDX leading '1'). Dropping them is only accepted when it lands exactly on ncols; the per-column arithmetic re-validates.""" if len(row) <= ncols: return row kept = [v for v in row if not (v is not None and v == int(v) and 0 < abs(v) < 10)] return kept if len(kept) == ncols else row def _perms4(k): """Ordered assignments of k values to distinct slots of 4.""" for a in range(4): if k == 1: yield (a,) continue for b in range(4): if b == a: continue if k == 2: yield (a, b) continue for c in range(4): if c != a and c != b: yield (a, b, c) def _align_trans_row(vals: list, has_roc: bool, has_gains: bool) -> tuple | None: """Align one transposed data row (newest period first). v0=nav_beg v1=nii v2=gains v3=ops, then the distribution sub-values and total. Three label layouts: I1 (default, Voya w/ dropped ROC): v4=divs v5=gdist v6=dtot I3 (STEW: ROC label, no gains label): v4=divs v5=roc v6=dtot I2 (Voya w/ non-zero ROC value): v4=divs v5=gdist v6=roc v7=dtot Zero-dropped columns make this ambiguous - resolve by arithmetic: divs+gdist+roc == dtot AND nav_end == nav_beg+ops+accretion-dtot. Positive outflow convention (transposed tables print positives).""" if len(vals) < 7: return None nav_b = abs(vals[0]) # NII and gains are SIGNED (loss years are negative) - the ops row # is their signed sum; only NAV magnitudes are always positive nii, gains, ops = vals[1], vals[2], vals[3] if abs(nii + gains - ops) > 0.02 * max(1.0, abs(ops)) + 0.02: return None interps = [] if has_roc and not has_gains: interps.append((4, None, 6, 5, 7)) # divs, gdist=0, dtot, roc, next interps.append((4, 5, 6, None, 7)) if has_roc: interps.append((4, 5, 7, 6, 8)) strict_fb = None for di, gi, ti, ri, nxt in interps: if len(vals) < max(ti, ri or 0, gi or 0) + 1: continue divs = abs(vals[di]) gdist = 0.0 if gi is None else abs(vals[gi]) dtot = abs(vals[ti]) roc = 0.0 if ri is None else abs(vals[ri]) if abs(divs + gdist + roc - dtot) > 0.03: continue rest = vals[nxt:] if not rest: # table ends at the total distributions row (PDX) return (nav_b, divs, gdist, roc, dtot, None, None) for acc in [0.0] + list(rest): nav_e = nav_b + ops + abs(acc) - dtot for i, x in enumerate(rest): if abs(x - nav_e) <= 0.03: mkt_e = rest[i + 1] if i + 1 < len(rest) else None return (nav_b, divs, gdist, roc, dtot, nav_e, mkt_e) # dist arithmetic is exact but no nav_end pin - keep as fallback if strict_fb is None and abs(divs + gdist + roc - dtot) <= 0.011: strict_fb = (nav_b, divs, gdist, roc, dtot, None, None) # slot placement: some components dropped entirely (JCE: divs + dtot # only, tail = nav_e/mkt_e). Assign the post-ops values to the # (divs, gdist, dtot, roc) slots, zeros implied, and require # divs+gdist+roc == dtot plus the nav identity on the tail. for m in (2, 1, 0): tail = vals[-m:] if m else [] dvals = vals[4: len(vals) - m] if not (1 <= len(dvals) <= 3): continue for perm in _perms4(len(dvals)): slots = [0.0, 0.0, 0.0, 0.0] for s, v in zip(perm, dvals): if slots[s]: break slots[s] = abs(v) else: sdivs, sgdist, sdtot, sroc = slots if sdtot and abs(sdivs + sgdist + sroc - sdtot) \ <= 0.02 * max(1.0, sdtot) + 0.02: nav_e = None if tail: pred = nav_b + ops - sdtot nav_e = next( (abs(x) for x in tail if abs(abs(x) - pred) <= 0.05), None) if nav_e is None: continue mkt_e = tail[-1] if tail else None return (nav_b, sdivs, sgdist, sroc, sdtot, nav_e, mkt_e) return strict_fb def _transposed_rows(after: str, header_end: int, span: int) -> list: """Data rows (list of value lists) after a transposed header. Handles dash dates (Voya MM-DD-YY) and slash dates (PIMCO/Nuveen MM/DD/YYYY).""" dates = [dm for dm in re.finditer(r"\d{1,2}[-/]\d{1,2}[-/]\d{2,4}", after)] dates = [dm for dm in dates if len(dm.group(0)) >= 8] rows = [] for k, dm in enumerate(dates): stop = (dates[k + 1].start() if k + 1 < len(dates) else min(len(after), dm.end() + span)) seg = after[dm.end():stop] seg = re.sub(r"\(\s+", "(", seg) seg = re.sub(r"\s+\)", ")", seg) seg = re.sub(r"(?= 7: rows.append(vals) return rows def _section_split(after: str, span: int): """Split a multi-fund consolidated region into (header, rows) sections. A section break is a text run of >= 3 words (a fund name) followed by date rows. Returns [(header, rows), ...].""" parts = [] pos = 0 cur_header = "" cur = [] date_re = re.compile(r"\d{1,2}[-/]\d{1,2}[-/]\d{2,4}") while pos < len(after): dm = date_re.search(after, pos) if not dm: break between = after[pos:dm.start()] words = [w for w in between.split() if not NUMTOK.fullmatch(w) and not re.fullmatch(r"[()$%,.\-]+", w) and re.search(r"[A-Za-z]", w)] tickerish = (1 <= len(words) <= 2 and all( re.fullmatch(r"[A-Za-z]{1,8}", w) for w in words)) if words and cur: # text run before a date with existing rows: section break if len(words) >= 3 or tickerish: parts.append((cur_header, cur)) cur_header = " ".join(words) cur = [] # gather this date row stop_m = date_re.search(after, dm.end() + 1) stop = stop_m.start() if stop_m else min(len(after), dm.end() + span) seg = after[dm.end():stop] seg = re.sub(r"\(\s+", "(", seg) seg = re.sub(r"\s+\)", ")", seg) seg = re.sub(r"(?= 7: cur.append(vals) pos = stop if cur: parts.append((cur_header, cur)) return parts def _pick_section(parts, name: str, ticker: str): """Choose the section belonging to THIS fund in a consolidated report (PIMCO/Nuveen report several funds in one table).""" if not parts: return None if len(parts) == 1: return parts[0][1] name_words = {w.lower() for w in re.findall(r"[A-Za-z0-9]+", name) if len(w) > 2 and w.lower() not in ("fund", "funds", "inc", "company", "llc", "corp", "corporation", "series", "trust", "the", "of", "and")} best, best_score = None, 0 for header, rows in parts: hw = {w.lower() for w in re.findall(r"[A-Za-z0-9]+", header)} score = len(hw & name_words) if ticker and re.search(r"\b" + re.escape(ticker.upper()) + r"\b", header, re.I): score += 5 if score > best_score: best, best_score = rows, score if best is None or best_score < 2: return parts[0][1] # no identifying header: first section return best def parse_transposed(t: str, name: str = "", ticker: str = "") -> dict | None: def _trans_from(m): # the header row ("Year(s) [or Period] Ended") may sit before the # label list (PIMCO) or after it (Voya) - take the one closest # before the first data row window = t[max(0, m.start() - 500): m.start() + 9000] datere = r"\d{1,2}[-/]\d{1,2}[-/]\d{2,4}" first_date = re.search(datere, window) if not first_date: return None hm = None for h in re.finditer(r"years? (?:or period )?ended", window, re.I): if h.start() < first_date.start(): hm = h if hm is None: # no "Year(s) ended" header (Nuveen JCE): start at the anchor - # the label list before it carries no dates after = window[m.start() - max(0, m.start() - 500):] else: after = window[hm.end():] has_roc = bool(re.search(r"(?:tax )?return of capital", window, re.I)) has_gains = bool(re.search(r"from net realized gains", window, re.I)) parts = _section_split(after, 300) rows = _pick_section(parts, name, ticker) if not rows or len(rows) < 3: return None cols = [_align_trans_row(v, has_roc, has_gains) for v in rows] # the oldest 1-2 periods are sometimes structurally different # (reorganization years) - tolerate trailing failures bad = [i for i, c in enumerate(cols) if c is None] if bad and any(i < len(cols) - 2 for i in bad): return None if sum(c is None for c in cols) >= 3: return None rows = [v for v, c in zip(rows, cols) if c is not None] cols = [c for c in cols if c is not None] if len(cols) < 3: return None mkt = [c[6] for c in cols] out = { "ncols": len(cols), "nav_beg": [c[0] for c in cols], "nav_end": ([c[5] for c in cols] if all(c[5] is not None for c in cols) else []), "mkt_end": mkt if all(x is not None for x in mkt) else [], "dist_tot": [c[4] for c in cols], "divs": [c[1] for c in cols], "gains": [c[2] for c in cols], "roc": [c[3] for c in cols], "ops": [abs(v[3]) for v in rows], } tot5 = sum(out["divs"]) + sum(out["gains"]) + sum(out["roc"]) if tot5 > 0: out["share_div"] = sum(out["divs"]) / tot5 out["share_gains"] = sum(out["gains"]) / tot5 out["share_roc"] = sum(out["roc"]) / tot5 return out for m in re.finditer( r"net asset value(?: per (?:common )?share)?,? beginning of " r"(?:the )?(?:year|period)", t, re.I): h = _trans_from(m) if h: return h return None def parse_columnar(t: str, name: str = "", ticker: str = "") -> dict | None: """Columnar value stream (SRH/STEW): a LABEL LIST (no values on the label rows), then a period header, then one contiguous value run per label. Rows are identified arithmetically, not by position.""" anchor_rx = (r"net asset value(?: per (?:common )?share)?,? beginning of " r"(?:the )?(?:year|period)") for m in re.finditer(anchor_rx, t, re.I): region = t[m.start(): m.start() + 9000] toks = re.findall(r"\S+", region) # the NAV-begin row = the longest run of numeric tokens ("$" is # transparent); period-header year runs (2025 2024 ...) are # rejected by magnitude run, run_toks, best = [], [], (None, 0) for x in toks: if x == "$": if run: run_toks.append(x) continue if NUMTOK.fullmatch(x) and not re.fullmatch(r"\(\d{1,2}\)", x): run.append(x) run_toks.append(x) else: vals = [float(v.strip("$()").replace(",", "")) for v in run] if len(run) >= 4 and best[1] < len(run) \ and all(abs(v) < 100 for v in vals): best = (run_toks[:], len(run)) run, run_toks = [], [] vals = [float(v.strip("$()").replace(",", "")) for v in run] if len(run) >= 4 and best[1] < len(run) \ and all(abs(v) < 100 for v in vals): best = (run_toks[:], len(run)) if best[0] is None or best[1] < 4: continue ncols = best[1] stream = [x for x in best[0] if x != "$" and NUMTOK.fullmatch(x) and not re.fullmatch(r"\(\d{1,2}\)", x)] stream = stream + [x for x in toks[toks.index(best[0][0]) + len(best[0]):] if x != "$" and NUMTOK.fullmatch(x) and not re.fullmatch(r"\(\d{1,2}\)", x)] rows = [_nums(stream[k: k + ncols]) for k in range(0, len(stream) - ncols + 1, ncols)] if len(rows) < 4: continue nav_b = rows[0] # ops row: nii=rows[1], gains=rows[2] in the standard order; # verify nii+gains==ops and locate ops (rows[3], or scan) nii, gains = rows[1], rows[2] ops = None for cand in range(2, min(6, len(rows) - 2)): r = rows[cand] err = sum(abs((_get(nii, c) + _get(gains, c)) - _get(r, c)) for c in range(ncols)) if err <= 0.05 * ncols: ops = r ops_i = cand break if ops is None: continue # dtot row: some later row t with divs/roc before it satisfying # divs (+roc) == dtot per column best = None for t_ in range(ops_i + 2, len(rows) - 1): dtot = rows[t_] window_rows = rows[ops_i + 1: t_] for extra in (0, 1): need_cols = len(window_rows) - extra if need_cols < 1: continue # try every way to pick divs (+ optional roc) from the # window rows so the column arithmetic holds from itertools import combinations for pick in combinations(range(len(window_rows)), 2 if extra else 1): ok = True dv = [0.0] * ncols rc = [0.0] * ncols for j, pi in enumerate(pick): w = window_rows[pi] vec = dv if j == 0 else rc for c in range(ncols): vec[c] += abs(_get(w, c)) for c in range(ncols): d = abs(_get(dtot, c)) if abs(dv[c] + rc[c] - d) > 0.02 * max(1.0, d) + 0.02: ok = False break if ok: best = (t_, dv, rc, dtot) break if best: break if best: break if best is None: continue t_, dv, rc, dtot = best # nav_e: first row after dtot close to nav_b + ops - dtot # (capital transactions are usually small) nav_e = mkt_e = None pred = [_get(nav_b, c) + _get(ops, c) - abs(_get(dtot, c)) for c in range(ncols)] for e_ in range(t_ + 1, len(rows)): r = rows[e_] close = sum(1 for c in range(ncols) if abs(_get(r, c) - pred[c]) < 3.0) if close >= ncols - 1: nav_e = r if e_ + 1 < len(rows): mkt_e = rows[e_ + 1] break out = { "ncols": ncols, "nav_beg": list(nav_b), "nav_end": [abs(_get(nav_e, c)) for c in range(ncols)] if nav_e is not None else [], "mkt_end": [abs(_get(mkt_e, c)) for c in range(ncols)] if mkt_e is not None else [], "dist_tot": [abs(_get(dtot, c)) for c in range(ncols)], "divs": list(dv), "gains": [0.0] * ncols, "roc": list(rc), "ops": [abs(_get(ops, c)) for c in range(ncols)], } tot5 = sum(out["divs"]) + sum(out["gains"]) + sum(out["roc"]) if tot5 > 0: out["share_div"] = sum(out["divs"]) / tot5 out["share_gains"] = sum(out["gains"]) / tot5 out["share_roc"] = sum(out["roc"]) / tot5 return out return None def parse_highlights(t: str, name: str = "", ticker: str = "") -> dict | None: """Parse the per-share highlights table. Returns None on failure. name/ticker identify THIS fund in a consolidated report.""" trans_name = name trans_ticker = ticker # collapse whitespace: CEF HTML tables flatten with one