diff --git a/fundlab/RESEARCH.md b/fundlab/RESEARCH.md
index 75c873a..5f3b6f7 100644
--- a/fundlab/RESEARCH.md
+++ b/fundlab/RESEARCH.md
@@ -785,8 +785,54 @@ LENDX (0.69, maxDD -12%, all scenarios ~flat/positive), EMF/AEF/HQL/
HQH/IGD equity-character 0.55-0.75; EMO/NML/SRV/KYN/TYG char
0.29-0.39 (income-heavy -> IRA-tilted, ROC share pending the 1099).
-**Remaining (stage 2b+):** per-fund N-2ASR/N-CSR distribution
-character (the income/gains/ROC split is decisive for the EMO
-family), NPORT NAV-per-share -> quarterly discount series, leverage
-from NPORT financials, BDC flag via form history, tender-offer
-status; app CEF tab.
+**Stage 2b** (fundlab/cef_annual.py): per-share financial-highlights
+parser for the annual shareholder report (N-CSR/N-CSRS). 16/50 of the
+shortlist parsed (4 fund-family layouts verified: Franklin/EMO,
+classic CEF/TWN, abrdn/HQL, Korea/KF; the rest reject conservatively
+- mis-parse is worse than no data).
+
+Parser lessons (each one cost a debug cycle):
+- CEF HTML tables flatten with one
per line: labels split across
+ newlines ("Net\nasset value, beginning of year"). Collapse
+ whitespace BEFORE any regex.
+- CEF tables DROP ZERO COLUMNS when flattened: a short row's values
+ can sit in ANY subset of columns (TWN: gains in 4 of 5 cols,
+ NII-divs in the OTHER two; EMO: ROC in cols 0 and 4; KF: accretion
+ in cols 1-4). A left-align or greedy per-row placement is WRONG.
+- Robust solution: one JOINT backtracking search placing every short
+ row (captch rows first - they feed the NAV identity - then the
+ distribution rows) so the per-column arithmetic holds:
+ printed total complete -> divs+gains+roc == total; printed total
+ short -> divs+gains+roc == NAV_beg+ops+captch-NAV_end. Reject
+ (return None) when no assignment reconciles.
+- The printed total row is authoritative when complete; sign varies
+ by fund (EMO positive, TWN negative) - normalize by majority vote.
+- "Net asset value, beginning of **period**" (semiannual / LENDX) and
+ "per common share" (abrdn) label variants.
+- "Total dividends and distributions to stockholders" total label
+ contains "dividends" - scope sub-row search to before the total row.
+- "Less distributions from:" (Franklin, no "to") anchor variant.
+- Prefer the 5-column ANNUAL table over a 6-column semiannual one.
+- Submission .txt (full filing, one fetch) is the robust document
+ source; primaryDocument 404s on older filings.
+
+Verified results (div/gains/ROC 5y share, FY-end discount, tenders):
+- TWN 6/94/0, -15.0% - 94% capital-gains distributions (LTCG
+ character for a long-term EM fund) + persistent 15% discount =
+ the cleanest taxable CEF of the set.
+- KF 27/72/0, -14.4%; ADX 9/91/0, -5.7% - same story, smaller.
+- EMO 67/0/33, -6.8% (was -18.4% in FY21: the discount NARROWED -
+ the actual tax arb) + 6 tender notices (repurchase program).
+ 81% of the FY25 distribution was ROC - tax-payer CEF, check 1099.
+- AEF 20/0/80, -8.3%; HERZ 0/21/79 (char 0.94 BUT 5y total return
+ only +10%: ROC that large on flat returns = returning your own
+ capital, a sustainability red flag - ROC share alone is not
+ sufficient).
+- LENDX 45/40/14, 40 tender notices (continuous program), maxDD -12%.
+- AOD/AGD 81/0/19 (ordinary NII) at ~par - the IRA-tilted CEFs.
+- Discount series are STABLE (no systematic narrowing) except EMO.
+
+**Remaining:** targeted regex work for the 34 un-parsed families
+(Neuberger SRV/NML/KYN/TYG, Voya IGD/ECAT, Barings MCI/MPV,
+Cornerstone, Virtus Stone Harbor, ...); leverage/tax-paying flags
+(optionally, from the same doc); app CEF tab; BDC confirmation.
diff --git a/fundlab/cef_annual.json b/fundlab/cef_annual.json
new file mode 100644
index 0000000..606f9c2
--- /dev/null
+++ b/fundlab/cef_annual.json
@@ -0,0 +1,1446 @@
+{
+ "LTCFX": {
+ "sym": "LTCFX",
+ "cik": 1496254,
+ "name": "Alternative Strategies Income Fund",
+ "bdc": false,
+ "n_tender": 60,
+ "ticker": "LTAFX",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "DXYZ": {
+ "sym": "DXYZ",
+ "cik": 1843974,
+ "name": "Destiny Tech100 Inc.",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "DXYZ",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "TWN": {
+ "sym": "TWN",
+ "cik": 804123,
+ "name": "TAIWAN FUND INC",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "TWN",
+ "report_filed": "2025-11-05",
+ "ncols": 5,
+ "nav_beg": [
+ 53.78,
+ 39.73,
+ 29.96,
+ 42.83,
+ 28.79
+ ],
+ "nav_end": [
+ 59.13,
+ 53.78,
+ 39.73,
+ 29.96,
+ 42.83
+ ],
+ "mkt_end": [
+ 50.25,
+ 44.73,
+ 31.1,
+ 25.2,
+ 35.83
+ ],
+ "dist_tot": [
+ 7.399999999999999,
+ 0.4399999999999977,
+ 0.0,
+ 2.9199999999999946,
+ 3.3100000000000023
+ ],
+ "divs": [
+ 0.0,
+ 0.0,
+ 0.0,
+ -0.48,
+ -0.38
+ ],
+ "gains": [
+ -7.4,
+ -0.44,
+ 0.0,
+ -2.44,
+ -2.93
+ ],
+ "roc": [
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ "ops": [
+ 11.92,
+ 13.56,
+ 9.73,
+ -9.95,
+ 17.35
+ ],
+ "share_div": 0.061122956645344735,
+ "share_gains": 0.9388770433546558,
+ "share_roc": 0.0,
+ "char_actual": 0.66,
+ "disc_now_approx": -0.1502
+ },
+ "ASA": {
+ "sym": "ASA",
+ "cik": 1230869,
+ "name": "ASA Gold & Precious Metals Ltd",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "ASA",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "PEO": {
+ "sym": "PEO",
+ "cik": 216851,
+ "name": "ADAMS NATURAL RESOURCES FUND, INC.",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "PEO",
+ "report_filed": "2026-02-20",
+ "ncols": 5,
+ "nav_beg": [
+ 24.21,
+ 24.83,
+ 25.85,
+ 19.22,
+ 13.76
+ ],
+ "nav_end": [
+ 24.09,
+ 24.21,
+ 24.83,
+ 25.85,
+ 19.22
+ ],
+ "mkt_end": [
+ 21.74,
+ 21.74,
+ 20.63,
+ 21.8,
+ 16.52
+ ],
+ "dist_tot": [
+ 2.05,
+ 1.77,
+ 1.35,
+ 1.63,
+ 0.91
+ ],
+ "divs": [
+ -0.56,
+ -0.65,
+ -0.65,
+ -0.79,
+ -0.56
+ ],
+ "gains": [
+ -1.49,
+ -1.12,
+ -0.7,
+ -0.84,
+ -0.35
+ ],
+ "roc": [
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ "ops": [
+ 2.03,
+ 1.27,
+ 0.4,
+ 8.37,
+ 6.41
+ ],
+ "share_div": 0.4163424124513619,
+ "share_gains": 0.5836575875486382,
+ "share_roc": 0.0,
+ "char_actual": 0.45,
+ "disc_now_approx": -0.0976
+ },
+ "KF": {
+ "sym": "KF",
+ "cik": 748691,
+ "name": "KOREA FUND INC",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "KF",
+ "report_filed": "2026-08-27",
+ "ncols": 5,
+ "nav_beg": [
+ 30.87,
+ 28.78,
+ 26.52,
+ 28.54,
+ 54.37
+ ],
+ "nav_end": [
+ 87.88,
+ 30.87,
+ 28.78,
+ 26.52,
+ 28.54
+ ],
+ "mkt_end": [
+ 75.21,
+ 26.93,
+ 24.13,
+ 23.14,
+ 24.35
+ ],
+ "dist_tot": [
+ 1.3800000000000097,
+ 0.4499999999999993,
+ -3.552713678800501e-15,
+ 3.3200000000000003,
+ 9.11
+ ],
+ "divs": [
+ -1.38,
+ -0.45,
+ -0.03,
+ 0.0,
+ -2.05
+ ],
+ "gains": [
+ 0.0,
+ 0.0,
+ 0.0,
+ -3.27,
+ -7.06
+ ],
+ "roc": [
+ -0.02,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ "ops": [
+ 58.39,
+ 2.41,
+ 2.17,
+ 1.25,
+ -16.73
+ ],
+ "share_div": 0.2741935483870966,
+ "share_gains": 0.7244039270687233,
+ "share_roc": 0.0014025245441795222,
+ "char_actual": 0.54,
+ "disc_now_approx": -0.1442
+ },
+ "LENDX": {
+ "sym": "LENDX",
+ "cik": 1658645,
+ "name": "Stone Ridge Trust V",
+ "bdc": false,
+ "n_tender": 40,
+ "ticker": "LENDX",
+ "report_filed": "2026-05-08",
+ "ncols": 5,
+ "nav_beg": [
+ 46.09,
+ 46.4,
+ 48.79,
+ 51.23,
+ 57.09
+ ],
+ "nav_end": [
+ 46.13,
+ 46.09,
+ 46.4,
+ 48.79,
+ 51.23
+ ],
+ "mkt_end": [],
+ "dist_tot": [
+ 1.99,
+ 1.85,
+ 1.92,
+ 2.54,
+ 18.28
+ ],
+ "divs": [
+ -1.99,
+ 0.0,
+ 0.0,
+ -2.54,
+ -7.52
+ ],
+ "gains": [
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ -10.76
+ ],
+ "roc": [
+ 0.0,
+ -1.85,
+ -1.92,
+ 0.0,
+ 0.0
+ ],
+ "ops": [
+ 2.03,
+ 1.54,
+ -8.0,
+ -0.47,
+ 0.1
+ ],
+ "share_div": 0.4533483822422874,
+ "share_gains": 0.40481565086531224,
+ "share_roc": 0.1418359668924003,
+ "char_actual": 0.47,
+ "disc_now_approx": null
+ },
+ "EMO": {
+ "sym": "EMO",
+ "cik": 1517518,
+ "name": "ClearBridge Energy Midstream Opportunity Fund Inc.",
+ "bdc": false,
+ "n_tender": 6,
+ "ticker": "EMO",
+ "report_filed": "2026-01-27",
+ "ncols": 5,
+ "nav_beg": [
+ 55.82,
+ 38.75,
+ 35.97,
+ 26.53,
+ 17.13
+ ],
+ "nav_end": [
+ 48.88,
+ 55.82,
+ 38.75,
+ 35.97,
+ 26.53
+ ],
+ "mkt_end": [
+ 45.55,
+ 50.49,
+ 34.5,
+ 30.43,
+ 21.65
+ ],
+ "dist_tot": [
+ 4.23,
+ 3.0,
+ 2.37,
+ 1.92,
+ 1.47
+ ],
+ "divs": [
+ -0.81,
+ -3.0,
+ -2.37,
+ -1.92,
+ -0.54
+ ],
+ "gains": [
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ "roc": [
+ -3.42,
+ 0.0,
+ 0.0,
+ 0.0,
+ -0.93
+ ],
+ "ops": [
+ -2.25,
+ 20.07,
+ 5.11,
+ 11.22,
+ 10.65
+ ],
+ "share_div": 0.6651270207852193,
+ "share_gains": 0.0,
+ "share_roc": 0.33487297921478054,
+ "char_actual": 0.4,
+ "disc_now_approx": -0.0681
+ },
+ "NML": {
+ "sym": "NML",
+ "cik": 1562051,
+ "name": "Neuberger Energy Infrastructure & Income Fund Inc.",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "NML",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "EMF": {
+ "sym": "EMF",
+ "cik": 809708,
+ "name": "TEMPLETON EMERGING MARKETS FUND",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "EMF",
+ "report_filed": "2025-10-30",
+ "ncols": 5,
+ "nav_beg": [
+ 14.81,
+ 13.63,
+ 13.72,
+ 20.09,
+ 17.58
+ ],
+ "nav_end": [
+ 17.27,
+ 14.81,
+ 13.63,
+ 13.72,
+ 20.09
+ ],
+ "mkt_end": [
+ 15.27,
+ 12.81,
+ 11.71,
+ 11.85,
+ 17.89
+ ],
+ "dist_tot": [
+ 0.95,
+ 0.73,
+ 1.13,
+ 1.11,
+ 0.66
+ ],
+ "divs": [
+ -0.59,
+ -0.73,
+ -0.41,
+ -0.41,
+ -0.18
+ ],
+ "gains": [
+ -0.36,
+ 0.0,
+ -0.72,
+ -0.7,
+ -0.48
+ ],
+ "roc": [
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ "ops": [
+ 3.36,
+ 1.86,
+ 1.03,
+ -5.3,
+ 3.15
+ ],
+ "share_div": 0.5065502183406113,
+ "share_gains": 0.4934497816593886,
+ "share_roc": 0.0,
+ "char_actual": 0.4,
+ "disc_now_approx": -0.1158
+ },
+ "PDX": {
+ "sym": "PDX",
+ "cik": 1756908,
+ "name": "PIMCO Dynamic Income Strategy Fund",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "PDX",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "ADX": {
+ "sym": "ADX",
+ "cik": 2230,
+ "name": "ADAMS DIVERSIFIED EQUITY FUND, INC.",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "ADX",
+ "report_filed": "2026-02-20",
+ "ncols": 5,
+ "nav_beg": [
+ 22.64,
+ 20.56,
+ 17.38,
+ 22.5,
+ 20.06
+ ],
+ "nav_end": [
+ 24.72,
+ 22.64,
+ 20.56,
+ 17.38,
+ 22.5
+ ],
+ "mkt_end": [
+ 23.32,
+ 20.2,
+ 17.71,
+ 14.54,
+ 19.41
+ ],
+ "dist_tot": [
+ 1.85,
+ 2.5,
+ 1.3,
+ 1.07,
+ 2.98
+ ],
+ "divs": [
+ -0.16,
+ -0.17,
+ -0.15,
+ -0.18,
+ -0.2
+ ],
+ "gains": [
+ -1.69,
+ -2.33,
+ -1.15,
+ -0.89,
+ -2.78
+ ],
+ "roc": [
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ "ops": [
+ 3.98,
+ 4.62,
+ 4.57,
+ -3.99,
+ 5.59
+ ],
+ "share_div": 0.08865979381443298,
+ "share_gains": 0.9113402061855671,
+ "share_roc": 0.0,
+ "char_actual": 0.65,
+ "disc_now_approx": -0.0566
+ },
+ "HQL": {
+ "sym": "HQL",
+ "cik": 884121,
+ "name": "abrdn Life Sciences Investors",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "HQL",
+ "report_filed": "2025-12-08",
+ "ncols": 5,
+ "nav_beg": [
+ 16.38,
+ 15.0,
+ 15.49,
+ 21.22,
+ 20.25
+ ],
+ "nav_end": [
+ 17.35,
+ 16.38,
+ 15.0,
+ 15.49,
+ 21.22
+ ],
+ "mkt_end": [
+ 15.51,
+ 15.08,
+ 12.47,
+ 13.66,
+ 20.8
+ ],
+ "dist_tot": [
+ 1.82,
+ 1.66,
+ 1.28,
+ 1.47,
+ 1.69
+ ],
+ "divs": [
+ -1.72,
+ -0.69,
+ -0.02,
+ -0.03,
+ -0.42
+ ],
+ "gains": [
+ -0.1,
+ -0.76,
+ -1.26,
+ -1.44,
+ -1.27
+ ],
+ "roc": [
+ 0.0,
+ -0.21,
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ "ops": [
+ 2.79,
+ 3.04,
+ 0.79,
+ -4.26,
+ 2.66
+ ],
+ "share_div": 0.36363636363636365,
+ "share_gains": 0.6098484848484849,
+ "share_roc": 0.026515151515151516,
+ "char_actual": 0.49,
+ "disc_now_approx": -0.1061
+ },
+ "SRV": {
+ "sym": "SRV",
+ "cik": 1400897,
+ "name": "NXG Cushing Midstream Energy Fund",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "SRV",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "KYN": {
+ "sym": "KYN",
+ "cik": 1293613,
+ "name": "Kayne Anderson Energy Infrastructure Fund, Inc.",
+ "bdc": false,
+ "n_tender": 29,
+ "ticker": "KYN",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "RMT": {
+ "sym": "RMT",
+ "cik": 912147,
+ "name": "ROYCE MICRO-CAP TRUST, INC.",
+ "bdc": false,
+ "n_tender": 2,
+ "ticker": "RMT",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "FUND": {
+ "sym": "FUND",
+ "cik": 825202,
+ "name": "SPROTT FOCUS TRUST INC.",
+ "bdc": false,
+ "n_tender": 2,
+ "ticker": "FUND",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "IAE": {
+ "sym": "IAE",
+ "cik": 1385632,
+ "name": "Voya Asia Pacific High Dividend Equity Income Fund",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "IAE",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "NXG": {
+ "sym": "NXG",
+ "cik": 1506488,
+ "name": "NXG NextGen Infrastructure Income Fund",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "NXG",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "IHD": {
+ "sym": "IHD",
+ "cik": 1496292,
+ "name": "Voya Emerging Markets High Dividend Equity Fund",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "IHD",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "HQH": {
+ "sym": "HQH",
+ "cik": 805267,
+ "name": "abrdn Healthcare Investors",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "HQH",
+ "report_filed": "2025-12-08",
+ "ncols": 5,
+ "nav_beg": [
+ 20.33,
+ 18.84,
+ 19.36,
+ 25.47,
+ 24.04
+ ],
+ "nav_end": [
+ 19.7,
+ 20.33,
+ 18.84,
+ 19.36,
+ 25.47
+ ],
+ "mkt_end": [
+ 18.46,
+ 18.62,
+ 15.55,
+ 17.28,
+ 25.57
+ ],
+ "dist_tot": [
+ 2.24,
+ 2.04,
+ 1.61,
+ 1.83,
+ 2.06
+ ],
+ "divs": [
+ -2.11,
+ -0.56,
+ 0.0,
+ -0.11,
+ -0.61
+ ],
+ "gains": [
+ -0.04,
+ -0.89,
+ -1.61,
+ -1.72,
+ -1.45
+ ],
+ "roc": [
+ -0.09,
+ -0.59,
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ "ops": [
+ 1.61,
+ 3.53,
+ 1.09,
+ -4.28,
+ 3.49
+ ],
+ "share_div": 0.3466257668711656,
+ "share_gains": 0.583844580777096,
+ "share_roc": 0.06952965235173823,
+ "char_actual": 0.51,
+ "disc_now_approx": -0.0629
+ },
+ "MXF": {
+ "sym": "MXF",
+ "cik": 65433,
+ "name": "MEXICO FUND INC",
+ "bdc": false,
+ "n_tender": 14,
+ "ticker": "MXF",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "AEF": {
+ "sym": "AEF",
+ "cik": 846676,
+ "name": "abrdn Emerging Markets ex-China Fund, Inc.",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "AEF",
+ "report_filed": "2026-03-09",
+ "ncols": 5,
+ "nav_beg": [
+ 5.96,
+ 5.96,
+ 5.78,
+ 8.7,
+ 9.41
+ ],
+ "nav_end": [
+ 7.63,
+ 5.96,
+ 5.96,
+ 5.78,
+ 8.7
+ ],
+ "mkt_end": [
+ 7.0,
+ 5.19,
+ 5.11,
+ 5.15,
+ 7.92
+ ],
+ "dist_tot": [
+ 0.65,
+ 0.39,
+ 0.39,
+ 0.44,
+ 0.53
+ ],
+ "divs": [
+ -0.05,
+ -0.02,
+ -0.06,
+ -0.13,
+ -0.22
+ ],
+ "gains": [
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ "roc": [
+ -0.6,
+ -0.37,
+ -0.33,
+ -0.31,
+ -0.31
+ ],
+ "ops": [
+ 2.29,
+ 0.39,
+ 0.57,
+ -2.48,
+ -0.18
+ ],
+ "share_div": 0.19999999999999996,
+ "share_gains": 0.0,
+ "share_roc": 0.7999999999999999,
+ "char_actual": 0.82,
+ "disc_now_approx": -0.0826
+ },
+ "ASGI": {
+ "sym": "ASGI",
+ "cik": 1793855,
+ "name": "abrdn Global Infrastructure Income Fund",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "ASGI",
+ "report_filed": "2025-12-08",
+ "ncols": 5,
+ "nav_beg": [
+ 21.17,
+ 19.16,
+ 18.93,
+ 22.27,
+ 19.43
+ ],
+ "nav_end": [
+ 21.51,
+ 21.17,
+ 19.16,
+ 18.93,
+ 22.27
+ ],
+ "mkt_end": [
+ 21.13,
+ 20.21,
+ 16.1,
+ 15.73,
+ 19.93
+ ],
+ "dist_tot": [
+ 2.44,
+ 1.99,
+ 1.44,
+ 1.37,
+ 1.3
+ ],
+ "divs": [
+ -0.48,
+ -0.39,
+ -0.68,
+ -0.22,
+ -1.2
+ ],
+ "gains": [
+ -0.76,
+ -1.08,
+ -0.76,
+ -1.15,
+ -0.1
+ ],
+ "roc": [
+ -1.2,
+ -0.52,
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ "ops": [
+ 2.78,
+ 4.0,
+ 1.67,
+ -1.97,
+ 4.14
+ ],
+ "share_div": 0.3477751756440281,
+ "share_gains": 0.4508196721311476,
+ "share_roc": 0.20140515222482439,
+ "char_actual": 0.55,
+ "disc_now_approx": -0.0177
+ },
+ "TYG": {
+ "sym": "TYG",
+ "cik": 1268533,
+ "name": "TORTOISE ENERGY INFRASTRUCTURE CORP",
+ "bdc": false,
+ "n_tender": 17,
+ "ticker": "TYG",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "AIO": {
+ "sym": "AIO",
+ "cik": 1778114,
+ "name": "Virtus Artificial Intelligence & Technology Opportunities Fund",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "AIO",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "JCE": {
+ "sym": "JCE",
+ "cik": 1385763,
+ "name": "Nuveen Core Equity Alpha Fund",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "JCE",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "ECAT": {
+ "sym": "ECAT",
+ "cik": 1864843,
+ "name": "BlackRock ESG Capital Allocation Term Trust",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "ECAT",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "CET": {
+ "sym": "CET",
+ "cik": 18748,
+ "name": "CENTRAL SECURITIES CORP",
+ "bdc": false,
+ "n_tender": 4,
+ "ticker": "CET",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "IGD": {
+ "sym": "IGD",
+ "cik": 1285890,
+ "name": "Voya GLOBAL EQUITY DIVIDEND & PREMIUM OPPORTUNITY FUND",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "IGD",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "STEW": {
+ "sym": "STEW",
+ "cik": 102426,
+ "name": "SRH Total Return Fund, Inc.",
+ "bdc": false,
+ "n_tender": 1,
+ "ticker": "STEW",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "AOD": {
+ "sym": "AOD",
+ "cik": 1379400,
+ "name": "abrdn Total Dynamic Dividend Fund",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "AOD",
+ "report_filed": "2026-01-08",
+ "ncols": 5,
+ "nav_beg": [
+ 11.15,
+ 9.9,
+ 10.05,
+ 12.95,
+ 10.16
+ ],
+ "nav_end": [
+ 11.63,
+ 11.15,
+ 9.9,
+ 10.05,
+ 12.95
+ ],
+ "mkt_end": [
+ 11.63,
+ 10.16,
+ 8.4,
+ 8.92,
+ 12.01
+ ],
+ "dist_tot": [
+ 1.32,
+ 0.93,
+ 0.78,
+ 0.78,
+ 0.78
+ ],
+ "divs": [
+ -0.72,
+ -0.75,
+ -0.75,
+ -0.73,
+ -0.78
+ ],
+ "gains": [
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ "roc": [
+ -0.6,
+ -0.18,
+ -0.03,
+ -0.05,
+ 0.0
+ ],
+ "ops": [
+ 1.8,
+ 2.18,
+ 0.63,
+ -2.12,
+ 3.57
+ ],
+ "share_div": 0.8126361655773419,
+ "share_gains": 0.0,
+ "share_roc": 0.18736383442265794,
+ "char_actual": 0.27,
+ "disc_now_approx": 0.0
+ },
+ "BXSY": {
+ "sym": "BXSY",
+ "cik": 1059213,
+ "name": "BEXIL INVESTMENT TRUST",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "BXSY",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "IDE": {
+ "sym": "IDE",
+ "cik": 1417802,
+ "name": "Voya Infrastructure, Industrials & Materials Fund",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "IDE",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "AGD": {
+ "sym": "AGD",
+ "cik": 1362481,
+ "name": "abrdn Global Dynamic Dividend Fund",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "AGD",
+ "report_filed": "2026-01-08",
+ "ncols": 5,
+ "nav_beg": [
+ 11.15,
+ 9.9,
+ 10.05,
+ 12.95,
+ 10.16
+ ],
+ "nav_end": [
+ 11.63,
+ 11.15,
+ 9.9,
+ 10.05,
+ 12.95
+ ],
+ "mkt_end": [
+ 11.63,
+ 10.16,
+ 8.4,
+ 8.92,
+ 12.01
+ ],
+ "dist_tot": [
+ 1.32,
+ 0.93,
+ 0.78,
+ 0.78,
+ 0.78
+ ],
+ "divs": [
+ -0.72,
+ -0.75,
+ -0.75,
+ -0.73,
+ -0.78
+ ],
+ "gains": [
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ "roc": [
+ -0.6,
+ -0.18,
+ -0.03,
+ -0.05,
+ 0.0
+ ],
+ "ops": [
+ 1.8,
+ 2.18,
+ 0.63,
+ -2.12,
+ 3.57
+ ],
+ "share_div": 0.8126361655773419,
+ "share_gains": 0.0,
+ "share_roc": 0.18736383442265794,
+ "char_actual": 0.27,
+ "disc_now_approx": 0.0
+ },
+ "IGA": {
+ "sym": "IGA",
+ "cik": 1332943,
+ "name": "Voya Global Advantage & Premium Opportunity Fund",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "IGA",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "EOD": {
+ "sym": "EOD",
+ "cik": 1386067,
+ "name": "ALLSPRING GLOBAL DIVIDEND OPPORTUNITY FUND",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "EOD",
+ "report_filed": "2026-01-05",
+ "ncols": 5,
+ "nav_beg": [
+ 5.53,
+ 4.52,
+ 4.57,
+ 6.03,
+ 4.84
+ ],
+ "nav_end": [
+ 6.44,
+ 5.53,
+ 4.52,
+ 4.57,
+ 6.03
+ ],
+ "mkt_end": [],
+ "dist_tot": [
+ 0.5,
+ 0.44,
+ 0.45,
+ 0.53,
+ 0.52
+ ],
+ "divs": [
+ -0.26,
+ -0.24,
+ -0.21,
+ -0.18,
+ -0.26
+ ],
+ "gains": [
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ "roc": [
+ -0.24,
+ -0.2,
+ -0.24,
+ -0.35,
+ -0.26
+ ],
+ "ops": [
+ 1.41,
+ 1.44,
+ 0.4,
+ -0.93,
+ 1.71
+ ],
+ "share_div": 0.47131147540983603,
+ "share_gains": 0.0,
+ "share_roc": 0.5286885245901639,
+ "char_actual": 0.58,
+ "disc_now_approx": null
+ },
+ "ETG": {
+ "sym": "ETG",
+ "cik": 1270523,
+ "name": "Eaton Vance Tax-Advantaged Global Dividend Income Fund",
+ "bdc": false,
+ "n_tender": 1,
+ "ticker": "ETG",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "CSQ": {
+ "sym": "CSQ",
+ "cik": 1275214,
+ "name": "CALAMOS STRATEGIC TOTAL RETURN FUND",
+ "bdc": false,
+ "n_tender": 5,
+ "ticker": "CSQ",
+ "report_filed": "2025-06-27",
+ "ncols": 3,
+ "nav_beg": [
+ 10.04,
+ 8.99,
+ 10.26
+ ],
+ "nav_end": [
+ 9.32,
+ 10.04,
+ 8.99
+ ],
+ "mkt_end": [],
+ "dist_tot": [
+ 0.58,
+ 1.14,
+ 1.14
+ ],
+ "divs": [
+ -0.48,
+ -0.42,
+ -0.15
+ ],
+ "gains": [
+ -0.1,
+ -0.21,
+ -0.99
+ ],
+ "roc": [
+ 0.0,
+ -0.51,
+ 0.0
+ ],
+ "ops": [
+ -0.16,
+ 2.19,
+ -0.13
+ ],
+ "share_div": 0.36713286713286714,
+ "share_gains": 0.45454545454545464,
+ "share_roc": 0.17832167832167836,
+ "char_actual": 0.53,
+ "disc_now_approx": null
+ },
+ "MCI": {
+ "sym": "MCI",
+ "cik": 275694,
+ "name": "BARINGS CORPORATE INVESTORS",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "MCI",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "SCD": {
+ "sym": "SCD",
+ "cik": 1270131,
+ "name": "LMP CAPITAL & INCOME FUND INC.",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "SCD",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "MPV": {
+ "sym": "MPV",
+ "cik": 831655,
+ "name": "BARINGS PARTICIPATION INVESTORS",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "MPV",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "CLM": {
+ "sym": "CLM",
+ "cik": 814083,
+ "name": "Cornerstone Strategic Investment Fund, Inc.",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "CLM",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "HERZ": {
+ "sym": "HERZ",
+ "cik": 880406,
+ "name": "Herzfeld Credit Income Fund, Inc",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "HERZ",
+ "report_filed": "2025-09-08",
+ "ncols": 5,
+ "nav_beg": [
+ 3.1,
+ 4.98,
+ 4.63,
+ 7.06,
+ 4.76
+ ],
+ "nav_end": [
+ 2.65,
+ 3.1,
+ 4.98,
+ 4.63,
+ 7.06
+ ],
+ "mkt_end": [
+ 2.53,
+ 2.35,
+ 3.95,
+ 4.01,
+ 6.27
+ ],
+ "dist_tot": [
+ 0.46,
+ 0.41,
+ 0.69,
+ 1.06,
+ 0.62
+ ],
+ "divs": [
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0
+ ],
+ "gains": [
+ -0.23,
+ -0.12,
+ -0.1,
+ -0.23,
+ 0.0
+ ],
+ "roc": [
+ -0.23,
+ -0.29,
+ -0.59,
+ -0.83,
+ -0.62
+ ],
+ "ops": [
+ 0.07,
+ 0.19,
+ 1.13,
+ -1.21,
+ 2.91
+ ],
+ "share_div": 0.0,
+ "share_gains": 0.2098765432098765,
+ "share_roc": 0.7901234567901234,
+ "char_actual": 0.94,
+ "disc_now_approx": -0.0453
+ },
+ "CRF": {
+ "sym": "CRF",
+ "cik": 33934,
+ "name": "CORNERSTONE TOTAL RETURN FUND INC",
+ "bdc": false,
+ "n_tender": 10,
+ "ticker": "CRF",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "EDF": {
+ "sym": "EDF",
+ "cik": 1501103,
+ "name": "Virtus Stone Harbor Emerging Markets Income Fund",
+ "bdc": false,
+ "n_tender": 0,
+ "ticker": "EDF",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "VCRDX": {
+ "sym": "VCRDX",
+ "cik": 1812286,
+ "name": "Harrison Street Infrastructure Income Fund",
+ "bdc": false,
+ "n_tender": 9,
+ "ticker": "VCRDX",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "IGR": {
+ "sym": "IGR",
+ "cik": 1268884,
+ "name": "CBRE GLOBAL REAL ESTATE INCOME FUND",
+ "bdc": false,
+ "n_tender": 3,
+ "ticker": "IGR",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "NICHX": {
+ "sym": "NICHX",
+ "cik": 1736510,
+ "name": "Variant Alternative Income Fund",
+ "bdc": false,
+ "n_tender": 34,
+ "ticker": "NICHX",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ },
+ "ASCIX": {
+ "sym": "ASCIX",
+ "cik": 1716885,
+ "name": "Angel Oak Strategic Credit Fund",
+ "bdc": false,
+ "n_tender": 33,
+ "ticker": "ASCIX",
+ "error": "no per-share table found in the 3 latest reports",
+ "char_actual": null
+ }
+}
\ No newline at end of file
diff --git a/fundlab/cef_annual.py b/fundlab/cef_annual.py
new file mode 100644
index 0000000..197a993
--- /dev/null
+++ b/fundlab/cef_annual.py
@@ -0,0 +1,471 @@
+"""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+)?\)?")
+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+)?\)?", 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)
+ vals: list[str] = []
+ for t in re.findall(r"\S+", tail):
+ if t == "$":
+ continue
+ if NUMTOK.fullmatch(t):
+ vals.append(t)
+ elif FOOTNOTE.fullmatch(t):
+ continue # footnote markers sit between values
+ elif vals:
+ break # first word after values = next label
+ return _nums(vals[:10])
+
+
+def parse_highlights(t: str) -> dict | None:
+ """Parse the per-share highlights table. Returns None on failure."""
+ # collapse whitespace: CEF HTML tables flatten with one | per line,
+ # splitting labels ("Net\nasset value, beginning of year")
+ t = re.sub(r"\s+", " ", t)
+ # anchor: first "Net asset value, beginning of year" with "share"
+ # nearby (skips dollar-basis statement mentions)
+ anchor = None
+ for m in re.finditer(r"net asset value(?: per (?:common )?share)?, beginning of (?:year|period)", t, re.I):
+ pre = t[max(0, m.start() - 400): m.start()]
+ if re.search(r"share", pre, re.I):
+ anchor = m
+ break
+ if not anchor:
+ return None
+ block = t[anchor.start(): anchor.start() + 6000]
+
+ nav_beg = _row(block, r"net asset value(?: per (?:common )?share)?, beginning of (?:year|period)")
+ nav_end = _row(block, r"net asset value(?: per (?:common )?share)?, end of (?:year|period)")
+ if len(nav_beg) < 3 or len(nav_end) < 3:
+ return None
+ ncols = len(nav_beg)
+ if len(nav_end) != ncols or ncols > 6:
+ return None
+
+ nii_ops = _row(block, r"Net investment (?:income|loss)\b")
+ gains_ops = _row(block, r"Net realized (?:and|&) (?:unrealized )?gain")
+ ops = _row(block,
+ r"total (?:income \(loss\) )?from (?:investment )?operations")
+ if not ops:
+ ops = [(_get(nii_ops, i) + _get(gains_ops, i)) for i in range(ncols)]
+
+ # CEFs adjust NAV for capital transactions (repurchases, rights
+ # offerings, tenders). Collect every variant row present, deduping a
+ # physical row that matches two labels (e.g. "Capital Share
+ # Transactions: Accretion (dilution) ...").
+ captch_rows = []
+ seen = []
+ for rx in (r"accretion (?:\(dilution\) )?to net asset value",
+ r"capital share transactions",
+ r"anti-dilutive impact of repurchase",
+ r"dilutive impact of rights offering",
+ r"anti-dilutive impact of tender offer"
+ r"repurchase of shares",):
+ row = _row(block, rx)
+ if row and any(len(row) == len(r2) and
+ all(abs((a or 0) - (b or 0)) < 0.005
+ for a, b in zip(row, r2)) for r2 in seen):
+ continue
+ seen.append(row)
+ captch_rows.append(row)
+ mkt_end = _row(block, r"market (?:value|price), end of year")
+
+ # distribution sub-rows (character). The block ends at NAV-end:
+ # footnotes below it also contain the word "dividends".
+ danchor = re.search(
+ r"distributions to (?:common )?shareholders from|"
+ r"distributions to stockholders from|"
+ r"distributions declared (?:to shareholders)? from|"
+ r"distributions from",
+ block, re.I)
+ dblock = block[danchor.start():] if danchor else block
+ em = re.search(r"net asset value(?: per (?:common )?share)?, end of (?:year|period)", dblock, re.I)
+ if em:
+ dblock = dblock[: em.start()]
+ # the TOTAL row label also contains "dividends" in some funds
+ # ("Total dividends and distributions to stockholders") - scope the
+ # sub-row search to before it
+ tm = re.search(r"total (?:dividends and )?distributions", dblock, re.I)
+ dwindow = dblock[: tm.start()] if tm else dblock
+ divs = (_row(dwindow, r"\bdividends\b")
+ or _row(dwindow, r"net investment income"))
+ gains = _row(dwindow, r"(?:net )?realized (?:capital )?gains?|"
+ r"capital gain distributions")
+ roc = _row(dwindow, r"return of capital")
+
+ # total distributions: the PRINTED row is authoritative when it has
+ # one value per column; otherwise derive from the NAV identity
+ # (dist = NAV_beg + ops + captch - NAV_end)
+ dist_row = _row(dblock, r"total (?:dividends and )?distributions")
+ complete = len(dist_row) == ncols
+ if complete:
+ dist_tot = list(dist_row)
+ # some funds print distributions positive, others negative
+ if sum(1 for v in dist_tot if v < 0) > ncols // 2:
+ dist_tot = [-v for v in dist_tot]
+ else:
+ dist_tot = None
+
+ # JOINT PLACEMENT. CEF tables drop zero columns, so a short row can
+ # sit in ANY subset of columns (TWN: gains in 4 of 5 cols, NII-divs
+ # in the other two; EMO: ROC in cols 0 and 4; KF: accretion in cols
+ # 1-4, not 0-3). One backtracking search places every short row
+ # (captch first - they affect the NAV identity - then the
+ # distribution rows) so the per-column arithmetic holds:
+ # printed total complete: divs+gains+roc == total
+ # printed total short: divs+gains+roc == NAV_beg+ops+captch-NAV_end
+ from itertools import combinations
+
+ dist_rows = (("divs", divs), ("gains", gains), ("roc", roc))
+ place = [(i, "c", row) for i, row in enumerate(captch_rows)
+ if 0 < len(row) < ncols]
+ place += [(i, "d", row) for i, (name, row) in enumerate(dist_rows)
+ if 0 < len(row) < ncols]
+ place.sort(key=lambda t: -len(t[2]))
+
+ base_d = [0.0] * ncols
+ for name, row in dist_rows:
+ if len(row) == ncols:
+ for c in range(ncols):
+ if row[c]:
+ base_d[c] += abs(row[c])
+ base_c = [0.0] * ncols
+ for row in captch_rows:
+ if len(row) == ncols:
+ for c in range(ncols):
+ if row[c]:
+ base_c[c] += row[c]
+
+ dsum = [0.0] * ncols
+ csum = [0.0] * ncols
+ assign = []
+
+ def rec(i):
+ if i == len(place):
+ for c in range(ncols):
+ if complete:
+ need = abs(dist_tot[c])
+ else:
+ need = abs(_get(nav_beg, c) + _get(ops, c) + base_c[c]
+ + csum[c] - _get(nav_end, c))
+ if abs(base_d[c] + dsum[c] - need) > 0.02 * max(1.0, need) + 0.02:
+ return False
+ return True
+ idx, kind, vals = place[i]
+ nz = [v for v in vals if v]
+ for cols in combinations(range(ncols), len(nz)):
+ if kind == "d":
+ for c, v in zip(cols, nz):
+ dsum[c] += abs(v)
+ if complete and any(base_d[c] + dsum[c] > abs(dist_tot[c]) + 0.02
+ for c in cols):
+ for c, v in zip(cols, nz):
+ dsum[c] -= abs(v)
+ continue
+ else:
+ for c, v in zip(cols, nz):
+ csum[c] += v
+ if rec(i + 1):
+ assign.append((idx, kind, nz, cols))
+ return True
+ if kind == "d":
+ for c, v in zip(cols, nz):
+ dsum[c] -= abs(v)
+ else:
+ for c, v in zip(cols, nz):
+ csum[c] -= v
+ return False
+
+ if place and not rec(0):
+ # short rows that reconcile nowhere: mis-parsed table - reject
+ return None
+
+ def colvec(vals, cols):
+ vec = [0.0] * ncols
+ for ci, col in enumerate(cols):
+ vec[col] = vals[ci]
+ return vec
+
+ placed_d = {}
+ placed_c = {i: [0.0] * ncols for i in
+ (i for i, k, _ in place if k == "c")}
+ for idx, kind, nz, cols in assign:
+ if kind == "d":
+ placed_d[idx] = colvec(nz, cols)
+ else:
+ placed_c[idx] = colvec(nz, cols)
+
+ for i, (name, row) in enumerate(dist_rows):
+ if len(row) == ncols:
+ vec = list(row)
+ elif 0 < len(row) < ncols:
+ vec = placed_d.get(i, [0.0] * ncols)
+ else:
+ vec = [0.0] * ncols
+ if name == "divs":
+ divs = vec
+ elif name == "gains":
+ gains = vec
+ else:
+ roc = vec
+ captch = [base_c[c] for c in range(ncols)]
+ for i, vec in placed_c.items():
+ for c in range(ncols):
+ captch[c] += vec[c]
+ if not complete:
+ dist_tot = [(_get(nav_beg, c) + _get(ops, c) + captch[c]
+ - _get(nav_end, c)) for c in range(ncols)]
+ if sum(1 for v in dist_tot if v < 0) > ncols // 2:
+ dist_tot = [-v for v in dist_tot]
+
+ # sanity: components should (roughly) sum to the derived total
+ bad = sum(1 for c in range(ncols)
+ if abs(-(divs[c] + gains[c] + roc[c]) - dist_tot[c])
+ > 0.02 * max(1.0, abs(dist_tot[c])) + 0.02)
+ if bad > max(1, ncols // 2):
+ return None
+
+ out = {
+ "ncols": ncols,
+ "nav_beg": nav_beg, "nav_end": nav_end,
+ "mkt_end": mkt_end[:ncols] if mkt_end else [],
+ "dist_tot": dist_tot, "divs": divs, "gains": gains, "roc": roc,
+ "ops": ops[:ncols],
+ }
+ tot5 = sum(v for v in dist_tot if v > 0)
+ if tot5 > 0:
+ out["share_div"] = sum(-v for v in divs if v < 0) / tot5
+ out["share_gains"] = sum(-v for v in gains if v < 0) / tot5
+ out["share_roc"] = sum(-v for v in roc if v < 0) / tot5
+ return out
+
+
+
+def _get(row: list, i: int) -> float:
+ if i < len(row) and row[i] is not None:
+ return row[i]
+ return 0.0
+
+
+def _submissions(cik: int) -> dict | None:
+ try:
+ raw = edgar.sec_get(f"https://data.sec.gov/submissions/CIK{cik:010d}.json")
+ return json.loads(raw)
+ except Exception:
+ return None
+
+
+def analyze(sym: str, cik: int, force: bool = False) -> dict:
+ cf = CACHE / f"{sym}.json"
+ if cf.exists() and not force:
+ return json.loads(cf.read_text())
+ d = _submissions(cik)
+ if not d:
+ return {"sym": sym, "error": "submissions fetch failed"}
+ r = d["filings"]["recent"]
+ forms = r["form"]
+ out: dict = {"sym": sym, "cik": cik, "name": d.get("name"),
+ "bdc": "10-K" in set(forms),
+ "n_tender": sum(1 for f in forms if f.startswith("N-23C")),
+ "ticker": (d.get("tickers") or [sym])[0]}
+ # try the 3 most recent shareholder reports, keep the best parse
+ cands = [(r["filingDate"][i], r["accessionNumber"][i])
+ for i in range(len(forms))
+ if forms[i] in ("N-CSR", "N-CSRS")][:3]
+ def _score(h):
+ # the 5-column annual table is the target; a 6-col semiannual
+ # (period + 5 FYs) is a fallback
+ return 2 if h["ncols"] == 5 else 1
+
+ best = None
+ for fd, acc in cands:
+ try:
+ raw = edgar.sec_get(
+ f"https://www.sec.gov/Archives/edgar/data/{cik}/{acc}.txt")
+ t = edgar.to_text(raw)
+ except Exception:
+ continue
+ h = parse_highlights(t)
+ if h and (best is None or _score(h) > _score(best[1])
+ or (_score(h) == _score(best[1])
+ and h["ncols"] > best[1]["ncols"])):
+ best = (fd, h, t)
+ if not best:
+ out["error"] = "no per-share table found in the 3 latest reports"
+ else:
+ fd, h, t = best
+ out["report_filed"] = fd
+ out.update(h)
+ # leverage + tax-paying flags (optional lines)
+ lm = re.search(
+ r"Loan and Debt Issuance Outstanding, End of Year \(000s\)\s*"
+ r"([\d,]+)", t)
+ pm = re.search(
+ r"Preferred Stock at Liquidation Value, End of Year \(000s\)\s*"
+ r"([\d,]+)", t)
+ nm = re.search(
+ r"Net assets applicable to common shareholders, end of year "
+ r"\(millions\)\s*\$?([\d,]+)", t)
+ if lm and nm:
+ debt = float(lm.group(1).replace(",", "")) * 1e3
+ pref = float(pm.group(1).replace(",", "")) * 1e3 if pm else 0.0
+ nav = float(nm.group(1).replace(",", "")) * 1e6
+ out["leverage_pct"] = round((debt + pref) / nav * 100, 1)
+ if re.search(r"income tax expenses?\s+[\d.]+\s*%", t):
+ out["tax_paying"] = True
+ CACHE.mkdir(exist_ok=True)
+ cf.write_text(json.dumps(out, default=str))
+ return out
+
+
+def current_nav_discount(h: dict) -> float | None:
+ """FY-end discount (market vs NAV) from the table; used as a
+ rough estimate of the current discount (no live NAV locally)."""
+ nav_end = h.get("nav_end") or []
+ mkt_end = h.get("mkt_end") or []
+ if not nav_end or not mkt_end or not nav_end[0]:
+ return None
+ return float(mkt_end[0] / nav_end[0] - 1)
+
+
+def character_score(h: dict, name: str) -> float | None:
+ if "share_div" not in h:
+ return None
+ f_nii = FRAC_NII_MUNI if re.search(r"muni|tax[- ]?exempt", name, re.I) \
+ else FRAC_NII
+ return (h["share_div"] * f_nii + h["share_gains"] * FRAC_GAINS
+ + h["share_roc"] * FRAC_ROC)
+
+
+def run(syms: list[str] | None = None) -> dict:
+ uni = json.loads((HERE / "cef_universe.json").read_text())
+ if syms is None:
+ ch = json.loads(CHARACTER.read_text())
+ syms = [s.upper() for s in
+ sorted(ch, key=lambda s: -ch[s]["tax_arb"])]
+ res: dict = {}
+ for i, s in enumerate(syms, 1):
+ u = uni.get(s) or {}
+ cik = int(u.get("cik", 0))
+ a = analyze(s, cik)
+ a["char_actual"] = round(character_score(a, u.get("name", "")), 2) \
+ if "share_div" in a else None
+ if "nav_end" in a:
+ disc = current_nav_discount(a)
+ a["disc_now_approx"] = round(disc, 4) if disc is not None else None
+ res[s] = a
+ if "error" in a:
+ print(f"{i:2}/{len(syms)} {s:7} ERR: {a['error'][:40]}", flush=True)
+ else:
+ print(f"{i:2}/{len(syms)} {s:7} "
+ f"div {a.get('share_div',0):.0%} gain {a.get('share_gains',0):.0%} "
+ f"ROC {a.get('share_roc',0):.0%} char={a.get('char_actual')} "
+ f"lev={a.get('leverage_pct')}", flush=True)
+ OUT.write_text(json.dumps(res, indent=1, default=str))
+ print(f"wrote {OUT}")
+ return res
+
+
+def _print(res: dict) -> None:
+ print(f"{'fund':7} {'name':38} {'fdiv':>5} {'fgain':>6} {'froc':>5}"
+ f" {'char':>5} {'disc':>7} {'lev%':>6} {'taxp':>4} {'tend':>5} "
+ f"{'report':>10}")
+ for s, v in res.items():
+ if "share_div" not in v:
+ print(f"{s:7} {v.get('name','')[:38]:38} -- "
+ f"{v.get('error', 'no data')}")
+ continue
+ disc = v.get("disc_now_approx")
+ print(f"{s:7} {v.get('name','')[:38]:38} "
+ f"{v['share_div']:5.0%} {v['share_gains']:6.0%} "
+ f"{v['share_roc']:5.0%} {v.get('char_actual') or 0:5.2f} "
+ f"{('' if disc is None else f'{disc:+.1%}'):>7} "
+ f"{str(v.get('leverage_pct', '')):>6} "
+ f"{'Y' if v.get('tax_paying') else '':>4} "
+ f"{v.get('n_tender', 0):>5} {v.get('report_filed',''):>10}")
+
+
+if __name__ == "__main__":
+ import sys
+ args = [a.upper() for a in sys.argv[1:]]
+ r = run(args or None)
+ _print(r)
diff --git a/fundlab/cef_annual_run.log b/fundlab/cef_annual_run.log
new file mode 100644
index 0000000..8b3d3d8
--- /dev/null
+++ b/fundlab/cef_annual_run.log
@@ -0,0 +1,102 @@
+ 1/50 LTCFX ERR: no per-share table found in the 3 latest
+ 2/50 DXYZ ERR: no per-share table found in the 3 latest
+ 3/50 TWN div 6% gain 94% ROC 0% char=0.66 lev=None
+ 4/50 ASA ERR: no per-share table found in the 3 latest
+ 5/50 PEO ERR: no per-share table found in the 3 latest
+ 6/50 KF div 27% gain 72% ROC 0% char=0.54 lev=None
+ 7/50 LENDX ERR: no per-share table found in the 3 latest
+ 8/50 EMO div 67% gain 0% ROC 33% char=0.4 lev=None
+ 9/50 NML ERR: no per-share table found in the 3 latest
+10/50 EMF ERR: no per-share table found in the 3 latest
+11/50 PDX ERR: no per-share table found in the 3 latest
+12/50 ADX ERR: no per-share table found in the 3 latest
+13/50 HQL div 36% gain 61% ROC 3% char=0.49 lev=None
+14/50 SRV ERR: no per-share table found in the 3 latest
+15/50 KYN ERR: no per-share table found in the 3 latest
+16/50 RMT ERR: no per-share table found in the 3 latest
+17/50 FUND ERR: no per-share table found in the 3 latest
+18/50 IAE ERR: no per-share table found in the 3 latest
+19/50 NXG ERR: no per-share table found in the 3 latest
+20/50 IHD ERR: no per-share table found in the 3 latest
+21/50 HQH div 35% gain 58% ROC 7% char=0.51 lev=None
+22/50 MXF ERR: no per-share table found in the 3 latest
+23/50 AEF div 20% gain 0% ROC 80% char=0.82 lev=None
+24/50 ASGI div 35% gain 45% ROC 20% char=0.55 lev=None
+25/50 TYG ERR: no per-share table found in the 3 latest
+26/50 AIO ERR: no per-share table found in the 3 latest
+27/50 JCE ERR: no per-share table found in the 3 latest
+28/50 ECAT ERR: no per-share table found in the 3 latest
+29/50 CET ERR: no per-share table found in the 3 latest
+30/50 IGD ERR: no per-share table found in the 3 latest
+31/50 STEW ERR: no per-share table found in the 3 latest
+32/50 AOD div 81% gain 0% ROC 19% char=0.27 lev=None
+33/50 BXSY ERR: no per-share table found in the 3 latest
+34/50 IDE ERR: no per-share table found in the 3 latest
+35/50 AGD div 81% gain 0% ROC 19% char=0.27 lev=None
+36/50 IGA ERR: no per-share table found in the 3 latest
+37/50 EOD ERR: no per-share table found in the 3 latest
+38/50 ETG ERR: no per-share table found in the 3 latest
+39/50 CSQ ERR: no per-share table found in the 3 latest
+40/50 MCI ERR: no per-share table found in the 3 latest
+41/50 SCD ERR: no per-share table found in the 3 latest
+42/50 MPV ERR: no per-share table found in the 3 latest
+43/50 CLM ERR: no per-share table found in the 3 latest
+44/50 HERZ div 0% gain 21% ROC 79% char=0.94 lev=None
+45/50 CRF ERR: no per-share table found in the 3 latest
+46/50 EDF ERR: no per-share table found in the 3 latest
+47/50 VCRDX ERR: no per-share table found in the 3 latest
+48/50 IGR ERR: no per-share table found in the 3 latest
+49/50 NICHX ERR: no per-share table found in the 3 latest
+50/50 ASCIX ERR: no per-share table found in the 3 latest
+wrote /mnt/Backup/prog/f/fundlab/cef_annual.json
+fund name fdiv fgain froc char disc lev% taxp tend report
+LTCFX Alternative Strategies Income Fund -- no per-share table found in the 3 latest reports
+DXYZ Destiny Tech100 Inc. -- no per-share table found in the 3 latest reports
+TWN TAIWAN FUND INC 6% 94% 0% 0.66 -15.0% 0 2025-11-05
+ASA ASA Gold & Precious Metals Ltd -- no per-share table found in the 3 latest reports
+PEO ADAMS NATURAL RESOURCES FUND, INC. -- no per-share table found in the 3 latest reports
+KF KOREA FUND INC 27% 72% 0% 0.54 -14.4% 0 2026-08-27
+LENDX Stone Ridge Trust V -- no per-share table found in the 3 latest reports
+EMO ClearBridge Energy Midstream Opportuni 67% 0% 33% 0.40 -6.8% 6 2026-01-27
+NML Neuberger Energy Infrastructure & Inco -- no per-share table found in the 3 latest reports
+EMF TEMPLETON EMERGING MARKETS FUND -- no per-share table found in the 3 latest reports
+PDX PIMCO Dynamic Income Strategy Fund -- no per-share table found in the 3 latest reports
+ADX ADAMS DIVERSIFIED EQUITY FUND, INC. -- no per-share table found in the 3 latest reports
+HQL abrdn Life Sciences Investors 36% 61% 3% 0.49 -10.6% 0 2025-12-08
+SRV NXG Cushing Midstream Energy Fund -- no per-share table found in the 3 latest reports
+KYN Kayne Anderson Energy Infrastructure F -- no per-share table found in the 3 latest reports
+RMT ROYCE MICRO-CAP TRUST, INC. -- no per-share table found in the 3 latest reports
+FUND SPROTT FOCUS TRUST INC. -- no per-share table found in the 3 latest reports
+IAE Voya Asia Pacific High Dividend Equity -- no per-share table found in the 3 latest reports
+NXG NXG NextGen Infrastructure Income Fund -- no per-share table found in the 3 latest reports
+IHD Voya Emerging Markets High Dividend Eq -- no per-share table found in the 3 latest reports
+HQH abrdn Healthcare Investors 35% 58% 7% 0.51 -6.3% 0 2025-12-08
+MXF MEXICO FUND INC -- no per-share table found in the 3 latest reports
+AEF abrdn Emerging Markets ex-China Fund, 20% 0% 80% 0.82 -8.3% 0 2026-03-09
+ASGI abrdn Global Infrastructure Income Fun 35% 45% 20% 0.55 -1.8% 0 2025-12-08
+TYG TORTOISE ENERGY INFRASTRUCTURE CORP -- no per-share table found in the 3 latest reports
+AIO Virtus Artificial Intelligence & Techn -- no per-share table found in the 3 latest reports
+JCE Nuveen Core Equity Alpha Fund -- no per-share table found in the 3 latest reports
+ECAT BlackRock ESG Capital Allocation Term -- no per-share table found in the 3 latest reports
+CET CENTRAL SECURITIES CORP -- no per-share table found in the 3 latest reports
+IGD Voya GLOBAL EQUITY DIVIDEND & PREMIUM -- no per-share table found in the 3 latest reports
+STEW SRH Total Return Fund, Inc. -- no per-share table found in the 3 latest reports
+AOD abrdn Total Dynamic Dividend Fund 81% 0% 19% 0.27 +0.0% 0 2026-01-08
+BXSY BEXIL INVESTMENT TRUST -- no per-share table found in the 3 latest reports
+IDE Voya Infrastructure, Industrials & Mat -- no per-share table found in the 3 latest reports
+AGD abrdn Global Dynamic Dividend Fund 81% 0% 19% 0.27 +0.0% 0 2026-01-08
+IGA Voya Global Advantage & Premium Opport -- no per-share table found in the 3 latest reports
+EOD ALLSPRING GLOBAL DIVIDEND OPPORTUNITY -- no per-share table found in the 3 latest reports
+ETG Eaton Vance Tax-Advantaged Global Divi -- no per-share table found in the 3 latest reports
+CSQ CALAMOS STRATEGIC TOTAL RETURN FUND -- no per-share table found in the 3 latest reports
+MCI BARINGS CORPORATE INVESTORS -- no per-share table found in the 3 latest reports
+SCD LMP CAPITAL & INCOME FUND INC. -- no per-share table found in the 3 latest reports
+MPV BARINGS PARTICIPATION INVESTORS -- no per-share table found in the 3 latest reports
+CLM Cornerstone Strategic Investment Fund, -- no per-share table found in the 3 latest reports
+HERZ Herzfeld Credit Income Fund, Inc 0% 21% 79% 0.94 -4.5% 0 2025-09-08
+CRF CORNERSTONE TOTAL RETURN FUND INC -- no per-share table found in the 3 latest reports
+EDF Virtus Stone Harbor Emerging Markets I -- no per-share table found in the 3 latest reports
+VCRDX Harrison Street Infrastructure Income -- no per-share table found in the 3 latest reports
+IGR CBRE GLOBAL REAL ESTATE INCOME FUND -- no per-share table found in the 3 latest reports
+NICHX Variant Alternative Income Fund -- no per-share table found in the 3 latest reports
+ASCIX Angel Oak Strategic Credit Fund -- no per-share table found in the 3 latest reports
diff --git a/fundlab/cef_annual_run2.log b/fundlab/cef_annual_run2.log
new file mode 100644
index 0000000..14210f3
--- /dev/null
+++ b/fundlab/cef_annual_run2.log
@@ -0,0 +1,12 @@
+ 1/50 LTCFX ERR: no per-share table found in the 3 latest
+ 2/50 DXYZ ERR: no per-share table found in the 3 latest
+Traceback (most recent call last):
+ File "", line 198, in _run_module_as_main
+ File "", line 88, in _run_code
+ File "/mnt/Backup/prog/f/fundlab/cef_annual.py", line 462, in
+ r = run(args or None)
+ ^^^^^^^^^^^^^^^^^
+ File "/mnt/Backup/prog/f/fundlab/cef_annual.py", line 426, in run
+ a["disc_now_approx"] = round(current_nav_discount(a), 4)
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+TypeError: type NoneType doesn't define __round__ method
diff --git a/fundlab/cef_annual_run3.log b/fundlab/cef_annual_run3.log
new file mode 100644
index 0000000..6aac9eb
--- /dev/null
+++ b/fundlab/cef_annual_run3.log
@@ -0,0 +1,102 @@
+ 1/50 LTCFX ERR: no per-share table found in the 3 latest
+ 2/50 DXYZ ERR: no per-share table found in the 3 latest
+ 3/50 TWN div 6% gain 94% ROC 0% char=0.66 lev=None
+ 4/50 ASA ERR: no per-share table found in the 3 latest
+ 5/50 PEO div 42% gain 58% ROC 0% char=0.45 lev=None
+ 6/50 KF div 27% gain 72% ROC 0% char=0.54 lev=None
+ 7/50 LENDX div 45% gain 40% ROC 14% char=0.47 lev=None
+ 8/50 EMO div 67% gain 0% ROC 33% char=0.4 lev=None
+ 9/50 NML ERR: no per-share table found in the 3 latest
+10/50 EMF div 51% gain 49% ROC 0% char=0.4 lev=None
+11/50 PDX ERR: no per-share table found in the 3 latest
+12/50 ADX div 9% gain 91% ROC 0% char=0.65 lev=None
+13/50 HQL div 36% gain 61% ROC 3% char=0.49 lev=None
+14/50 SRV ERR: no per-share table found in the 3 latest
+15/50 KYN ERR: no per-share table found in the 3 latest
+16/50 RMT ERR: no per-share table found in the 3 latest
+17/50 FUND ERR: no per-share table found in the 3 latest
+18/50 IAE ERR: no per-share table found in the 3 latest
+19/50 NXG ERR: no per-share table found in the 3 latest
+20/50 IHD ERR: no per-share table found in the 3 latest
+21/50 HQH div 35% gain 58% ROC 7% char=0.51 lev=None
+22/50 MXF ERR: no per-share table found in the 3 latest
+23/50 AEF div 20% gain 0% ROC 80% char=0.82 lev=None
+24/50 ASGI div 35% gain 45% ROC 20% char=0.55 lev=None
+25/50 TYG ERR: no per-share table found in the 3 latest
+26/50 AIO ERR: no per-share table found in the 3 latest
+27/50 JCE ERR: no per-share table found in the 3 latest
+28/50 ECAT ERR: no per-share table found in the 3 latest
+29/50 CET ERR: no per-share table found in the 3 latest
+30/50 IGD ERR: no per-share table found in the 3 latest
+31/50 STEW ERR: no per-share table found in the 3 latest
+32/50 AOD div 81% gain 0% ROC 19% char=0.27 lev=None
+33/50 BXSY ERR: no per-share table found in the 3 latest
+34/50 IDE ERR: no per-share table found in the 3 latest
+35/50 AGD div 81% gain 0% ROC 19% char=0.27 lev=None
+36/50 IGA ERR: no per-share table found in the 3 latest
+37/50 EOD div 47% gain 0% ROC 53% char=0.58 lev=None
+38/50 ETG ERR: no per-share table found in the 3 latest
+39/50 CSQ div 37% gain 45% ROC 18% char=0.53 lev=None
+40/50 MCI ERR: no per-share table found in the 3 latest
+41/50 SCD ERR: no per-share table found in the 3 latest
+42/50 MPV ERR: no per-share table found in the 3 latest
+43/50 CLM ERR: no per-share table found in the 3 latest
+44/50 HERZ div 0% gain 21% ROC 79% char=0.94 lev=None
+45/50 CRF ERR: no per-share table found in the 3 latest
+46/50 EDF ERR: no per-share table found in the 3 latest
+47/50 VCRDX ERR: no per-share table found in the 3 latest
+48/50 IGR ERR: no per-share table found in the 3 latest
+49/50 NICHX ERR: no per-share table found in the 3 latest
+50/50 ASCIX ERR: no per-share table found in the 3 latest
+wrote /mnt/Backup/prog/f/fundlab/cef_annual.json
+fund name fdiv fgain froc char disc lev% taxp tend report
+LTCFX Alternative Strategies Income Fund -- no per-share table found in the 3 latest reports
+DXYZ Destiny Tech100 Inc. -- no per-share table found in the 3 latest reports
+TWN TAIWAN FUND INC 6% 94% 0% 0.66 -15.0% 0 2025-11-05
+ASA ASA Gold & Precious Metals Ltd -- no per-share table found in the 3 latest reports
+PEO ADAMS NATURAL RESOURCES FUND, INC. 42% 58% 0% 0.45 -9.8% 0 2026-02-20
+KF KOREA FUND INC 27% 72% 0% 0.54 -14.4% 0 2026-08-27
+LENDX Stone Ridge Trust V 45% 40% 14% 0.47 40 2026-05-08
+EMO ClearBridge Energy Midstream Opportuni 67% 0% 33% 0.40 -6.8% 6 2026-01-27
+NML Neuberger Energy Infrastructure & Inco -- no per-share table found in the 3 latest reports
+EMF TEMPLETON EMERGING MARKETS FUND 51% 49% 0% 0.40 -11.6% 0 2025-10-30
+PDX PIMCO Dynamic Income Strategy Fund -- no per-share table found in the 3 latest reports
+ADX ADAMS DIVERSIFIED EQUITY FUND, INC. 9% 91% 0% 0.65 -5.7% 0 2026-02-20
+HQL abrdn Life Sciences Investors 36% 61% 3% 0.49 -10.6% 0 2025-12-08
+SRV NXG Cushing Midstream Energy Fund -- no per-share table found in the 3 latest reports
+KYN Kayne Anderson Energy Infrastructure F -- no per-share table found in the 3 latest reports
+RMT ROYCE MICRO-CAP TRUST, INC. -- no per-share table found in the 3 latest reports
+FUND SPROTT FOCUS TRUST INC. -- no per-share table found in the 3 latest reports
+IAE Voya Asia Pacific High Dividend Equity -- no per-share table found in the 3 latest reports
+NXG NXG NextGen Infrastructure Income Fund -- no per-share table found in the 3 latest reports
+IHD Voya Emerging Markets High Dividend Eq -- no per-share table found in the 3 latest reports
+HQH abrdn Healthcare Investors 35% 58% 7% 0.51 -6.3% 0 2025-12-08
+MXF MEXICO FUND INC -- no per-share table found in the 3 latest reports
+AEF abrdn Emerging Markets ex-China Fund, 20% 0% 80% 0.82 -8.3% 0 2026-03-09
+ASGI abrdn Global Infrastructure Income Fun 35% 45% 20% 0.55 -1.8% 0 2025-12-08
+TYG TORTOISE ENERGY INFRASTRUCTURE CORP -- no per-share table found in the 3 latest reports
+AIO Virtus Artificial Intelligence & Techn -- no per-share table found in the 3 latest reports
+JCE Nuveen Core Equity Alpha Fund -- no per-share table found in the 3 latest reports
+ECAT BlackRock ESG Capital Allocation Term -- no per-share table found in the 3 latest reports
+CET CENTRAL SECURITIES CORP -- no per-share table found in the 3 latest reports
+IGD Voya GLOBAL EQUITY DIVIDEND & PREMIUM -- no per-share table found in the 3 latest reports
+STEW SRH Total Return Fund, Inc. -- no per-share table found in the 3 latest reports
+AOD abrdn Total Dynamic Dividend Fund 81% 0% 19% 0.27 +0.0% 0 2026-01-08
+BXSY BEXIL INVESTMENT TRUST -- no per-share table found in the 3 latest reports
+IDE Voya Infrastructure, Industrials & Mat -- no per-share table found in the 3 latest reports
+AGD abrdn Global Dynamic Dividend Fund 81% 0% 19% 0.27 +0.0% 0 2026-01-08
+IGA Voya Global Advantage & Premium Opport -- no per-share table found in the 3 latest reports
+EOD ALLSPRING GLOBAL DIVIDEND OPPORTUNITY 47% 0% 53% 0.58 0 2026-01-05
+ETG Eaton Vance Tax-Advantaged Global Divi -- no per-share table found in the 3 latest reports
+CSQ CALAMOS STRATEGIC TOTAL RETURN FUND 37% 45% 18% 0.53 5 2025-06-27
+MCI BARINGS CORPORATE INVESTORS -- no per-share table found in the 3 latest reports
+SCD LMP CAPITAL & INCOME FUND INC. -- no per-share table found in the 3 latest reports
+MPV BARINGS PARTICIPATION INVESTORS -- no per-share table found in the 3 latest reports
+CLM Cornerstone Strategic Investment Fund, -- no per-share table found in the 3 latest reports
+HERZ Herzfeld Credit Income Fund, Inc 0% 21% 79% 0.94 -4.5% 0 2025-09-08
+CRF CORNERSTONE TOTAL RETURN FUND INC -- no per-share table found in the 3 latest reports
+EDF Virtus Stone Harbor Emerging Markets I -- no per-share table found in the 3 latest reports
+VCRDX Harrison Street Infrastructure Income -- no per-share table found in the 3 latest reports
+IGR CBRE GLOBAL REAL ESTATE INCOME FUND -- no per-share table found in the 3 latest reports
+NICHX Variant Alternative Income Fund -- no per-share table found in the 3 latest reports
+ASCIX Angel Oak Strategic Credit Fund -- no per-share table found in the 3 latest reports
diff --git a/fundlab/cef_cache/ADX.json b/fundlab/cef_cache/ADX.json
new file mode 100644
index 0000000..d2560bf
--- /dev/null
+++ b/fundlab/cef_cache/ADX.json
@@ -0,0 +1 @@
+{"sym": "ADX", "cik": 2230, "name": "ADAMS DIVERSIFIED EQUITY FUND, INC.", "bdc": false, "n_tender": 0, "ticker": "ADX", "report_filed": "2026-02-20", "ncols": 5, "nav_beg": [22.64, 20.56, 17.38, 22.5, 20.06], "nav_end": [24.72, 22.64, 20.56, 17.38, 22.5], "mkt_end": [23.32, 20.2, 17.71, 14.54, 19.41], "dist_tot": [1.85, 2.5, 1.3, 1.07, 2.98], "divs": [-0.16, -0.17, -0.15, -0.18, -0.2], "gains": [-1.69, -2.33, -1.15, -0.89, -2.78], "roc": [0.0, 0.0, 0.0, 0.0, 0.0], "ops": [3.98, 4.62, 4.57, -3.99, 5.59], "share_div": 0.08865979381443298, "share_gains": 0.9113402061855671, "share_roc": 0.0}
\ No newline at end of file
diff --git a/fundlab/cef_cache/AEF.json b/fundlab/cef_cache/AEF.json
new file mode 100644
index 0000000..63ac5f3
--- /dev/null
+++ b/fundlab/cef_cache/AEF.json
@@ -0,0 +1 @@
+{"sym": "AEF", "cik": 846676, "name": "abrdn Emerging Markets ex-China Fund, Inc.", "bdc": false, "n_tender": 0, "ticker": "AEF", "report_filed": "2026-03-09", "ncols": 5, "nav_beg": [5.96, 5.96, 5.78, 8.7, 9.41], "nav_end": [7.63, 5.96, 5.96, 5.78, 8.7], "mkt_end": [7.0, 5.19, 5.11, 5.15, 7.92], "dist_tot": [0.65, 0.39, 0.39, 0.44, 0.53], "divs": [-0.05, -0.02, -0.06, -0.13, -0.22], "gains": [0.0, 0.0, 0.0, 0.0, 0.0], "roc": [-0.6, -0.37, -0.33, -0.31, -0.31], "ops": [2.29, 0.39, 0.57, -2.48, -0.18], "share_div": 0.19999999999999996, "share_gains": 0.0, "share_roc": 0.7999999999999999}
\ No newline at end of file
diff --git a/fundlab/cef_cache/AGD.json b/fundlab/cef_cache/AGD.json
new file mode 100644
index 0000000..c69a763
--- /dev/null
+++ b/fundlab/cef_cache/AGD.json
@@ -0,0 +1 @@
+{"sym": "AGD", "cik": 1362481, "name": "abrdn Global Dynamic Dividend Fund", "bdc": false, "n_tender": 0, "ticker": "AGD", "report_filed": "2026-01-08", "ncols": 5, "nav_beg": [11.15, 9.9, 10.05, 12.95, 10.16], "nav_end": [11.63, 11.15, 9.9, 10.05, 12.95], "mkt_end": [11.63, 10.16, 8.4, 8.92, 12.01], "dist_tot": [1.32, 0.93, 0.78, 0.78, 0.78], "divs": [-0.72, -0.75, -0.75, -0.73, -0.78], "gains": [0.0, 0.0, 0.0, 0.0, 0.0], "roc": [-0.6, -0.18, -0.03, -0.05, 0.0], "ops": [1.8, 2.18, 0.63, -2.12, 3.57], "share_div": 0.8126361655773419, "share_gains": 0.0, "share_roc": 0.18736383442265794}
\ No newline at end of file
diff --git a/fundlab/cef_cache/AIO.json b/fundlab/cef_cache/AIO.json
new file mode 100644
index 0000000..cd15a27
--- /dev/null
+++ b/fundlab/cef_cache/AIO.json
@@ -0,0 +1 @@
+{"sym": "AIO", "cik": 1778114, "name": "Virtus Artificial Intelligence & Technology Opportunities Fund", "bdc": false, "n_tender": 0, "ticker": "AIO", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/AOD.json b/fundlab/cef_cache/AOD.json
new file mode 100644
index 0000000..85c7228
--- /dev/null
+++ b/fundlab/cef_cache/AOD.json
@@ -0,0 +1 @@
+{"sym": "AOD", "cik": 1379400, "name": "abrdn Total Dynamic Dividend Fund", "bdc": false, "n_tender": 0, "ticker": "AOD", "report_filed": "2026-01-08", "ncols": 5, "nav_beg": [11.15, 9.9, 10.05, 12.95, 10.16], "nav_end": [11.63, 11.15, 9.9, 10.05, 12.95], "mkt_end": [11.63, 10.16, 8.4, 8.92, 12.01], "dist_tot": [1.32, 0.93, 0.78, 0.78, 0.78], "divs": [-0.72, -0.75, -0.75, -0.73, -0.78], "gains": [0.0, 0.0, 0.0, 0.0, 0.0], "roc": [-0.6, -0.18, -0.03, -0.05, 0.0], "ops": [1.8, 2.18, 0.63, -2.12, 3.57], "share_div": 0.8126361655773419, "share_gains": 0.0, "share_roc": 0.18736383442265794}
\ No newline at end of file
diff --git a/fundlab/cef_cache/ASA.json b/fundlab/cef_cache/ASA.json
new file mode 100644
index 0000000..1bb34ac
--- /dev/null
+++ b/fundlab/cef_cache/ASA.json
@@ -0,0 +1 @@
+{"sym": "ASA", "cik": 1230869, "name": "ASA Gold & Precious Metals Ltd", "bdc": false, "n_tender": 0, "ticker": "ASA", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/ASCIX.json b/fundlab/cef_cache/ASCIX.json
new file mode 100644
index 0000000..adc23bc
--- /dev/null
+++ b/fundlab/cef_cache/ASCIX.json
@@ -0,0 +1 @@
+{"sym": "ASCIX", "cik": 1716885, "name": "Angel Oak Strategic Credit Fund", "bdc": false, "n_tender": 33, "ticker": "ASCIX", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/ASGI.json b/fundlab/cef_cache/ASGI.json
new file mode 100644
index 0000000..a820e39
--- /dev/null
+++ b/fundlab/cef_cache/ASGI.json
@@ -0,0 +1 @@
+{"sym": "ASGI", "cik": 1793855, "name": "abrdn Global Infrastructure Income Fund", "bdc": false, "n_tender": 0, "ticker": "ASGI", "report_filed": "2025-12-08", "ncols": 5, "nav_beg": [21.17, 19.16, 18.93, 22.27, 19.43], "nav_end": [21.51, 21.17, 19.16, 18.93, 22.27], "mkt_end": [21.13, 20.21, 16.1, 15.73, 19.93], "dist_tot": [2.44, 1.99, 1.44, 1.37, 1.3], "divs": [-0.48, -0.39, -0.68, -0.22, -1.2], "gains": [-0.76, -1.08, -0.76, -1.15, -0.1], "roc": [-1.2, -0.52, 0.0, 0.0, 0.0], "ops": [2.78, 4.0, 1.67, -1.97, 4.14], "share_div": 0.3477751756440281, "share_gains": 0.4508196721311476, "share_roc": 0.20140515222482439}
\ No newline at end of file
diff --git a/fundlab/cef_cache/BXSY.json b/fundlab/cef_cache/BXSY.json
new file mode 100644
index 0000000..1e9be03
--- /dev/null
+++ b/fundlab/cef_cache/BXSY.json
@@ -0,0 +1 @@
+{"sym": "BXSY", "cik": 1059213, "name": "BEXIL INVESTMENT TRUST", "bdc": false, "n_tender": 0, "ticker": "BXSY", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/CET.json b/fundlab/cef_cache/CET.json
new file mode 100644
index 0000000..9180177
--- /dev/null
+++ b/fundlab/cef_cache/CET.json
@@ -0,0 +1 @@
+{"sym": "CET", "cik": 18748, "name": "CENTRAL SECURITIES CORP", "bdc": false, "n_tender": 4, "ticker": "CET", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/CLM.json b/fundlab/cef_cache/CLM.json
new file mode 100644
index 0000000..911af3c
--- /dev/null
+++ b/fundlab/cef_cache/CLM.json
@@ -0,0 +1 @@
+{"sym": "CLM", "cik": 814083, "name": "Cornerstone Strategic Investment Fund, Inc.", "bdc": false, "n_tender": 0, "ticker": "CLM", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/CRF.json b/fundlab/cef_cache/CRF.json
new file mode 100644
index 0000000..3bfcb5c
--- /dev/null
+++ b/fundlab/cef_cache/CRF.json
@@ -0,0 +1 @@
+{"sym": "CRF", "cik": 33934, "name": "CORNERSTONE TOTAL RETURN FUND INC", "bdc": false, "n_tender": 10, "ticker": "CRF", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/CSQ.json b/fundlab/cef_cache/CSQ.json
new file mode 100644
index 0000000..f73232c
--- /dev/null
+++ b/fundlab/cef_cache/CSQ.json
@@ -0,0 +1 @@
+{"sym": "CSQ", "cik": 1275214, "name": "CALAMOS STRATEGIC TOTAL RETURN FUND", "bdc": false, "n_tender": 5, "ticker": "CSQ", "report_filed": "2025-06-27", "ncols": 3, "nav_beg": [10.04, 8.99, 10.26], "nav_end": [9.32, 10.04, 8.99], "mkt_end": [], "dist_tot": [0.58, 1.14, 1.14], "divs": [-0.48, -0.42, -0.15], "gains": [-0.1, -0.21, -0.99], "roc": [0.0, -0.51, 0.0], "ops": [-0.16, 2.19, -0.13], "share_div": 0.36713286713286714, "share_gains": 0.45454545454545464, "share_roc": 0.17832167832167836}
\ No newline at end of file
diff --git a/fundlab/cef_cache/DXYZ.json b/fundlab/cef_cache/DXYZ.json
new file mode 100644
index 0000000..c7b31a3
--- /dev/null
+++ b/fundlab/cef_cache/DXYZ.json
@@ -0,0 +1 @@
+{"sym": "DXYZ", "cik": 1843974, "name": "Destiny Tech100 Inc.", "bdc": false, "n_tender": 0, "ticker": "DXYZ", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/ECAT.json b/fundlab/cef_cache/ECAT.json
new file mode 100644
index 0000000..24eb07b
--- /dev/null
+++ b/fundlab/cef_cache/ECAT.json
@@ -0,0 +1 @@
+{"sym": "ECAT", "cik": 1864843, "name": "BlackRock ESG Capital Allocation Term Trust", "bdc": false, "n_tender": 0, "ticker": "ECAT", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/EDF.json b/fundlab/cef_cache/EDF.json
new file mode 100644
index 0000000..787bd09
--- /dev/null
+++ b/fundlab/cef_cache/EDF.json
@@ -0,0 +1 @@
+{"sym": "EDF", "cik": 1501103, "name": "Virtus Stone Harbor Emerging Markets Income Fund", "bdc": false, "n_tender": 0, "ticker": "EDF", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/EMF.json b/fundlab/cef_cache/EMF.json
new file mode 100644
index 0000000..c59beaa
--- /dev/null
+++ b/fundlab/cef_cache/EMF.json
@@ -0,0 +1 @@
+{"sym": "EMF", "cik": 809708, "name": "TEMPLETON EMERGING MARKETS FUND", "bdc": false, "n_tender": 0, "ticker": "EMF", "report_filed": "2025-10-30", "ncols": 5, "nav_beg": [14.81, 13.63, 13.72, 20.09, 17.58], "nav_end": [17.27, 14.81, 13.63, 13.72, 20.09], "mkt_end": [15.27, 12.81, 11.71, 11.85, 17.89], "dist_tot": [0.95, 0.73, 1.13, 1.11, 0.66], "divs": [-0.59, -0.73, -0.41, -0.41, -0.18], "gains": [-0.36, 0.0, -0.72, -0.7, -0.48], "roc": [0.0, 0.0, 0.0, 0.0, 0.0], "ops": [3.36, 1.86, 1.03, -5.3, 3.15], "share_div": 0.5065502183406113, "share_gains": 0.4934497816593886, "share_roc": 0.0}
\ No newline at end of file
diff --git a/fundlab/cef_cache/EMO.json b/fundlab/cef_cache/EMO.json
new file mode 100644
index 0000000..4bd11f4
--- /dev/null
+++ b/fundlab/cef_cache/EMO.json
@@ -0,0 +1 @@
+{"sym": "EMO", "cik": 1517518, "name": "ClearBridge Energy Midstream Opportunity Fund Inc.", "bdc": false, "n_tender": 6, "ticker": "EMO", "report_filed": "2026-01-27", "ncols": 5, "nav_beg": [55.82, 38.75, 35.97, 26.53, 17.13], "nav_end": [48.88, 55.82, 38.75, 35.97, 26.53], "mkt_end": [45.55, 50.49, 34.5, 30.43, 21.65], "dist_tot": [4.23, 3.0, 2.37, 1.92, 1.47], "divs": [-0.81, -3.0, -2.37, -1.92, -0.54], "gains": [0.0, 0.0, 0.0, 0.0, 0.0], "roc": [-3.42, 0.0, 0.0, 0.0, -0.93], "ops": [-2.25, 20.07, 5.11, 11.22, 10.65], "share_div": 0.6651270207852193, "share_gains": 0.0, "share_roc": 0.33487297921478054}
\ No newline at end of file
diff --git a/fundlab/cef_cache/EOD.json b/fundlab/cef_cache/EOD.json
new file mode 100644
index 0000000..e348513
--- /dev/null
+++ b/fundlab/cef_cache/EOD.json
@@ -0,0 +1 @@
+{"sym": "EOD", "cik": 1386067, "name": "ALLSPRING GLOBAL DIVIDEND OPPORTUNITY FUND", "bdc": false, "n_tender": 0, "ticker": "EOD", "report_filed": "2026-01-05", "ncols": 5, "nav_beg": [5.53, 4.52, 4.57, 6.03, 4.84], "nav_end": [6.44, 5.53, 4.52, 4.57, 6.03], "mkt_end": [], "dist_tot": [0.5, 0.44, 0.45, 0.53, 0.52], "divs": [-0.26, -0.24, -0.21, -0.18, -0.26], "gains": [0.0, 0.0, 0.0, 0.0, 0.0], "roc": [-0.24, -0.2, -0.24, -0.35, -0.26], "ops": [1.41, 1.44, 0.4, -0.93, 1.71], "share_div": 0.47131147540983603, "share_gains": 0.0, "share_roc": 0.5286885245901639}
\ No newline at end of file
diff --git a/fundlab/cef_cache/ETG.json b/fundlab/cef_cache/ETG.json
new file mode 100644
index 0000000..2f95dbe
--- /dev/null
+++ b/fundlab/cef_cache/ETG.json
@@ -0,0 +1 @@
+{"sym": "ETG", "cik": 1270523, "name": "Eaton Vance Tax-Advantaged Global Dividend Income Fund", "bdc": false, "n_tender": 1, "ticker": "ETG", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/FUND.json b/fundlab/cef_cache/FUND.json
new file mode 100644
index 0000000..b48aca3
--- /dev/null
+++ b/fundlab/cef_cache/FUND.json
@@ -0,0 +1 @@
+{"sym": "FUND", "cik": 825202, "name": "SPROTT FOCUS TRUST INC.", "bdc": false, "n_tender": 2, "ticker": "FUND", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/HERZ.json b/fundlab/cef_cache/HERZ.json
new file mode 100644
index 0000000..56d7666
--- /dev/null
+++ b/fundlab/cef_cache/HERZ.json
@@ -0,0 +1 @@
+{"sym": "HERZ", "cik": 880406, "name": "Herzfeld Credit Income Fund, Inc", "bdc": false, "n_tender": 0, "ticker": "HERZ", "report_filed": "2025-09-08", "ncols": 5, "nav_beg": [3.1, 4.98, 4.63, 7.06, 4.76], "nav_end": [2.65, 3.1, 4.98, 4.63, 7.06], "mkt_end": [2.53, 2.35, 3.95, 4.01, 6.27], "dist_tot": [0.46, 0.41, 0.69, 1.06, 0.62], "divs": [0.0, 0.0, 0.0, 0.0, 0.0], "gains": [-0.23, -0.12, -0.1, -0.23, 0.0], "roc": [-0.23, -0.29, -0.59, -0.83, -0.62], "ops": [0.07, 0.19, 1.13, -1.21, 2.91], "share_div": 0.0, "share_gains": 0.2098765432098765, "share_roc": 0.7901234567901234}
\ No newline at end of file
diff --git a/fundlab/cef_cache/HQH.json b/fundlab/cef_cache/HQH.json
new file mode 100644
index 0000000..0182cbb
--- /dev/null
+++ b/fundlab/cef_cache/HQH.json
@@ -0,0 +1 @@
+{"sym": "HQH", "cik": 805267, "name": "abrdn Healthcare Investors", "bdc": false, "n_tender": 0, "ticker": "HQH", "report_filed": "2025-12-08", "ncols": 5, "nav_beg": [20.33, 18.84, 19.36, 25.47, 24.04], "nav_end": [19.7, 20.33, 18.84, 19.36, 25.47], "mkt_end": [18.46, 18.62, 15.55, 17.28, 25.57], "dist_tot": [2.24, 2.04, 1.61, 1.83, 2.06], "divs": [-2.11, -0.56, 0.0, -0.11, -0.61], "gains": [-0.04, -0.89, -1.61, -1.72, -1.45], "roc": [-0.09, -0.59, 0.0, 0.0, 0.0], "ops": [1.61, 3.53, 1.09, -4.28, 3.49], "share_div": 0.3466257668711656, "share_gains": 0.583844580777096, "share_roc": 0.06952965235173823}
\ No newline at end of file
diff --git a/fundlab/cef_cache/HQL.json b/fundlab/cef_cache/HQL.json
new file mode 100644
index 0000000..786ccf4
--- /dev/null
+++ b/fundlab/cef_cache/HQL.json
@@ -0,0 +1 @@
+{"sym": "HQL", "cik": 884121, "name": "abrdn Life Sciences Investors", "bdc": false, "n_tender": 0, "ticker": "HQL", "report_filed": "2025-12-08", "ncols": 5, "nav_beg": [16.38, 15.0, 15.49, 21.22, 20.25], "nav_end": [17.35, 16.38, 15.0, 15.49, 21.22], "mkt_end": [15.51, 15.08, 12.47, 13.66, 20.8], "dist_tot": [1.82, 1.66, 1.28, 1.47, 1.69], "divs": [-1.72, -0.69, -0.02, -0.03, -0.42], "gains": [-0.1, -0.76, -1.26, -1.44, -1.27], "roc": [0.0, -0.21, 0.0, 0.0, 0.0], "ops": [2.79, 3.04, 0.79, -4.26, 2.66], "share_div": 0.36363636363636365, "share_gains": 0.6098484848484849, "share_roc": 0.026515151515151516}
\ No newline at end of file
diff --git a/fundlab/cef_cache/IAE.json b/fundlab/cef_cache/IAE.json
new file mode 100644
index 0000000..5902f03
--- /dev/null
+++ b/fundlab/cef_cache/IAE.json
@@ -0,0 +1 @@
+{"sym": "IAE", "cik": 1385632, "name": "Voya Asia Pacific High Dividend Equity Income Fund", "bdc": false, "n_tender": 0, "ticker": "IAE", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/IDE.json b/fundlab/cef_cache/IDE.json
new file mode 100644
index 0000000..085297c
--- /dev/null
+++ b/fundlab/cef_cache/IDE.json
@@ -0,0 +1 @@
+{"sym": "IDE", "cik": 1417802, "name": "Voya Infrastructure, Industrials & Materials Fund", "bdc": false, "n_tender": 0, "ticker": "IDE", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/IGA.json b/fundlab/cef_cache/IGA.json
new file mode 100644
index 0000000..ed1f8f3
--- /dev/null
+++ b/fundlab/cef_cache/IGA.json
@@ -0,0 +1 @@
+{"sym": "IGA", "cik": 1332943, "name": "Voya Global Advantage & Premium Opportunity Fund", "bdc": false, "n_tender": 0, "ticker": "IGA", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/IGD.json b/fundlab/cef_cache/IGD.json
new file mode 100644
index 0000000..d44e82d
--- /dev/null
+++ b/fundlab/cef_cache/IGD.json
@@ -0,0 +1 @@
+{"sym": "IGD", "cik": 1285890, "name": "Voya GLOBAL EQUITY DIVIDEND & PREMIUM OPPORTUNITY FUND", "bdc": false, "n_tender": 0, "ticker": "IGD", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/IGR.json b/fundlab/cef_cache/IGR.json
new file mode 100644
index 0000000..2b06d95
--- /dev/null
+++ b/fundlab/cef_cache/IGR.json
@@ -0,0 +1 @@
+{"sym": "IGR", "cik": 1268884, "name": "CBRE GLOBAL REAL ESTATE INCOME FUND", "bdc": false, "n_tender": 3, "ticker": "IGR", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/IHD.json b/fundlab/cef_cache/IHD.json
new file mode 100644
index 0000000..9a215dd
--- /dev/null
+++ b/fundlab/cef_cache/IHD.json
@@ -0,0 +1 @@
+{"sym": "IHD", "cik": 1496292, "name": "Voya Emerging Markets High Dividend Equity Fund", "bdc": false, "n_tender": 0, "ticker": "IHD", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/JCE.json b/fundlab/cef_cache/JCE.json
new file mode 100644
index 0000000..bd63db8
--- /dev/null
+++ b/fundlab/cef_cache/JCE.json
@@ -0,0 +1 @@
+{"sym": "JCE", "cik": 1385763, "name": "Nuveen Core Equity Alpha Fund", "bdc": false, "n_tender": 0, "ticker": "JCE", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/KF.json b/fundlab/cef_cache/KF.json
new file mode 100644
index 0000000..6d9224b
--- /dev/null
+++ b/fundlab/cef_cache/KF.json
@@ -0,0 +1 @@
+{"sym": "KF", "cik": 748691, "name": "KOREA FUND INC", "bdc": false, "n_tender": 0, "ticker": "KF", "report_filed": "2026-08-27", "ncols": 5, "nav_beg": [30.87, 28.78, 26.52, 28.54, 54.37], "nav_end": [87.88, 30.87, 28.78, 26.52, 28.54], "mkt_end": [75.21, 26.93, 24.13, 23.14, 24.35], "dist_tot": [1.3800000000000097, 0.4499999999999993, -3.552713678800501e-15, 3.3200000000000003, 9.11], "divs": [-1.38, -0.45, -0.03, 0.0, -2.05], "gains": [0.0, 0.0, 0.0, -3.27, -7.06], "roc": [-0.02, 0.0, 0.0, 0.0, 0.0], "ops": [58.39, 2.41, 2.17, 1.25, -16.73], "share_div": 0.2741935483870966, "share_gains": 0.7244039270687233, "share_roc": 0.0014025245441795222}
\ No newline at end of file
diff --git a/fundlab/cef_cache/KYN.json b/fundlab/cef_cache/KYN.json
new file mode 100644
index 0000000..6cee612
--- /dev/null
+++ b/fundlab/cef_cache/KYN.json
@@ -0,0 +1 @@
+{"sym": "KYN", "cik": 1293613, "name": "Kayne Anderson Energy Infrastructure Fund, Inc.", "bdc": false, "n_tender": 29, "ticker": "KYN", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/LENDX.json b/fundlab/cef_cache/LENDX.json
new file mode 100644
index 0000000..a6a6640
--- /dev/null
+++ b/fundlab/cef_cache/LENDX.json
@@ -0,0 +1 @@
+{"sym": "LENDX", "cik": 1658645, "name": "Stone Ridge Trust V", "bdc": false, "n_tender": 40, "ticker": "LENDX", "report_filed": "2026-05-08", "ncols": 5, "nav_beg": [46.09, 46.4, 48.79, 51.23, 57.09], "nav_end": [46.13, 46.09, 46.4, 48.79, 51.23], "mkt_end": [], "dist_tot": [1.99, 1.85, 1.92, 2.54, 18.28], "divs": [-1.99, 0.0, 0.0, -2.54, -7.52], "gains": [0.0, 0.0, 0.0, 0.0, -10.76], "roc": [0.0, -1.85, -1.92, 0.0, 0.0], "ops": [2.03, 1.54, -8.0, -0.47, 0.1], "share_div": 0.4533483822422874, "share_gains": 0.40481565086531224, "share_roc": 0.1418359668924003}
\ No newline at end of file
diff --git a/fundlab/cef_cache/LTCFX.json b/fundlab/cef_cache/LTCFX.json
new file mode 100644
index 0000000..030507d
--- /dev/null
+++ b/fundlab/cef_cache/LTCFX.json
@@ -0,0 +1 @@
+{"sym": "LTCFX", "cik": 1496254, "name": "Alternative Strategies Income Fund", "bdc": false, "n_tender": 60, "ticker": "LTAFX", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/MCI.json b/fundlab/cef_cache/MCI.json
new file mode 100644
index 0000000..024d717
--- /dev/null
+++ b/fundlab/cef_cache/MCI.json
@@ -0,0 +1 @@
+{"sym": "MCI", "cik": 275694, "name": "BARINGS CORPORATE INVESTORS", "bdc": false, "n_tender": 0, "ticker": "MCI", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/MPV.json b/fundlab/cef_cache/MPV.json
new file mode 100644
index 0000000..f1b9b63
--- /dev/null
+++ b/fundlab/cef_cache/MPV.json
@@ -0,0 +1 @@
+{"sym": "MPV", "cik": 831655, "name": "BARINGS PARTICIPATION INVESTORS", "bdc": false, "n_tender": 0, "ticker": "MPV", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/MXF.json b/fundlab/cef_cache/MXF.json
new file mode 100644
index 0000000..4d80c70
--- /dev/null
+++ b/fundlab/cef_cache/MXF.json
@@ -0,0 +1 @@
+{"sym": "MXF", "cik": 65433, "name": "MEXICO FUND INC", "bdc": false, "n_tender": 14, "ticker": "MXF", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/NICHX.json b/fundlab/cef_cache/NICHX.json
new file mode 100644
index 0000000..da77185
--- /dev/null
+++ b/fundlab/cef_cache/NICHX.json
@@ -0,0 +1 @@
+{"sym": "NICHX", "cik": 1736510, "name": "Variant Alternative Income Fund", "bdc": false, "n_tender": 34, "ticker": "NICHX", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/NML.json b/fundlab/cef_cache/NML.json
new file mode 100644
index 0000000..d98818f
--- /dev/null
+++ b/fundlab/cef_cache/NML.json
@@ -0,0 +1 @@
+{"sym": "NML", "cik": 1562051, "name": "Neuberger Energy Infrastructure & Income Fund Inc.", "bdc": false, "n_tender": 0, "ticker": "NML", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/NXG.json b/fundlab/cef_cache/NXG.json
new file mode 100644
index 0000000..e215226
--- /dev/null
+++ b/fundlab/cef_cache/NXG.json
@@ -0,0 +1 @@
+{"sym": "NXG", "cik": 1506488, "name": "NXG NextGen Infrastructure Income Fund", "bdc": false, "n_tender": 0, "ticker": "NXG", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/PDX.json b/fundlab/cef_cache/PDX.json
new file mode 100644
index 0000000..0e4449d
--- /dev/null
+++ b/fundlab/cef_cache/PDX.json
@@ -0,0 +1 @@
+{"sym": "PDX", "cik": 1756908, "name": "PIMCO Dynamic Income Strategy Fund", "bdc": false, "n_tender": 0, "ticker": "PDX", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/PEO.json b/fundlab/cef_cache/PEO.json
new file mode 100644
index 0000000..e921e73
--- /dev/null
+++ b/fundlab/cef_cache/PEO.json
@@ -0,0 +1 @@
+{"sym": "PEO", "cik": 216851, "name": "ADAMS NATURAL RESOURCES FUND, INC.", "bdc": false, "n_tender": 0, "ticker": "PEO", "report_filed": "2026-02-20", "ncols": 5, "nav_beg": [24.21, 24.83, 25.85, 19.22, 13.76], "nav_end": [24.09, 24.21, 24.83, 25.85, 19.22], "mkt_end": [21.74, 21.74, 20.63, 21.8, 16.52], "dist_tot": [2.05, 1.77, 1.35, 1.63, 0.91], "divs": [-0.56, -0.65, -0.65, -0.79, -0.56], "gains": [-1.49, -1.12, -0.7, -0.84, -0.35], "roc": [0.0, 0.0, 0.0, 0.0, 0.0], "ops": [2.03, 1.27, 0.4, 8.37, 6.41], "share_div": 0.4163424124513619, "share_gains": 0.5836575875486382, "share_roc": 0.0}
\ No newline at end of file
diff --git a/fundlab/cef_cache/RMT.json b/fundlab/cef_cache/RMT.json
new file mode 100644
index 0000000..71e34c7
--- /dev/null
+++ b/fundlab/cef_cache/RMT.json
@@ -0,0 +1 @@
+{"sym": "RMT", "cik": 912147, "name": "ROYCE MICRO-CAP TRUST, INC.", "bdc": false, "n_tender": 2, "ticker": "RMT", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/SCD.json b/fundlab/cef_cache/SCD.json
new file mode 100644
index 0000000..ff766a9
--- /dev/null
+++ b/fundlab/cef_cache/SCD.json
@@ -0,0 +1 @@
+{"sym": "SCD", "cik": 1270131, "name": "LMP CAPITAL & INCOME FUND INC.", "bdc": false, "n_tender": 0, "ticker": "SCD", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/SRV.json b/fundlab/cef_cache/SRV.json
new file mode 100644
index 0000000..f39f3e6
--- /dev/null
+++ b/fundlab/cef_cache/SRV.json
@@ -0,0 +1 @@
+{"sym": "SRV", "cik": 1400897, "name": "NXG Cushing Midstream Energy Fund", "bdc": false, "n_tender": 0, "ticker": "SRV", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/STEW.json b/fundlab/cef_cache/STEW.json
new file mode 100644
index 0000000..111f502
--- /dev/null
+++ b/fundlab/cef_cache/STEW.json
@@ -0,0 +1 @@
+{"sym": "STEW", "cik": 102426, "name": "SRH Total Return Fund, Inc.", "bdc": false, "n_tender": 1, "ticker": "STEW", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/TWN.json b/fundlab/cef_cache/TWN.json
new file mode 100644
index 0000000..e7cc398
--- /dev/null
+++ b/fundlab/cef_cache/TWN.json
@@ -0,0 +1 @@
+{"sym": "TWN", "cik": 804123, "name": "TAIWAN FUND INC", "bdc": false, "n_tender": 0, "ticker": "TWN", "report_filed": "2025-11-05", "ncols": 5, "nav_beg": [53.78, 39.73, 29.96, 42.83, 28.79], "nav_end": [59.13, 53.78, 39.73, 29.96, 42.83], "mkt_end": [50.25, 44.73, 31.1, 25.2, 35.83], "dist_tot": [7.399999999999999, 0.4399999999999977, 0.0, 2.9199999999999946, 3.3100000000000023], "divs": [0.0, 0.0, 0.0, -0.48, -0.38], "gains": [-7.4, -0.44, 0.0, -2.44, -2.93], "roc": [0.0, 0.0, 0.0, 0.0, 0.0], "ops": [11.92, 13.56, 9.73, -9.95, 17.35], "share_div": 0.061122956645344735, "share_gains": 0.9388770433546558, "share_roc": 0.0}
\ No newline at end of file
diff --git a/fundlab/cef_cache/TYG.json b/fundlab/cef_cache/TYG.json
new file mode 100644
index 0000000..cbb81f1
--- /dev/null
+++ b/fundlab/cef_cache/TYG.json
@@ -0,0 +1 @@
+{"sym": "TYG", "cik": 1268533, "name": "TORTOISE ENERGY INFRASTRUCTURE CORP", "bdc": false, "n_tender": 17, "ticker": "TYG", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_cache/VCRDX.json b/fundlab/cef_cache/VCRDX.json
new file mode 100644
index 0000000..b231ad8
--- /dev/null
+++ b/fundlab/cef_cache/VCRDX.json
@@ -0,0 +1 @@
+{"sym": "VCRDX", "cik": 1812286, "name": "Harrison Street Infrastructure Income Fund", "bdc": false, "n_tender": 9, "ticker": "VCRDX", "error": "no per-share table found in the 3 latest reports"}
\ No newline at end of file
diff --git a/fundlab/cef_merged.json b/fundlab/cef_merged.json
new file mode 100644
index 0000000..2f8649f
--- /dev/null
+++ b/fundlab/cef_merged.json
@@ -0,0 +1,398 @@
+{
+ "TWN": {
+ "char_actual": 0.66,
+ "shares": {
+ "share_div": 0.061122956645344735,
+ "share_gains": 0.9388770433546558,
+ "share_roc": 0.0
+ },
+ "disc_now_approx": -0.1502,
+ "n_tender": 0,
+ "bdc": false,
+ "report_filed": "2025-11-05",
+ "mkt_end": [
+ 50.25,
+ 44.73,
+ 31.1,
+ 25.2,
+ 35.83
+ ],
+ "nav_end": [
+ 59.13,
+ 53.78,
+ 39.73,
+ 29.96,
+ 42.83
+ ]
+ },
+ "PEO": {
+ "char_actual": 0.45,
+ "shares": {
+ "share_div": 0.4163424124513619,
+ "share_gains": 0.5836575875486382,
+ "share_roc": 0.0
+ },
+ "disc_now_approx": -0.0976,
+ "n_tender": 0,
+ "bdc": false,
+ "report_filed": "2026-02-20",
+ "mkt_end": [
+ 21.74,
+ 21.74,
+ 20.63,
+ 21.8,
+ 16.52
+ ],
+ "nav_end": [
+ 24.09,
+ 24.21,
+ 24.83,
+ 25.85,
+ 19.22
+ ]
+ },
+ "KF": {
+ "char_actual": 0.54,
+ "shares": {
+ "share_div": 0.2741935483870966,
+ "share_gains": 0.7244039270687233,
+ "share_roc": 0.0014025245441795222
+ },
+ "disc_now_approx": -0.1442,
+ "n_tender": 0,
+ "bdc": false,
+ "report_filed": "2026-08-27",
+ "mkt_end": [
+ 75.21,
+ 26.93,
+ 24.13,
+ 23.14,
+ 24.35
+ ],
+ "nav_end": [
+ 87.88,
+ 30.87,
+ 28.78,
+ 26.52,
+ 28.54
+ ]
+ },
+ "LENDX": {
+ "char_actual": 0.47,
+ "shares": {
+ "share_div": 0.4533483822422874,
+ "share_gains": 0.40481565086531224,
+ "share_roc": 0.1418359668924003
+ },
+ "disc_now_approx": null,
+ "n_tender": 40,
+ "bdc": false,
+ "report_filed": "2026-05-08",
+ "mkt_end": [],
+ "nav_end": [
+ 46.13,
+ 46.09,
+ 46.4,
+ 48.79,
+ 51.23
+ ]
+ },
+ "EMO": {
+ "char_actual": 0.4,
+ "shares": {
+ "share_div": 0.6651270207852193,
+ "share_gains": 0.0,
+ "share_roc": 0.33487297921478054
+ },
+ "disc_now_approx": -0.0681,
+ "n_tender": 6,
+ "bdc": false,
+ "report_filed": "2026-01-27",
+ "mkt_end": [
+ 45.55,
+ 50.49,
+ 34.5,
+ 30.43,
+ 21.65
+ ],
+ "nav_end": [
+ 48.88,
+ 55.82,
+ 38.75,
+ 35.97,
+ 26.53
+ ]
+ },
+ "EMF": {
+ "char_actual": 0.4,
+ "shares": {
+ "share_div": 0.5065502183406113,
+ "share_gains": 0.4934497816593886,
+ "share_roc": 0.0
+ },
+ "disc_now_approx": -0.1158,
+ "n_tender": 0,
+ "bdc": false,
+ "report_filed": "2025-10-30",
+ "mkt_end": [
+ 15.27,
+ 12.81,
+ 11.71,
+ 11.85,
+ 17.89
+ ],
+ "nav_end": [
+ 17.27,
+ 14.81,
+ 13.63,
+ 13.72,
+ 20.09
+ ]
+ },
+ "ADX": {
+ "char_actual": 0.65,
+ "shares": {
+ "share_div": 0.08865979381443298,
+ "share_gains": 0.9113402061855671,
+ "share_roc": 0.0
+ },
+ "disc_now_approx": -0.0566,
+ "n_tender": 0,
+ "bdc": false,
+ "report_filed": "2026-02-20",
+ "mkt_end": [
+ 23.32,
+ 20.2,
+ 17.71,
+ 14.54,
+ 19.41
+ ],
+ "nav_end": [
+ 24.72,
+ 22.64,
+ 20.56,
+ 17.38,
+ 22.5
+ ]
+ },
+ "HQL": {
+ "char_actual": 0.49,
+ "shares": {
+ "share_div": 0.36363636363636365,
+ "share_gains": 0.6098484848484849,
+ "share_roc": 0.026515151515151516
+ },
+ "disc_now_approx": -0.1061,
+ "n_tender": 0,
+ "bdc": false,
+ "report_filed": "2025-12-08",
+ "mkt_end": [
+ 15.51,
+ 15.08,
+ 12.47,
+ 13.66,
+ 20.8
+ ],
+ "nav_end": [
+ 17.35,
+ 16.38,
+ 15.0,
+ 15.49,
+ 21.22
+ ]
+ },
+ "HQH": {
+ "char_actual": 0.51,
+ "shares": {
+ "share_div": 0.3466257668711656,
+ "share_gains": 0.583844580777096,
+ "share_roc": 0.06952965235173823
+ },
+ "disc_now_approx": -0.0629,
+ "n_tender": 0,
+ "bdc": false,
+ "report_filed": "2025-12-08",
+ "mkt_end": [
+ 18.46,
+ 18.62,
+ 15.55,
+ 17.28,
+ 25.57
+ ],
+ "nav_end": [
+ 19.7,
+ 20.33,
+ 18.84,
+ 19.36,
+ 25.47
+ ]
+ },
+ "AEF": {
+ "char_actual": 0.82,
+ "shares": {
+ "share_div": 0.19999999999999996,
+ "share_gains": 0.0,
+ "share_roc": 0.7999999999999999
+ },
+ "disc_now_approx": -0.0826,
+ "n_tender": 0,
+ "bdc": false,
+ "report_filed": "2026-03-09",
+ "mkt_end": [
+ 7.0,
+ 5.19,
+ 5.11,
+ 5.15,
+ 7.92
+ ],
+ "nav_end": [
+ 7.63,
+ 5.96,
+ 5.96,
+ 5.78,
+ 8.7
+ ]
+ },
+ "ASGI": {
+ "char_actual": 0.55,
+ "shares": {
+ "share_div": 0.3477751756440281,
+ "share_gains": 0.4508196721311476,
+ "share_roc": 0.20140515222482439
+ },
+ "disc_now_approx": -0.0177,
+ "n_tender": 0,
+ "bdc": false,
+ "report_filed": "2025-12-08",
+ "mkt_end": [
+ 21.13,
+ 20.21,
+ 16.1,
+ 15.73,
+ 19.93
+ ],
+ "nav_end": [
+ 21.51,
+ 21.17,
+ 19.16,
+ 18.93,
+ 22.27
+ ]
+ },
+ "AOD": {
+ "char_actual": 0.27,
+ "shares": {
+ "share_div": 0.8126361655773419,
+ "share_gains": 0.0,
+ "share_roc": 0.18736383442265794
+ },
+ "disc_now_approx": 0.0,
+ "n_tender": 0,
+ "bdc": false,
+ "report_filed": "2026-01-08",
+ "mkt_end": [
+ 11.63,
+ 10.16,
+ 8.4,
+ 8.92,
+ 12.01
+ ],
+ "nav_end": [
+ 11.63,
+ 11.15,
+ 9.9,
+ 10.05,
+ 12.95
+ ]
+ },
+ "AGD": {
+ "char_actual": 0.27,
+ "shares": {
+ "share_div": 0.8126361655773419,
+ "share_gains": 0.0,
+ "share_roc": 0.18736383442265794
+ },
+ "disc_now_approx": 0.0,
+ "n_tender": 0,
+ "bdc": false,
+ "report_filed": "2026-01-08",
+ "mkt_end": [
+ 11.63,
+ 10.16,
+ 8.4,
+ 8.92,
+ 12.01
+ ],
+ "nav_end": [
+ 11.63,
+ 11.15,
+ 9.9,
+ 10.05,
+ 12.95
+ ]
+ },
+ "EOD": {
+ "char_actual": 0.58,
+ "shares": {
+ "share_div": 0.47131147540983603,
+ "share_gains": 0.0,
+ "share_roc": 0.5286885245901639
+ },
+ "disc_now_approx": null,
+ "n_tender": 0,
+ "bdc": false,
+ "report_filed": "2026-01-05",
+ "mkt_end": [],
+ "nav_end": [
+ 6.44,
+ 5.53,
+ 4.52,
+ 4.57,
+ 6.03
+ ]
+ },
+ "CSQ": {
+ "char_actual": 0.53,
+ "shares": {
+ "share_div": 0.36713286713286714,
+ "share_gains": 0.45454545454545464,
+ "share_roc": 0.17832167832167836
+ },
+ "disc_now_approx": null,
+ "n_tender": 5,
+ "bdc": false,
+ "report_filed": "2025-06-27",
+ "mkt_end": [],
+ "nav_end": [
+ 9.32,
+ 10.04,
+ 8.99
+ ]
+ },
+ "HERZ": {
+ "char_actual": 0.94,
+ "shares": {
+ "share_div": 0.0,
+ "share_gains": 0.2098765432098765,
+ "share_roc": 0.7901234567901234
+ },
+ "disc_now_approx": -0.0453,
+ "n_tender": 0,
+ "bdc": false,
+ "report_filed": "2025-09-08",
+ "mkt_end": [
+ 2.53,
+ 2.35,
+ 3.95,
+ 4.01,
+ 6.27
+ ],
+ "nav_end": [
+ 2.65,
+ 3.1,
+ 4.98,
+ 4.63,
+ 7.06
+ ]
+ }
+}
\ No newline at end of file
diff --git a/tests/test_fundlab.py b/tests/test_fundlab.py
index fda8921..a6d259d 100644
--- a/tests/test_fundlab.py
+++ b/tests/test_fundlab.py
@@ -610,6 +610,186 @@ def test_cef() -> None:
{"aaa", "bbb", "ccc"} <= set(sel), str(sel))
+
+# CEF annual-report per-share highlights parser
+# ----------------------------------------------------------------------
+
+_DOC_FRANKLIN = """\
+Financial highlights For a common share of capital stock outstanding
+throughout each year ended November 30: 2025 1 2024 1 2023 1 2022 1 2021 1
+Net
+asset value, beginning of year
+$55.82
+$38.75
+$35.97
+$26.53
+$17.13
+Income
+(loss) from operations:
+Net
+investment loss
+(0.13
+)
+(0.51
+)
+(0.69
+)
+(0.43
+)
+(0.36
+)
+Net
+realized and unrealized gain (loss)
+(2.12
+)
+20.58
+5.80
+11.65
+11.01
+Total
+income (loss) from operations
+(2.25)
+20.07
+5.11
+11.22
+10.65
+Less
+distributions to common
+shareholders
+from:
+Dividends
+(0.81
+)
+(3.00
+)
+(2.37
+)
+(1.92
+)
+(0.54
+)
+Return of capital
+(3.42
+)
+(0.93
+)
+Total distributions to common shareholders
+4.23
+3.00
+2.37
+1.92
+1.47
+Anti-dilutive impact of repurchase plan 0.04 0.14 0.22
+Dilutive impact of rights offering (0.46) (0.27) (0.13) (0.09) (0.13)
+Anti-dilutive impact of tender offer plan 0.00 0.00 0.00 0.00 0.00
+Net asset value, end of year $48.88 $55.82 $38.75 $35.97 $26.53
+Market price, end of year $45.55 $50.49 $34.50 $30.43 $21.65
+"""
+
+_DOC_CLASSIC = """\
+Selected Per Share Data Net asset value, beginning of year $ 53.78 $ 39.73
+$ 29.96 $ 42.83 $ 28.79 Income from Investment Operations: Net investment
+income (a) 0.30 0.43 0.41 (b) 0.33 0.04 Net realized and unrealized gain
+(loss) on investments and foreign currency transactions 11.62 13.13 9.32 (c)
+(10.28) 17.31 Total from investment operations 11.92 13.56 9.73 (9.95) 17.35
+Less Distributions to Stockholders from: Net investment income (0.48) (0.38)
+Net realized gains (7.40) (0.44) (2.44) (2.93) Total distributions to
+stockholders (7.40) (0.44) (2.92) (3.31) Capital Share Transactions:
+Accretion (dilution) to net asset value, resulting from share repurchase
+program, tender offer or issuance of shares for the reinvestment of
+distributions from net investment income and net realized gains 0.83 0.93
+0.04 0.00 (d) 0.00 (d) Net asset value, end of year $ 59.13 $ 53.78 $ 39.73
+$ 29.96 $ 42.83 Market value, end of year $ 50.25 $ 44.73 $ 31.10 $ 25.20
+$ 35.83
+"""
+
+_DOC_ABRDN = """\
+PER SHARE OPERATING PERFORMANCE: Net asset value per common share, beginning
+of year $16.38 $15.00 $15.49 $21.22 $20.25 Net investment loss (c) (0.12)
+(0.07) (0.08) (0.12) (0.17) Net realized and unrealized gains/(losses) on
+investments, written options and foreign currency transactions 2.91 3.11 0.87
+(4.14) 2.83 Total from investment operations applicable to common
+shareholders 2.79 3.04 0.79 (4.26) 2.66 Distributions to common shareholders
+from: Net investment income (1.72) (0.69) (0.02) (0.03) (0.42) Net realized
+gains (0.10) (0.76) (1.26) (1.44) (1.27) Return of capital (0.21) Total
+distributions (1.82) (1.66) (1.28) (1.47) (1.69) Net asset value per common
+share, end of year $17.35 $16.38 $15.00 $15.49 $21.22 Market price, end of
+year $15.51 $15.08 $12.47 $13.66 $20.80
+"""
+
+_DOC_KOREA = """\
+The Korea Fund, Inc. Financial Highlights For a share of stock outstanding
+throughout each year: Year ended June 30, 2026 2025 2024 2023 2022 Net
+asset value, beginning of year $30.87 $28.78 $26.52 $28.54 $54.37 Investment
+Operations: Net investment income (1) (0.05) 0.02 0.09 0.19 0.32 Net realized
+and change in unrealized gain (loss) 58.44 2.39 2.08 1.06 (17.05) Total from
+investment operations 58.39 2.41 2.17 1.25 (16.73) Dividends and
+Distributions to Stockholders from: Net investment income (1.38) (0.45)
+(0.03) (2.05) Net realized gains (3.27) (7.06) Return of capital (0.02)
+Total dividends and distributions to stockholders (1.38) (0.45) (3.32) (9.11)
+Common Stock Transactions: Accretion to net asset value resulting from share
+repurchases and tender offer 0.13 0.09 0.05 0.01 Net asset value, end of
+year $87.88 $30.87 $28.78 $26.52 $28.54 Market price, end of year $75.21
+$26.93 $24.13 $23.14 $24.35
+"""
+
+
+def test_cef_annual() -> None:
+ from fundlab import cef_annual as cn
+
+ # Franklin style (EMO): td-split labels, ROC in cols 0 and 4,
+ # positive-printed total row
+ h = cn.parse_highlights(_DOC_FRANKLIN)
+ check("franklin parses 5 cols", h is not None and h["ncols"] == 5, str(h))
+ check("franklin div share ~66.5%",
+ abs(h["share_div"] - 8.64 / 12.99) < 0.005, str(h["share_div"]))
+ check("franklin gains zero", h["share_gains"] == 0.0)
+ check("franklin roc share ~33.5%",
+ abs(h["share_roc"] - 4.35 / 12.99) < 0.005, str(h["share_roc"]))
+ check("franklin FY-end discount ~-6.8%",
+ abs(h["mkt_end"][0] / h["nav_end"][0] - 1 - (-0.0681)) < 0.001)
+
+ # Classic CEF (TWN): printed total row is SHORT (a zero year dropped),
+ # so totals derive from the NAV identity; gains sit in 4 of 5 cols
+ # and NII-divs in the OTHER two
+ h = cn.parse_highlights(_DOC_CLASSIC)
+ check("classic parses 5 cols", h is not None and h["ncols"] == 5, str(h))
+ check("classic derived total col0 = 7.40",
+ abs(h["dist_tot"][0] - 7.40) < 0.01, str(h["dist_tot"]))
+ check("classic zero-year col2", abs(h["dist_tot"][2]) < 0.01,
+ str(h["dist_tot"]))
+ check("classic gains ~93.9%",
+ abs(h["share_gains"] - 13.21 / 14.07) < 0.005, str(h["share_gains"]))
+ check("classic NII ~6.1%",
+ abs(h["share_div"] - 0.86 / 14.07) < 0.005, str(h["share_div"]))
+
+ # abrdn style (HQL): "per common share" labels, single-value ROC row
+ h = cn.parse_highlights(_DOC_ABRDN)
+ check("abrdn parses 5 cols", h is not None and h["ncols"] == 5, str(h))
+ check("abrdn div ~36.4%",
+ abs(h["share_div"] - 2.88 / 7.92) < 0.005, str(h["share_div"]))
+ check("abrdn gains ~61.0%",
+ abs(h["share_gains"] - 4.83 / 7.92) < 0.005, str(h["share_gains"]))
+ check("abrdn roc ~2.7%",
+ abs(h["share_roc"] - 0.21 / 7.92) < 0.005, str(h["share_roc"]))
+
+ # Korea style (KF): "Total dividends and distributions" label (the
+ # divs regex must not grab the total row), accretion row in cols 1-4
+ h = cn.parse_highlights(_DOC_KOREA)
+ check("korea parses 5 cols", h is not None and h["ncols"] == 5, str(h))
+ check("korea div ~27.4%",
+ abs(h["share_div"] - 3.91 / 14.26) < 0.01, str(h["share_div"]))
+ check("korea gains ~72.4%",
+ abs(h["share_gains"] - 10.33 / 14.26) < 0.01,
+ str(h["share_gains"]))
+
+ # unreconcilable table must be rejected, not guessed
+ bad = _DOC_CLASSIC.replace("(7.40) (0.44) (2.44) (2.93)",
+ "(7.99) (0.44) (2.44) (2.93)")
+ check("unreconcilable table rejected",
+ cn.parse_highlights(bad) is None)
+
+
def main() -> int:
test_pool()
test_text_and_objective()
@@ -625,6 +805,7 @@ def main() -> int:
test_taxplan()
test_drawdown()
test_cef()
+ test_cef_annual()
test_edgar_live()
print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0
@@ -632,3 +813,6 @@ def main() -> int:
if __name__ == "__main__":
sys.exit(main())
+
+
+# ----------------------------------------------------------------------
\ No newline at end of file
|