Case study · Private credit · Public SEC data

Every loan in a BDC report, one row each, with its footnote's meaning attached.

You're a credit analyst at a fund that reads BDC reports as public evidence about private loans. Each loan's status, such as "this borrower stopped paying interest", is a footnote mark explained pages away. BDC Footnote Tape turns any BDC's 10-K or 10-Q into a spreadsheet you can load, in seconds.

Try the demo Code Real reports downloaded live from SEC EDGAR. A made-up offline sample is included. No language model is called.
4 of 4reports add up to their own printed total
76 of 76distinct footnote marks explained
7 to 12 sper report, observed on 4 reports
$0AI model cost: fixed rules only
For
A private lending fund, or an investor choosing which funds to back
Users
You, the credit analyst, and your head of credit data, who decides what to load
Data
Public SEC filings: Ares Capital, Main Street, Golub, BlackRock TCP
Built with
Python, Streamlit, fixed rules
TechniquesData engSEC EDGAR fetch, rate-limited and cachedData engSection location and dating in 6 to 60 MB filingsData engHeader-keyword table parsingData engFootnote resolution onto each rowData engCSV, JSON and a data dictionaryAIAgentic QA: Gemini reviewer and fixer agent (full pipeline, off in the demo)EvalReconciliation to printed totals (1%)EvalPlanted-error testsEvalMemory profiling, 25 MiB cap
01 · VISION

Nobody retypes a BDC's loan list again.

Every BDC (a stock-listed fund that lends to private companies) files its full loan list with the SEC each quarter. Today an analyst rebuilds it by hand, for every lender, every quarter. The vision: any report in, one checked row per loan out, with each status flag next to the text that explains it.

02 · THE PROBLEM

Four things go wrong today.

1 · You search before you read

The loan list sits somewhere in a 6 to 60 MB report, often as two copies with different dates.

2 · You retype every row

Each BDC prints its table differently: rows split over lines, tables continued across pages, amounts in thousands or millions.

3 · You miss which loans are in trouble

Each loan's status is a footnote mark explained pages away from the loan. Ares Capital's 10-K has 12 marks across 1,409 holdings.

4 · A missed table looks like a clean file

Nothing checks the extraction, so a gap reaches the data unnoticed.

03 · THE SOLUTION

Code reads the report. You check the flags that matter.

  1. The loan list is found and dated. Paste a link, filing ID or ticker. The tool picks the section for the period EDGAR records for the filing.
  2. Every table becomes one row per holding. Borrower, industry, loan type, rate, spread, maturity, amount owed, cost, fair value, in dollars.
  3. Every mark gets its meaning. Footnote text and a tag on each row: non-accrual, PIK, restricted and six more.
  4. Every report is checked against itself. Totals must match the report's printed total within 1%. Unexplained marks and empty results are listed. Then you download CSV, JSON and a data dictionary.
04 · HYPOTHESES AND IMPACT

One job, one bet, one number each.

Your jobHypothesisMetric and target, then resultExpected impact
When a report is filed, I want the right loan list found and dated, so I start at the first loan.If the loan list is found and dated from the report itself, you reach the first loan sooner, because no one searches a 6 to 60 MB document by hand.Share of reports where the extracted date equals the EDGAR period: up. 4 of 4. Guardrail: previous-period leaks stay at 0Less time before analysis starts (unobserved)
When I need the loans, I want one row per holding in dollars, so I stop retyping.If column headers are matched by keyword and units read from the page, most reports give a full spreadsheet with no hand edits, because layout differences become rules.Share of reports within 1% of the printed total: up. 4 of 4; 1 extra report produced 0 rowsHours per report saved (unobserved)
When I scan a lender's book, I want each status as a column, so I see non-accrual and PIK loans without the report.If each mark is replaced by its meaning on the row, you find non-accrual and PIK loans without opening the report, because the status is a column, not a cross-reference.Distinct marks explained: up. 76 of 76. Flag accuracy unmeasuredBorrowers in trouble seen sooner (unobserved)
When I load a file, I want to know it adds up, so I don't recheck every row.If every report shows whether it adds up and which marks failed, your head of data loads passing files without rechecking rows, because failures are visible first.Share of loaded reports later corrected by hand: down. Baseline unobserved. Planted errors caught: 2 of 2Fewer bad rows in the fund's data (unobserved)

Demo runs on 2026-10-01, public SEC data. Matching the printed total proves the totals, not each row. No analyst or data lead has used it.

05 · THE VALUE

What it's worth, and what it asks of you.

Golub Capital BDC's quarterly report: 1,812 holdings, 31 footnote marks, one spreadsheet in 12 seconds.

You, the credit analyst
You get
"I open a report and see every loan, its spread (interest above the base rate) and whether it's on non-accrual, in one table, with the footnote text beside it."
Will you believe it?
Medium. 4 of 4 tested reports add up to their own printed total. One other layout produced 0 rows. Non-accrual flags are keyword matches. How often they're right is unmeasured.
How fast
Low delay. 7.0 to 12.0 s per report, observed on 4 reports.
What it takes
Low. Paste a link or ticker, click once.
What you give up
Low. Reading the loan list line by line. You keep the footnote text and the report link to check against.
Your head of credit data
They get
"A quarterly spreadsheet of every BDC's loans with status flags we can load, without a vendor contract or a team retyping."
Will they believe it?
Low to medium. Tested on 4 BDCs. Pass rate across all BDCs unobserved.
How fast
Medium delay. Seconds per report, but covering every BDC needs a bulk run and a review of failures (unobserved).
What it takes
Medium. Host it, watch failed checks, add rules for new table layouts.
What they give up
Medium. The team maintains the table-reading code itself instead of relying on a vendor's service guarantee.

The weak link is belief, for both of you. For you: label non-accrual rows by hand on 10 reports, against each BDC's own list, to turn "keyword match" into a measured accuracy. For your head of data: run the demo over the 1,805 reports already downloaded and publish the pass rate per BDC. No new code.

06 · LIMITATIONS

What this doesn't prove yet.

07 · SCOPE AND NEXT STEPS

What it won't do, and what comes next.

Non-goals
  • A confirmed non-accrual list. The repo's Gemini checker isn't connected; tags stay keyword matches.
  • Matching borrower names across lenders. The sibling BDC Mark Tape does this.
  • Change from one quarter to the next. The demo reads one report at a time.
  • Layouts with no company-name column, and symbol marks (*, ^, #).
  • Investment advice, currency conversion, reports above 25 MiB.
Next
  1. Hand labels on 10 reports to measure non-accrual flag accuracy.
  2. A run over the 1,805 reports on disk, with the pass rate per BDC.
  3. 50 random reports to count date matches and previous-period leaks.
  4. 10 planted errors in real reports to count what the checks catch.
  5. A timed session, by hand vs demo, on the same 5 reports.
RESOURCES

Go deeper.

Try the demo, step by step three steps, in order
  1. Pick an example report, or paste a link from SEC EDGAR (the SEC's public filings website), a filing ID (accession number) or a stock ticker.
  2. Click Run. You watch seven steps report as they finish: fetch, locate, parse, standardise, footnotes, resolve, QA (quality checks).
  3. Read your QA table. Tick "Only non-accrual candidates". Trace one loan to its footnote text. Then download the CSV, JSON and data dictionary (a file describing each column).

Real reports are downloaded live from SEC EDGAR and labelled public SEC data. A made-up offline sample is included. It runs on fixed rules: no language model is called. Limits: 25 MiB per filing, 10 filings per visit.

Main learnings 4 live reports, 1 failure, speed and memory

Demo runs, 2026-10-01, empty cache, local Mac, Python 3.11. Source: demo/observed_runs.txt (output of demo/run_live.py). Public SEC data.

ReportHoldingsFair value vs printed totalMarks explainedRows flagged as possible non-accrualTime
Ares Capital 10-K, 2025-12-31, 24.5 MB1,409-0.48% (pass)12 of 121911.5 s
Main Street Capital 10-Q, 2026-06-30, 13.4 MB6190.00% (pass)21 of 21337.7 s
Golub Capital BDC 10-Q, 2026-06-30, 24.6 MB1,8120.00% (pass)31 of 314512.0 s
BlackRock TCP Capital 10-Q, 2026-06-30, 18.9 MB3190.00% (pass)12 of 1207.0 s
  • Hypotheses 1 and 2: 4 of 4 reports picked the EDGAR period and added up. A fifth, a Blackstone Private Credit Fund 10-K on disk (32.5 MB), produced 0 holdings: its table has no company-name column. The checks show it as failed.
  • Hypothesis 3: 76 of 76 distinct marks explained. Of Golub's 45 flagged rows, 8 come from a note on companies the fund partly owns that only mentions non-accrual. So the flag over-counts there.
  • Guardrail, previous-period leak: before the section cut-off, Golub's non-accrual footnote read "as of September 30, 2025" inside the June 30, 2026 loan list. After the cut-off it reads June 30, 2026.
  • Negative amounts read as marks: before the fix, Main Street had 11 and Golub 24 unexplained marks. All were negative cost or fair value cells such as "(71)". After: 0.
  • Speed: footnote extraction took 69% to 72% of each run.
  • Memory: peak 667 MB on a 24.5 MB report, 804 MB on a 32.5 MB report. This set the 25 MiB cap (the free Community Cloud host guarantees about 690 MB).
  • Report sizes: 1,805 main documents on disk: median 6.1 MB, 95th percentile 21.1 MB, max 60.3 MB. 97.5% are at or under 25 MiB.
  • Tests: demo/test_demo.py: 11 passed, 1 skipped offline; 12 passed with the live SEC test on. The Streamlit AppTest run shows no error.
  • Repo pipeline, earlier run: 4,224 loan-list sections from 1,811 reports of 60 BDCs; 102 sections not found; 325 with no footnotes; median 17 footnotes. Source: output/bdc_footnotes_full_pipeline.csv (local, not in git).
  • README claims corrected: "~95% of cases correctly" and "100x faster" have no measurement behind them. The Gemini completeness check in output/logs/qa_run_v15.log passed 25 times and failed 50 times.
Hypotheses in full before and after, and the test for each

1 · FIND

Before and after
Before: the analyst scrolls to the loan list and checks its date. After: the section is found and dated, matching the period EDGAR records.
Hypothesis
If the loan list is found and dated from the report itself, then the analyst reaches the first loan sooner, because no one searches a 6 to 60 MB document by hand.
Metric
Share of reports where the extracted date equals the EDGAR period. Up. Observed 4 of 4. Analyst baseline unobserved. Guardrail: rows or footnotes taken from the previous period's section stay at 0.
Test
50 reports picked at random from the 1,805 on disk. Count date matches and previous-period leaks.

2 · PARSE

Before and after
Before: rows retyped into a spreadsheet. After: one row per holding, amounts in dollars.
Hypothesis
If column headers are matched by keyword and units read from the page, then most reports give a full spreadsheet with no hand edits, because layout differences become rules instead of retyping.
Metric
Share of reports whose extracted fair value is within 1% of the printed total. Up. Observed 4 of 4 tested; 1 extra report produced 0 rows. Baseline across all reports unobserved.
Test
Same 50-report sample, grouped by BDC.

3 · RESOLVE

Before and after
Before: flip to the footnote pages and match marks by eye. After: each row carries its footnote text and tags.
Hypothesis
If each mark is replaced by its meaning on the row, then the analyst finds non-accrual and PIK loans without opening the report, because the status is a column, not a cross-reference.
Metric
Distinct marks explained. Up. Observed 76 of 76 across 4 reports. Accuracy of the non-accrual flag unmeasured.
Test
Label non-accrual rows by hand on 10 reports; compare with the flag.

4 · TRUST

Before and after
Before: no one checks the extraction. After: quality checks per report: totals, unexplained marks, empty results.
Hypothesis
If every report shows whether it adds up and which marks failed, then the head of data loads passing files without rechecking rows, because failures are visible before loading.
Metric
Share of loaded reports later corrected by hand. Down. Baseline unobserved.
Test
Planted errors: the made-up sample has 2, and the checks catch both (tests). Next: plant 10 errors in real reports and count catches.
All sub-problems the 7 found, and where the code tackles them
  1. Find. The loan list sits somewhere in a 6 to 60 MB report, often as two copies with different dates. Proof: demo/vendor_parallax/section.py find_sections, demo/soi_parse.py choose_section.
  2. Parse. Each BDC prints its table differently: rows split over lines, tables continued across pages, amounts in thousands or millions. Proof: demo/vendor_parallax/tables.py find_header, classify_rows; demo/soi_parse.py. Partial: a layout with no company-name column produces 0 rows.
  3. Resolve. Each loan's status is a footnote mark explained pages away from the loan. Proof: demo/footnotes.py calling the repo's parsers/footnote_extractor.py; mark lookup in demo/pipeline.py step 6.
  4. Trust. A missed table looks like a clean file unless something checks it. Proof: demo/pipeline.py step 7, which compares totals with the report's own total row and lists unexplained marks.
  5. Confirm. A footnote that mentions non-accrual doesn't always mean the loan is on non-accrual. The repo has a checker built on Google's Gemini AI model (qa_reviewer/non_accrual_confirmer.py). The demo leaves it off.
  6. Match names. The same borrower goes by different names at different lenders.
  7. Track change. A status matters most when it changes from one quarter to the next. The demo reads one report at a time.

This build tackles 1 to 4.

How it's built fetch, locate, parse, standardise, footnotes, resolve, QA, export
  1. Fetch: turns a link, filing ID, ticker or uploaded file into the report itself. Follows the SEC's download rules (identify the caller, 5 requests per second). Saves copies to disk. 25 MiB cap.
  2. Locate: finds the dated loan-list sections in the report. Picks the period EDGAR records for the filing, else the latest date.
  3. Parse: reads every table in the section into rows, matching column headers by keyword. Continuation pages with no header reuse the same column matching.
  4. Standardise: makes one row per holding. Columns: borrower, industry, loan type and lien (who gets repaid first), reference rate, spread, maturity (repayment date), par (amount owed), cost (amount the fund paid), fair value, footnote marks. Amounts are converted to dollars.
  5. Footnotes: builds a list of each mark and its text, using the repo's own extractor. Three demo safeguards: stop at the next dated section, remove the BDC's page header, cut text that runs into the report's notes.
  6. Resolve: gives each row's marks their footnote text and a tag. Tags: non-accrual, PIK, restricted (resale limited), non-qualifying, affiliate, Level 3, unfunded, non-income producing, pledged.
  7. QA: compares total fair value and cost with the report's own total (passes within 1%). Lists unexplained marks, rows missing values and interest rate cells that couldn't be read.
  8. Export: holdings CSV, footnotes CSV, JSON, data dictionary (37 columns).
How success is tracked early signals and later results
MetricTypeTests hypothesisHow measured
Extracted date equals EDGAR periodEarly signal1Quality-check field per run, 50-report sample
Fair value within 1% of the printed totalEarly signal2fair_value_reconciles per run
Distinct marks explainedEarly signal3markers_resolved / markers_distinct
Non-accrual flag accuracyLater result3Hand labels on 10 reports
Reports corrected by hand after loadingLater result4Correction log kept by the data team
Analyst time from report to first loan rowLater result1, 2Timed session, by hand vs demo, same 5 reports
Scope in scope and out of scope
In scopeNon-goal (including sub-problems not tackled)
Any BDC 10-K or 10-Q main document up to 25 MiBSub-problem 2, partly: layouts with no company-name column (0 rows observed on one BDC). Symbol marks (*, ^, #) are not attached to rows
One dated loan list per run (EDGAR period, else latest)Sub-problem 5, confirm: tags are keyword matches. The repo's Gemini checker isn't connected, so there's no confirmed non-accrual list
Footnote text, keyword tags, quality checks, CSV/JSON/data dictionarySub-problem 6, match names: no matching of borrowers across lenders (the sibling BDC Mark Tape does this)
Fixed rules only, zero AI model costSub-problem 7, track change: no history from one quarter to the next
Investment advice, currency conversion of non-dollar amounts owed, reports above 25 MiB
Trade-offs what was chosen, over what, and the cost
ChoseOverGainCost
fixed rules onlythe repo's Gemini reviewer, paid per usezero model cost, same output every runnon-accrual tags stay unconfirmed
copy the Parallax table reader into demo/import from Parallaxone folder to deploytwo copies that can drift apart
repo footnote extractor as isrewrite it on lxmlreuses existing code that has already been run and testedabout 70% of run time, a second copy of the document in memory
25 MiB cap35 MiBobserved peak fits the Cloud memory minimum2.5% of reports on disk refused
"(A)" marks only when footnotes use upper-case lettersalwaysno false marks from names like "Co-Investment (E)"a BDC mixing cases loses upper-case marks
1% tolerance when matching totalsexact matchrounding in thousands doesn't fail a filea -0.48% gap passes unexplained
Terms on this page plain definitions
  • BDC: a stock-listed fund that lends money to private companies.
  • 10-K and 10-Q: the annual and quarterly reports a BDC files with the SEC.
  • Schedule of investments, or loan list: the table in each report that lists every loan the fund holds.
  • Footnote mark: a small letter or symbol next to a loan, explained pages away.
  • Non-accrual: the borrower stopped paying interest, so the lender stops counting it as income.
  • PIK (payment in kind): interest added to the amount owed instead of paid in cash.
  • Fair value: what the lender says a loan is worth today.
  • Reconcile, or add up: the rows we extract sum to the total the report itself prints.
Related project and links BDC Mark Tape, demo, code

Sibling: BDC Mark Tape. It compares how different BDCs value the same borrower. How they fit: this spreadsheet shows which loans a lender flags; the Mark Tape shows where lenders disagree on a loan's value. Together they point an analyst at the borrowers to review first.

How it was built with PM OS the product process, step by step
StageArtifactRepeatable with
PRDWORKFLOW_DIAGRAM.md: pipeline spec with success criteria per stage/prd-draft
BuildFixed rules first, with an AI reviewer as backup; demo//code-first-draft
EvaluationQuality-check logs (Gemini completeness check: 25 pass, 50 fail); demo/observed_runs.txt on 4 BDCs/feature-results
ShowcaseThis page, name audit, goals and state files/portfolio-case-study, /name-audit, /goal-gate

Skills are mapped from the artifact each stage produced. They show how each stage repeats with PM OS; the original working sessions were not logged.

PM OS skills used

/code-first-draft /feature-results /goal-gate /name-audit /portfolio-case-study /prd-draft