RIC-vs-pass-through verification + return-of-capital detector

fundlab/rocdetect.py: for a fund's latest N-CSR annual report,
parses the per-share "Financial Highlights" distribution table
(NII / gains / (tax) return of capital / total), with
whitespace normalization, dollar-basis and fiscal-year-column
rejection, per-column arithmetic placement of short rows
(NII+gains+ROC==total), and family-report disambiguation by
current-NAV match. SEC 503 backoff, per-fund cache in roc_cache/.

Verified over the 38 core funds:
- HMEZX: 18.3% of 5y distributions were return of capital
  (0.62 + 0.37 per share in 2 of 5 fiscal years) - reinforces
  the IRA placement.
- 17 funds verified zero ROC (AFLIX, ANGLX, ATESX, BTMIX, the EV
  macro funds, FHCOX/FHMIX, LCORX/LCRIX, PMAIX/PMFKX, SVARX,
  WMNUX).
- 14 unverified (non-standard report layouts - prototype parser),
  PMORX/QCMMRX tickers not EDGAR-resolvable.

Also verified (form-type history on EDGAR): CVSIX, QSPNX, MBXIX,
ATRFX, ATESX, EAGMX, PBAIX, WISEX, EBSAX all file NPORT-P + N-CSR
+ 497 -> registered RICs, not pass-throughs; the whole 497-filer
universe is registered, so no candidate can pass through capital
losses. Proof case for ROC: PGSIX per-share table shows a tax
return-of-capital line and NAV eroded 11.87 -> 7.88 (-34%) over
5 fiscal years while paying out every year.

RESEARCH.md: full write-up. No app change (analysis artifact).
This commit is contained in:
Greg Pomerantz 2026-08-27 16:04:59 -04:00
parent 9458e316cb
commit 620e2499ac
40 changed files with 1823 additions and 0 deletions

View File

@ -130,6 +130,62 @@ not a gate.
4. CEF universe (485/N-2 filers) - separate pass; CEFs have
premium/discount dynamics the NAV screen can't see.
### RIC vs pass-through + return-of-capital check (2026-08-27)
User asked (1) whether the long-short alternatives are RICs or
pass-throughs, and (2) whether mutual funds can distribute
non-taxable "return of capital".
**RIC vs pass-through - verified on EDGAR (form-type history):**
CVSIX, QSPNX, MBXIX, ATRFX, ATESX, EAGMX, PBAIX, WISEX, EBSAX ALL
file NPORT-P + N-CSR + N-CSRS + N-CEN + 497/497K - i.e. registered
40 Act open-end funds = RICs. MBXIX/ATRFX (the "hedge strategy" I
classes) are in a registered trust (Mutual Fund Series Trust) even
though they sound like hedge funds. 1120-R tax returns are filed with
the IRS, not EDGAR, so their absence there is expected. The whole
2,384-fund universe is 497-filers, so NO candidate is a pass-through:
none can pass capital losses to shareholders; the only internal
tax benefit is the harvest reserve (offsets the fund's FUTURE gain
distributions). K-1 loss pass-throughs (MLPs, private credit) are
outside the universe and IRA-bound anyway.
**Return of capital - yes, and it happens in the candidate pool:**
ROC = distribution characterized as a return of the shareholder's
OWN basis. Non-taxable until basis is exhausted, then taxed as a
capital gain - i.e. DEFERRED, not exempt. It is also a red flag:
the fund is paying back principal. The definitive source is the
per-share "Financial Highlights" table in the N-CSR annual report
("Distributions from: NII / gains / (tax) return of capital /
total").
fundlab/rocdetect.py parses that table (whitespace-normalized,
dollar-basis and date-column rejection, lone values placed by
per-column arithmetic NII+gains+ROC==total, family reports
disambiguated by current-NAV match). Run over the 38 core funds
(cached per fund in roc_cache/):
- HMEZX: 18.3% of 5y distributions were ROC ($0.62 and $0.37 per
share in 2 of 5 fiscal years; $1.01/yr distributions otherwise
from NII) - reinforces the IRA placement.
- 17 funds verified ZERO ROC: AFLIX, ANGLX, ATESX, BTMIX, EAGMX,
EGRIX, EGRSX, ETSIX, FHCOX, FHMIX, LCORX, LCRIX, PMAIX, PMFKX,
SVARX, WMNUX.
- 14 unverified (non-standard report layouts: Vanguard MERVX,
JPMorgan JLPSX, AQR QSPNX, PGIM SCFZX, Columbia COSIX, ...):
parser is a prototype; per-family layouts not all covered.
- PMORX/QCMMRX: ticker not resolvable via browse-edgar.
Separate proof case found earlier while checking the NAV-decline
heuristic: PGSIX (Putnam Mortgage Securities, a drawdown-screen
candidate) - per-share table shows a "Tax return of capital" line
($0.35/sh in FY2021: NII $0.14 + ROC $0.35 = $0.49 total) and the
NAV eroded $11.87 -> $7.88 (-34%) over the 5 shown fiscal years
while paying out every year. Classic constant-distribution principal
erosion.
Placement consequence: ROC does NOT help the taxable-account case -
basis deferral in a taxable account is what a traditional IRA gives
you anyway, and it signals principal erosion. The taxable-friendly
character remains qualified dividends / LTCG.
### Price-appreciation vs. payout split (fundlab/taxsplit.py, 2026-08-27)
Follow-up to the tax-location plan: the user pointed out that fund
NAV appreciation is ALSO a capital gain (LTCG on a >1y sale) - the

View File

@ -0,0 +1,22 @@
{
"sym": "AFLIX",
"cik": "0001552947",
"trust": "Two Roads Shared Trust",
"filing": "2026-07-09",
"table": {
"nii": [
-0.09
],
"gains": [],
"roc": [
0.0
],
"tot": [
-0.78
],
"nav": [],
"ncols": 1
},
"roc_share_5y": -0.0,
"nav_change_5y": null
}

View File

@ -0,0 +1,9 @@
{
"sym": "AGUAX",
"cik": "0000809593",
"trust": "AMERICAN BEACON FUNDS",
"filing": "2026-06-08",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
}

View File

@ -0,0 +1,24 @@
{
"sym": "ANGLX",
"cik": "0001612930",
"trust": "Angel Oak Funds Trust",
"filing": "2026-04-08",
"table": {
"nii": [
-0.47
],
"gains": [],
"roc": [
0.0
],
"tot": [
-0.47
],
"nav": [
8.78
],
"ncols": 1
},
"roc_share_5y": -0.0,
"nav_change_5y": null
}

View File

@ -0,0 +1,22 @@
{
"sym": "ATESX",
"cik": "0001644419",
"trust": "Northern Lights Fund Trust IV",
"filing": "2026-08-10",
"table": {
"nii": [
-1.14
],
"gains": [],
"roc": [
0.0
],
"tot": [
-1.14
],
"nav": [],
"ncols": 1
},
"roc_share_5y": -0.0,
"nav_change_5y": null
}

View File

@ -0,0 +1,9 @@
{
"sym": "ATRFX",
"cik": "0001355064",
"trust": "MUTUAL FUND SERIES TRUST",
"filing": "2026-06-08",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
}

View File

@ -0,0 +1,40 @@
{
"sym": "BTMIX",
"cik": "0001282693",
"trust": "BAIRD FUNDS INC",
"filing": "2026-03-04",
"table": {
"nii": [
-0.31,
-0.29,
-0.25,
-0.13,
-0.09
],
"gains": [],
"roc": [
0.0,
0.0,
0.0,
0.0,
0.0
],
"tot": [
-0.31,
-0.29,
-0.25,
-0.13,
-0.09
],
"nav": [
10.09,
9.96,
9.96,
9.83,
10.38
],
"ncols": 5
},
"roc_share_5y": -0.0,
"nav_change_5y": -0.028
}

View File

@ -0,0 +1,9 @@
{
"sym": "COIAX",
"cik": "0000804239",
"trust": "SEI INSTITUTIONAL MANAGED TRUST",
"filing": "2025-12-08",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
}

View File

@ -0,0 +1,9 @@
{
"sym": "COSIX",
"cik": "0000773757",
"trust": "COLUMBIA FUNDS SERIES TRUST I",
"filing": "2026-08-06",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
}

View File

@ -0,0 +1,9 @@
{
"sym": "CVSIX",
"cik": "0000826732",
"trust": "CALAMOS INVESTMENT TRUST/IL",
"filing": "2025-12-29",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
}

View File

@ -0,0 +1,9 @@
{
"sym": "DMSZX",
"cik": "0001688680",
"trust": "Brinker Capital Destinations Trust",
"filing": "2026-05-06",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
}

View File

@ -0,0 +1,20 @@
{
"sym": "DULTX",
"cik": "0000230173",
"trust": "DELAWARE GROUP CASH RESERVE",
"filing": "2026-06-09",
"table": {
"nii": [
2.0
],
"gains": [],
"roc": [
0.0
],
"tot": [],
"nav": [],
"ncols": 1
},
"roc_share_5y": null,
"nav_change_5y": null
}

View File

@ -0,0 +1,22 @@
{
"sym": "EAGMX",
"cik": "0000745463",
"trust": "Eaton Vance Mutual Funds Trust",
"filing": "2026-03-31",
"table": {
"nii": [
-0.73
],
"gains": [],
"roc": [
0.0
],
"tot": [
-2.05
],
"nav": [],
"ncols": 1
},
"roc_share_5y": -0.0,
"nav_change_5y": null
}

View File

@ -0,0 +1,22 @@
{
"sym": "EGRIX",
"cik": "0000745463",
"trust": "Eaton Vance Mutual Funds Trust",
"filing": "2026-03-31",
"table": {
"nii": [
-0.73
],
"gains": [],
"roc": [
0.0
],
"tot": [
-2.05
],
"nav": [],
"ncols": 1
},
"roc_share_5y": -0.0,
"nav_change_5y": null
}

View File

@ -0,0 +1,22 @@
{
"sym": "EGRSX",
"cik": "0000745463",
"trust": "Eaton Vance Mutual Funds Trust",
"filing": "2026-03-31",
"table": {
"nii": [
-0.73
],
"gains": [],
"roc": [
0.0
],
"tot": [
-2.05
],
"nav": [],
"ncols": 1
},
"roc_share_5y": -0.0,
"nav_change_5y": null
}

View File

@ -0,0 +1,22 @@
{
"sym": "ETSIX",
"cik": "0000745463",
"trust": "Eaton Vance Mutual Funds Trust",
"filing": "2026-03-31",
"table": {
"nii": [
-0.73
],
"gains": [],
"roc": [
0.0
],
"tot": [
-2.05
],
"nav": [],
"ncols": 1
},
"roc_share_5y": -0.0,
"nav_change_5y": null
}

View File

@ -0,0 +1,32 @@
{
"sym": "FEMDX",
"cik": "0001124459",
"trust": "Franklin Global Trust",
"filing": "2025-09-26",
"table": {
"nii": [
-0.53,
-0.34,
-0.9
],
"gains": [],
"roc": [
0.0,
0.0,
0.0,
0.0,
0.0
],
"tot": [],
"nav": [
12.35,
11.55,
10.46,
9.91,
11.73
],
"ncols": 5
},
"roc_share_5y": null,
"nav_change_5y": 0.053
}

View File

@ -0,0 +1,32 @@
{
"sym": "FHCOX",
"cik": "0001707560",
"trust": "Federated Hermes Adviser Series",
"filing": "2026-08-25",
"table": {
"nii": [
-0.02,
-0.01,
-0.01
],
"gains": [],
"roc": [
0.0,
0.0,
0.0,
0.0,
0.0
],
"tot": [
-1.21,
-0.81,
-0.63,
-0.34,
-0.61
],
"nav": [],
"ncols": 5
},
"roc_share_5y": -0.0,
"nav_change_5y": null
}

View File

@ -0,0 +1,32 @@
{
"sym": "FHMIX",
"cik": "0001707560",
"trust": "Federated Hermes Adviser Series",
"filing": "2026-08-25",
"table": {
"nii": [
-0.02,
-0.01,
-0.01
],
"gains": [],
"roc": [
0.0,
0.0,
0.0,
0.0,
0.0
],
"tot": [
-1.21,
-0.81,
-0.63,
-0.34,
-0.61
],
"nav": [],
"ncols": 5
},
"roc_share_5y": -0.0,
"nav_change_5y": null
}

View File

@ -0,0 +1,9 @@
{
"sym": "HICOX",
"cik": "0000810744",
"trust": "COLORADO BONDSHARES A TAX EXEMPT FUND",
"filing": "2025-12-04",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
}

View File

@ -0,0 +1,39 @@
{
"sym": "HMEZX",
"cik": "0001354917",
"trust": "NEXPOINT FUNDS I",
"filing": "2025-09-08",
"table": {
"nii": [
-1.01,
-1.0,
-0.38,
-0.4
],
"gains": [],
"roc": [
-0.62,
-0.37,
0.0,
0.0,
0.0
],
"tot": [
-1.01,
-1.0,
-1.0,
-1.24,
-1.16
],
"nav": [
19.83,
19.71,
19.21,
19.89,
20.58
],
"ncols": 5
},
"roc_share_5y": 0.183,
"nav_change_5y": -0.036
}

View File

@ -0,0 +1,9 @@
{
"sym": "JLPSX",
"cik": "0001217286",
"trust": "JPMorgan Trust I",
"filing": "2026-05-07",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
}

View File

@ -0,0 +1,20 @@
{
"sym": "LAMHX",
"cik": "0000887194",
"trust": "LORD ABBETT RESEARCH FUND INC",
"filing": "2026-02-04",
"table": {
"nii": [
24.79
],
"gains": [],
"roc": [
0.0
],
"tot": [],
"nav": [],
"ncols": 1
},
"roc_share_5y": null,
"nav_change_5y": null
}

View File

@ -0,0 +1,32 @@
{
"sym": "LCORX",
"cik": "0001511699",
"trust": "Managed Portfolio Series",
"filing": "2026-08-04",
"table": {
"nii": [
-0.02,
-0.04,
-0.06,
-0.03,
-0.09
],
"gains": [],
"roc": [
0.0,
0.0,
0.0,
0.0,
0.0
],
"tot": [
-1.2
],
"nav": [
18.73
],
"ncols": 5
},
"roc_share_5y": -0.0,
"nav_change_5y": null
}

View File

@ -0,0 +1,32 @@
{
"sym": "LCRIX",
"cik": "0001511699",
"trust": "Managed Portfolio Series",
"filing": "2026-08-04",
"table": {
"nii": [
-0.02,
-0.04,
-0.06,
-0.03,
-0.09
],
"gains": [],
"roc": [
0.0,
0.0,
0.0,
0.0,
0.0
],
"tot": [
-1.2
],
"nav": [
18.73
],
"ncols": 5
},
"roc_share_5y": -0.0,
"nav_change_5y": null
}

View File

@ -0,0 +1,30 @@
{
"sym": "LPXAX",
"cik": "0001652200",
"trust": "Cohen & Steers Low Duration Preferred & Income Fund, Inc.",
"filing": "2026-07-02",
"table": {
"nii": [
-0.51
],
"gains": [],
"roc": [
-0.0,
0.0,
0.0,
0.0,
0.0
],
"tot": [],
"nav": [
9.59,
9.43,
9.23,
8.87,
9.36
],
"ncols": 5
},
"roc_share_5y": null,
"nav_change_5y": 0.025
}

View File

@ -0,0 +1,9 @@
{
"sym": "MBXIX",
"cik": "0001355064",
"trust": "MUTUAL FUND SERIES TRUST",
"filing": "2026-06-08",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
}

View File

@ -0,0 +1,9 @@
{
"sym": "MERVX",
"cik": "0001208133",
"trust": "MERGER FUND VL",
"filing": "2026-03-05",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
}

View File

@ -0,0 +1,31 @@
{
"sym": "PMAIX",
"cik": "0002042316",
"trust": "Victory Portfolios IV",
"filing": "2026-06-08",
"table": {
"nii": [
-0.04,
-0.04
],
"gains": [],
"roc": [
0.0,
0.0,
0.0,
0.0,
0.0
],
"tot": [
-6.32,
-1.99,
-1.1,
-1.06,
-6.45
],
"nav": [],
"ncols": 5
},
"roc_share_5y": -0.0,
"nav_change_5y": null
}

View File

@ -0,0 +1,31 @@
{
"sym": "PMFKX",
"cik": "0002042316",
"trust": "Victory Portfolios IV",
"filing": "2026-06-08",
"table": {
"nii": [
-0.04,
-0.04
],
"gains": [],
"roc": [
0.0,
0.0,
0.0,
0.0,
0.0
],
"tot": [
-6.32,
-1.99,
-1.1,
-1.06,
-6.45
],
"nav": [],
"ncols": 5
},
"roc_share_5y": -0.0,
"nav_change_5y": null
}

View File

@ -0,0 +1,9 @@
{
"sym": "QSPNX",
"cik": "0001444822",
"trust": "AQR Funds",
"filing": "2026-02-26",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
}

View File

@ -0,0 +1,9 @@
{
"sym": "RCTIX",
"cik": "0001516523",
"trust": "Advisers Investment Trust",
"filing": "2025-11-26",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
}

View File

@ -0,0 +1,9 @@
{
"sym": "SAFEX",
"cik": "0001257927",
"trust": "WEITZ FUNDS",
"filing": "2026-05-27",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
}

View File

@ -0,0 +1,9 @@
{
"sym": "SCFZX",
"cik": "0000887991",
"trust": "PRUDENTIAL INVESTMENT PORTFOLIOS 8",
"filing": "2025-12-03",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
}

View File

@ -0,0 +1,24 @@
{
"sym": "SVARX",
"cik": "0001556505",
"trust": "Advisors Preferred Trust",
"filing": "2026-03-10",
"table": {
"nii": [
-3.72
],
"gains": [],
"roc": [
0.0
],
"tot": [
-3.72
],
"nav": [
38.76
],
"ncols": 1
},
"roc_share_5y": -0.0,
"nav_change_5y": null
}

View File

@ -0,0 +1,9 @@
{
"sym": "USMSX",
"cik": "0001659326",
"trust": "JPMORGAN TRUST IV",
"filing": "2026-05-07",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
}

View File

@ -0,0 +1,22 @@
{
"sym": "WMNUX",
"cik": "0001545440",
"trust": "Ultimus Managers Trust",
"filing": "2026-08-03",
"table": {
"nii": [
-0.31
],
"gains": [],
"roc": [
0.0
],
"tot": [
-0.31
],
"nav": [],
"ncols": 1
},
"roc_share_5y": -0.0,
"nav_change_5y": null
}

718
fundlab/roc_results.json Normal file
View File

@ -0,0 +1,718 @@
{
"AFLIX": {
"sym": "AFLIX",
"cik": "0001552947",
"trust": "Two Roads Shared Trust",
"filing": "2026-07-09",
"table": {
"nii": [
-0.09
],
"gains": [],
"roc": [
0.0
],
"tot": [
-0.78
],
"nav": [],
"ncols": 1
},
"roc_share_5y": -0.0,
"nav_change_5y": null
},
"AGUAX": {
"sym": "AGUAX",
"cik": "0000809593",
"trust": "AMERICAN BEACON FUNDS",
"filing": "2026-06-08",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
},
"ANGLX": {
"sym": "ANGLX",
"cik": "0001612930",
"trust": "Angel Oak Funds Trust",
"filing": "2026-04-08",
"table": {
"nii": [
-0.47
],
"gains": [],
"roc": [
0.0
],
"tot": [
-0.47
],
"nav": [
8.78
],
"ncols": 1
},
"roc_share_5y": -0.0,
"nav_change_5y": null
},
"ATESX": {
"sym": "ATESX",
"cik": "0001644419",
"trust": "Northern Lights Fund Trust IV",
"filing": "2026-08-10",
"table": {
"nii": [
-1.14
],
"gains": [],
"roc": [
0.0
],
"tot": [
-1.14
],
"nav": [],
"ncols": 1
},
"roc_share_5y": -0.0,
"nav_change_5y": null
},
"ATRFX": {
"sym": "ATRFX",
"cik": "0001355064",
"trust": "MUTUAL FUND SERIES TRUST",
"filing": "2026-06-08",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
},
"BTMIX": {
"sym": "BTMIX",
"cik": "0001282693",
"trust": "BAIRD FUNDS INC",
"filing": "2026-03-04",
"table": {
"nii": [
-0.31,
-0.29,
-0.25,
-0.13,
-0.09
],
"gains": [],
"roc": [
0.0,
0.0,
0.0,
0.0,
0.0
],
"tot": [
-0.31,
-0.29,
-0.25,
-0.13,
-0.09
],
"nav": [
10.09,
9.96,
9.96,
9.83,
10.38
],
"ncols": 5
},
"roc_share_5y": -0.0,
"nav_change_5y": -0.028
},
"COIAX": {
"sym": "COIAX",
"cik": "0000804239",
"trust": "SEI INSTITUTIONAL MANAGED TRUST",
"filing": "2025-12-08",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
},
"COSIX": {
"sym": "COSIX",
"cik": "0000773757",
"trust": "COLUMBIA FUNDS SERIES TRUST I",
"filing": "2026-08-06",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
},
"CVSIX": {
"sym": "CVSIX",
"cik": "0000826732",
"trust": "CALAMOS INVESTMENT TRUST/IL",
"filing": "2025-12-29",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
},
"DMSZX": {
"sym": "DMSZX",
"cik": "0001688680",
"trust": "Brinker Capital Destinations Trust",
"filing": "2026-05-06",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
},
"DULTX": {
"sym": "DULTX",
"cik": "0000230173",
"trust": "DELAWARE GROUP CASH RESERVE",
"filing": "2026-06-09",
"table": {
"nii": [
2.0
],
"gains": [],
"roc": [
0.0
],
"tot": [],
"nav": [],
"ncols": 1
},
"roc_share_5y": null,
"nav_change_5y": null
},
"EAGMX": {
"sym": "EAGMX",
"cik": "0000745463",
"trust": "Eaton Vance Mutual Funds Trust",
"filing": "2026-03-31",
"table": {
"nii": [
-0.73
],
"gains": [],
"roc": [
0.0
],
"tot": [
-2.05
],
"nav": [],
"ncols": 1
},
"roc_share_5y": -0.0,
"nav_change_5y": null
},
"EGRIX": {
"sym": "EGRIX",
"cik": "0000745463",
"trust": "Eaton Vance Mutual Funds Trust",
"filing": "2026-03-31",
"table": {
"nii": [
-0.73
],
"gains": [],
"roc": [
0.0
],
"tot": [
-2.05
],
"nav": [],
"ncols": 1
},
"roc_share_5y": -0.0,
"nav_change_5y": null
},
"EGRSX": {
"sym": "EGRSX",
"cik": "0000745463",
"trust": "Eaton Vance Mutual Funds Trust",
"filing": "2026-03-31",
"table": {
"nii": [
-0.73
],
"gains": [],
"roc": [
0.0
],
"tot": [
-2.05
],
"nav": [],
"ncols": 1
},
"roc_share_5y": -0.0,
"nav_change_5y": null
},
"ETSIX": {
"sym": "ETSIX",
"cik": "0000745463",
"trust": "Eaton Vance Mutual Funds Trust",
"filing": "2026-03-31",
"table": {
"nii": [
-0.73
],
"gains": [],
"roc": [
0.0
],
"tot": [
-2.05
],
"nav": [],
"ncols": 1
},
"roc_share_5y": -0.0,
"nav_change_5y": null
},
"FEMDX": {
"sym": "FEMDX",
"cik": "0001124459",
"trust": "Franklin Global Trust",
"filing": "2025-09-26",
"table": {
"nii": [
-0.53,
-0.34,
-0.9
],
"gains": [],
"roc": [
0.0,
0.0,
0.0,
0.0,
0.0
],
"tot": [],
"nav": [
12.35,
11.55,
10.46,
9.91,
11.73
],
"ncols": 5
},
"roc_share_5y": null,
"nav_change_5y": 0.053
},
"FHCOX": {
"sym": "FHCOX",
"cik": "0001707560",
"trust": "Federated Hermes Adviser Series",
"filing": "2026-08-25",
"table": {
"nii": [
-0.02,
-0.01,
-0.01
],
"gains": [],
"roc": [
0.0,
0.0,
0.0,
0.0,
0.0
],
"tot": [
-1.21,
-0.81,
-0.63,
-0.34,
-0.61
],
"nav": [],
"ncols": 5
},
"roc_share_5y": -0.0,
"nav_change_5y": null
},
"FHMIX": {
"sym": "FHMIX",
"cik": "0001707560",
"trust": "Federated Hermes Adviser Series",
"filing": "2026-08-25",
"table": {
"nii": [
-0.02,
-0.01,
-0.01
],
"gains": [],
"roc": [
0.0,
0.0,
0.0,
0.0,
0.0
],
"tot": [
-1.21,
-0.81,
-0.63,
-0.34,
-0.61
],
"nav": [],
"ncols": 5
},
"roc_share_5y": -0.0,
"nav_change_5y": null
},
"HICOX": {
"sym": "HICOX",
"cik": "0000810744",
"trust": "COLORADO BONDSHARES A TAX EXEMPT FUND",
"filing": "2025-12-04",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
},
"HMEZX": {
"sym": "HMEZX",
"cik": "0001354917",
"trust": "NEXPOINT FUNDS I",
"filing": "2025-09-08",
"table": {
"nii": [
-1.01,
-1.0,
-0.38,
-0.4
],
"gains": [],
"roc": [
-0.62,
-0.37,
0.0,
0.0,
0.0
],
"tot": [
-1.01,
-1.0,
-1.0,
-1.24,
-1.16
],
"nav": [
19.83,
19.71,
19.21,
19.89,
20.58
],
"ncols": 5
},
"roc_share_5y": 0.183,
"nav_change_5y": -0.036
},
"JLPSX": {
"sym": "JLPSX",
"cik": "0001217286",
"trust": "JPMorgan Trust I",
"filing": "2026-05-07",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
},
"LAMHX": {
"sym": "LAMHX",
"cik": "0000887194",
"trust": "LORD ABBETT RESEARCH FUND INC",
"filing": "2026-02-04",
"table": {
"nii": [
24.79
],
"gains": [],
"roc": [
0.0
],
"tot": [],
"nav": [],
"ncols": 1
},
"roc_share_5y": null,
"nav_change_5y": null
},
"LCORX": {
"sym": "LCORX",
"cik": "0001511699",
"trust": "Managed Portfolio Series",
"filing": "2026-08-04",
"table": {
"nii": [
-0.02,
-0.04,
-0.06,
-0.03,
-0.09
],
"gains": [],
"roc": [
0.0,
0.0,
0.0,
0.0,
0.0
],
"tot": [
-1.2
],
"nav": [
18.73
],
"ncols": 5
},
"roc_share_5y": -0.0,
"nav_change_5y": null
},
"LCRIX": {
"sym": "LCRIX",
"cik": "0001511699",
"trust": "Managed Portfolio Series",
"filing": "2026-08-04",
"table": {
"nii": [
-0.02,
-0.04,
-0.06,
-0.03,
-0.09
],
"gains": [],
"roc": [
0.0,
0.0,
0.0,
0.0,
0.0
],
"tot": [
-1.2
],
"nav": [
18.73
],
"ncols": 5
},
"roc_share_5y": -0.0,
"nav_change_5y": null
},
"LPXAX": {
"sym": "LPXAX",
"cik": "0001652200",
"trust": "Cohen & Steers Low Duration Preferred & Income Fund, Inc.",
"filing": "2026-07-02",
"table": {
"nii": [
-0.51
],
"gains": [],
"roc": [
-0.0,
0.0,
0.0,
0.0,
0.0
],
"tot": [],
"nav": [
9.59,
9.43,
9.23,
8.87,
9.36
],
"ncols": 5
},
"roc_share_5y": null,
"nav_change_5y": 0.025
},
"MBXIX": {
"sym": "MBXIX",
"cik": "0001355064",
"trust": "MUTUAL FUND SERIES TRUST",
"filing": "2026-06-08",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
},
"MERVX": {
"sym": "MERVX",
"cik": "0001208133",
"trust": "MERGER FUND VL",
"filing": "2026-03-05",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
},
"PMAIX": {
"sym": "PMAIX",
"cik": "0002042316",
"trust": "Victory Portfolios IV",
"filing": "2026-06-08",
"table": {
"nii": [
-0.04,
-0.04
],
"gains": [],
"roc": [
0.0,
0.0,
0.0,
0.0,
0.0
],
"tot": [
-6.32,
-1.99,
-1.1,
-1.06,
-6.45
],
"nav": [],
"ncols": 5
},
"roc_share_5y": -0.0,
"nav_change_5y": null
},
"PMFKX": {
"sym": "PMFKX",
"cik": "0002042316",
"trust": "Victory Portfolios IV",
"filing": "2026-06-08",
"table": {
"nii": [
-0.04,
-0.04
],
"gains": [],
"roc": [
0.0,
0.0,
0.0,
0.0,
0.0
],
"tot": [
-6.32,
-1.99,
-1.1,
-1.06,
-6.45
],
"nav": [],
"ncols": 5
},
"roc_share_5y": -0.0,
"nav_change_5y": null
},
"PMORX": {
"sym": "PMORX",
"error": "CIK not found"
},
"QCMMRX": {
"sym": "QCMMRX",
"error": "CIK not found"
},
"QSPNX": {
"sym": "QSPNX",
"cik": "0001444822",
"trust": "AQR Funds",
"filing": "2026-02-26",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
},
"RCTIX": {
"sym": "RCTIX",
"cik": "0001516523",
"trust": "Advisers Investment Trust",
"filing": "2025-11-26",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
},
"SAFEX": {
"sym": "SAFEX",
"cik": "0001257927",
"trust": "WEITZ FUNDS",
"filing": "2026-05-27",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
},
"SCFZX": {
"sym": "SCFZX",
"cik": "0000887991",
"trust": "PRUDENTIAL INVESTMENT PORTFOLIOS 8",
"filing": "2025-12-03",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
},
"SVARX": {
"sym": "SVARX",
"cik": "0001556505",
"trust": "Advisors Preferred Trust",
"filing": "2026-03-10",
"table": {
"nii": [
-3.72
],
"gains": [],
"roc": [
0.0
],
"tot": [
-3.72
],
"nav": [
38.76
],
"ncols": 1
},
"roc_share_5y": -0.0,
"nav_change_5y": null
},
"USMSX": {
"sym": "USMSX",
"cik": "0001659326",
"trust": "JPMORGAN TRUST IV",
"filing": "2026-05-07",
"table": null,
"roc_share_5y": null,
"nav_change_5y": null
},
"WMNUX": {
"sym": "WMNUX",
"cik": "0001545440",
"trust": "Ultimus Managers Trust",
"filing": "2026-08-03",
"table": {
"nii": [
-0.31
],
"gains": [],
"roc": [
0.0
],
"tot": [
-0.31
],
"nav": [],
"ncols": 1
},
"roc_share_5y": -0.0,
"nav_change_5y": null
}
}

39
fundlab/roc_run.log Normal file
View File

@ -0,0 +1,39 @@
fund ROC 5y NAV 5y dist/yr (most recent first) filing
HMEZX 18.3% -3.6% tot -1.01/roc -0.62 tot -1.00/roc -0.37 to 2025-09-08
AFLIX -0.0% - tot -0.78 2026-07-09
AGUAX (no per-share table found)
ANGLX -0.0% - tot -0.47 2026-04-08
ATESX -0.0% - tot -1.14 2026-08-10
ATRFX (no per-share table found)
BTMIX -0.0% -2.8% tot -0.31 tot -0.29 tot -0.25 tot -0.13 2026-03-04
COIAX (no per-share table found)
COSIX (no per-share table found)
CVSIX (no per-share table found)
DMSZX (no per-share table found)
DULTX - - tot 0.00 2026-06-09
EAGMX -0.0% - tot -2.05 2026-03-31
EGRIX -0.0% - tot -2.05 2026-03-31
EGRSX -0.0% - tot -2.05 2026-03-31
ETSIX -0.0% - tot -2.05 2026-03-31
FEMDX - +5.3% tot 0.00 tot 0.00 tot 0.00 tot 0.00 tot 2025-09-26
FHCOX -0.0% - tot -1.21 tot -0.81 tot -0.63 tot -0.34 2026-08-25
FHMIX -0.0% - tot -1.21 tot -0.81 tot -0.63 tot -0.34 2026-08-25
HICOX (no per-share table found)
JLPSX (no per-share table found)
LAMHX - - tot 0.00 2026-02-04
LCORX -0.0% - tot -1.20 tot 0.00 tot 0.00 tot 0.00 tot 2026-08-04
LCRIX -0.0% - tot -1.20 tot 0.00 tot 0.00 tot 0.00 tot 2026-08-04
LPXAX - +2.5% tot 0.00 tot 0.00 tot 0.00 tot 0.00 tot 2026-07-02
MBXIX (no per-share table found)
MERVX (no per-share table found)
PMAIX -0.0% - tot -6.32 tot -1.99 tot -1.10 tot -1.06 2026-06-08
PMFKX -0.0% - tot -6.32 tot -1.99 tot -1.10 tot -1.06 2026-06-08
PMORX (CIK not found)
QCMMRX (CIK not found)
QSPNX (no per-share table found)
RCTIX (no per-share table found)
SAFEX (no per-share table found)
SCFZX (no per-share table found)
SVARX -0.0% - tot -3.72 2026-03-10
USMSX (no per-share table found)
WMNUX -0.0% - tot -0.31 2026-08-03

302
fundlab/rocdetect.py Normal file
View File

@ -0,0 +1,302 @@
"""Return-of-capital (ROC) detector from N-CSR annual reports.
A mutual fund's per-share "Financial Highlights" table (item 7 of the
annual report, filed as N-CSR) lists distributions by character:
Distributions from:
Net investment income ...
(Realized) capital gains ...
(Tax) return of capital ...
Total distributions ...
The "return of capital" line is the definitive answer to "is this
fund paying me back my own money?" - the price-data heuristic
(NAV falling while paying out) only SUGGESTS it, and for
long-duration funds NAV declines are usually rate damage, not ROC.
Facts used:
- The table shows up to 5-10 fiscal years, MOST RECENT LEFT.
- Values are parenthesized outflows: "(1.16)" = $1.16 distributed.
- A row with fewer tokens than the NII row has blanks dropped; the
arithmetic check (NII + gains + ROC == total per column) is used
to place a lone value (verified on PGSIX: the lone (0.35) only
reconciles in the oldest column: 0.14 + 0.35 = 0.49).
Run: python -m fundlab.rocdetect [SYM ...]
(no args = the 16 shortlist + 22 cross-checked funds)
Output: fundlab/roc_results.json (cached per fund in roc_cache/)
"""
from __future__ import annotations
import json
import re
import time
from pathlib import Path
from fundlab import edgar
HERE = Path(__file__).parent
CACHE = HERE / "roc_cache"
RESULTS = HERE / "roc_results.json"
NUM = re.compile(r"\$?\(?[\d,]+(?:\.\d+)?\)?:?")
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
NUMTOK = re.compile(r"\$?\(?\d[\d,]*(?:\.\d+)?\)?")
def _parse_table(block: str) -> dict | None:
"""Parse one per-share distribution block."""
def row(label_rx: str) -> list[float | None]:
rm = re.search(label_rx, block, re.I)
if not rm:
return []
tail = re.sub(r"\.{2,}", " ", block[rm.end(): rm.end() + 600])
# some reports print "(1.01 )" / "( 1.01 )" - rejoin the parens
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 # "NAV ... $ 15.88 $ 15.23 ..."
if NUMTOK.fullmatch(t):
vals.append(t)
elif vals:
break # numbers over - next label reached
return _nums(vals[:10])
nii = row(r"net investment income")
gains = row(r"(?:realized |unrealized )?capital gains?")
roc = row(r"(?:tax )?return of capital")
tot = row(r"total distributions")
nav = row(r"net asset value, end of year")
if not nii and not tot:
return None
ncols = max(len(x) for x in (nii, gains, roc, tot, nav))
if ncols == 0:
return None
# place short rows by arithmetic: find the column where the
# components sum to the total (within rounding)
def _get(row: list[float | None], i: int) -> float:
if i < len(row) and row[i] is not None:
return row[i]
return 0.0
def place(vals: list[float | None]) -> list[float | None]:
if len(vals) == ncols:
return vals
from itertools import combinations
nz = [v for v in vals if v is not None]
if not nz:
return [0.0] * ncols
def reconcile(assign: list[int], nz_: list[float]) -> bool:
put = {c: v for c, v in zip(assign, nz_)}
for c in range(ncols):
if not tot or c >= len(tot) or tot[c] is None:
continue
s = (_get(nii, c) + _get(gains, c) + put.get(c, 0.0))
if abs(s - tot[c]) > 0.011 * max(1.0, abs(tot[c])) + 1e-9:
return False
return True
# blanks are dropped when the table flattens, so a short row's
# values may sit in ANY columns - try placements and keep the
# one where every column reconciles (NII+gains+ROC == total)
if len(nz) <= 6 and tot:
for assign in combinations(range(ncols), len(nz)):
if reconcile(list(assign), nz):
put = {c: v for c, v in zip(assign, nz)}
return [put.get(c, 0.0) for c in range(ncols)]
full = [0.0] * ncols
for i, v in enumerate(vals):
if v is not None:
full[i] = v
return full
roc = place(roc)
# sanity: the per-share table has small values. Dollar-basis
# statements ("distributions to shareholders: $459,612,118") and
# fiscal-year date columns ("2025") leak in from other sections.
def _bad(vals: list[float | None]) -> bool:
for v in vals:
if v is None:
continue
if abs(v) > 1000:
return True
if v == int(v) and 1900 <= v <= 2100:
return True
return False
if _bad(tot) or _bad(nav) or _bad(nii):
return None
if tot and all(v == 0 for v in tot if v is not None):
return None # all-zero total = wrong table (these all pay)
# table-level validity: every column with a total must reconcile
# (NII + gains + ROC == total). Catches rows that leaked in from
# the operations section or prose ("...capital gains of $3.00...").
if tot:
for c in range(ncols):
t = tot[c] if c < len(tot) else None
if t is None:
continue
s = _get(nii, c) + _get(gains, c) + (roc[c] if c < len(roc) else 0.0)
if abs(s - t) > 0.011 * max(1.0, abs(t)) + 0.005:
return None
return {"nii": nii, "gains": gains, "roc": roc, "tot": tot,
"nav": nav, "ncols": ncols}
def parse_highlights(txt: str, nav_now: float | None = None) -> dict | None:
"""Extract the per-share distribution table for ONE fund.
Anchors = 'distributions from / declared to / to shareholders' lines
immediately followed by 'net investment income' (the per-share
table, not the dollar-basis statement). Family annual reports
carry one table PER FUND (NexPoint's report has three) - when the
fund's current NAV is known, pick the table whose end-of-year NAV
is closest to it; otherwise the first.
"""
txt = re.sub(r"\s+", " ", txt)
tables: list[dict] = []
for m in re.finditer(
r"distributions (?:declared )?(?:from|to shareholders)",
txt, re.I):
if not re.search(r"net investment income",
txt[m.end(): m.end() + 300], re.I):
continue
t = _parse_table(txt[m.start(): m.start() + 3000])
if t:
tables.append(t)
if not tables:
return None
if nav_now:
def navdist(t: dict) -> float:
v = t["nav"][0] if t["nav"] and t["nav"][0] else None
return abs(v - nav_now) / nav_now if v else 99.0
tables.sort(key=navdist)
if navdist(tables[0]) > 0.15:
return None # no table matches this fund's NAV
return tables[0]
def roc_share(h: dict) -> float | None:
tot = sum(v or 0 for v in h["tot"])
if not tot:
return None
return sum(v or 0 for v in h["roc"]) / tot
def _nav_now(sym: str) -> float | None:
"""Latest raw NAV from the price file (to disambiguate family
reports that carry one per-share table per fund)."""
try:
import pandas as pd
p = Path.home() / "prog" / "fin" / "stocks" / f"{sym.lower()}-history.csv"
d = pd.read_csv(p, parse_dates=["Date"])
c = d["Close"].dropna()
return float(c.iloc[-1]) if len(c) else None
except Exception:
return None
def detect(sym: str, force: bool = False) -> dict | None:
"""Full pipeline for one fund: CIK -> latest N-CSR -> highlights."""
CACHE.mkdir(exist_ok=True)
cpath = CACHE / f"{sym.lower()}.json"
if cpath.exists() and not force:
return json.loads(cpath.read_text())
tc = edgar.ticker_to_company(sym)
if not tc:
return {"sym": sym, "error": "CIK not found"}
cik, name = tc
fil = edgar.cik_recent_filings(cik, "N-CSR", count=1)
if not fil:
return {"sym": sym, "error": "no N-CSR on file", "cik": cik}
f0 = fil[0]
txt = edgar.to_text(edgar.sec_get(
edgar.doc_url(cik, f0["accession"], f0["doc"]), timeout=120))
h = parse_highlights(txt, nav_now=_nav_now(sym))
rs = roc_share(h) if h else None
out = {
"sym": sym, "cik": cik, "trust": name,
"filing": f0["filed"],
"table": h,
"roc_share_5y": (round(rs, 3) if rs is not None else None),
"nav_change_5y": (round(h["nav"][0] / h["nav"][-1] - 1, 3)
if h and len(h["nav"]) >= 2
and h["nav"][0] and h["nav"][-1] else None),
}
cpath.write_text(json.dumps(out, indent=1))
return out
def _default_syms() -> list[str]:
dr = json.loads((HERE / "decompose_results.json").read_text())
xc = json.loads((HERE / "xcheck_report.json").read_text())
return sorted(set(s.upper() for s in dr)
| set(s.upper() for s in xc))
def _print(res: dict) -> None:
print(f" {'fund':<7} {'ROC 5y':>7} {'NAV 5y':>8} {'dist/yr (most recent first)':<44} filing")
for s in sorted(res, key=lambda x: -(res[x].get("roc_share_5y")
or -1)):
r = res[s]
if "error" in r:
print(f" {s:<7} ({r['error']})")
continue
h = r["table"]
if not h:
print(f" {s:<7} (no per-share table found)")
continue
yrs = []
for i in range(h["ncols"]):
t = h["tot"][i] if i < len(h["tot"]) else None
ro = h["roc"][i] if i < len(h["roc"]) else None
yrs.append(f"tot {t if t is not None else 0:.2f}"
+ (f"/roc {ro:.2f}" if ro else ""))
rc = r.get("roc_share_5y")
nv = r.get("nav_change_5y")
print(f" {s:<7} {(f'{rc*100:5.1f}%' if rc is not None else ' -'):>7} "
f"{(f'{nv*100:+6.1f}%' if nv is not None else ' -'):>8} "
f"{' '.join(yrs)[:44]:<44} {r['filing']}")
def run(syms: list[str] | None = None, force: bool = False) -> dict:
syms = syms or _default_syms()
res: dict = {}
for s in syms:
for attempt in range(3):
try:
r = detect(s, force=force)
break
except Exception as e: # SEC 503/429 throttling - back off
if attempt == 2:
r = {"sym": s, "error": f"{type(e).__name__}: {e}"}
else:
time.sleep(15 * (attempt + 1))
r = None
if r:
res[s.upper()] = r
time.sleep(0.5)
RESULTS.write_text(json.dumps(res, indent=1))
_print(res)
return res
if __name__ == "__main__":
import sys
args = [a for a in sys.argv[1:] if a != "--force"]
run(args or None, force="--force" in sys.argv)