Skip to content

Repository files navigation

Private Credit Due Diligence Platform

A private credit portfolio risk monitoring and due diligence tool built directly on public SEC filings (10-Q/10-K) - no third-party data vendor required.

Dashboard Overview

Why This Project

  • Automates the recurring quarterly review of a BDC's portfolio holdings
  • Pulls the raw SEC filing and parses the actual portfolio holdings table, not just the pre-tagged XBRL summary (which is missing key fields like non-accrual status)
  • Reconciles parsed figures against the filer's own Balance Sheet
  • Produces the kind of output an investment risk analyst would use: a scored watchlist, quarter-over-quarter portfolio changes, and a rule-based IC memo

What It Does

  • Fully automated data pipeline: filing discovery -> raw document parsing -> reconciliation, for any quarter, without hardcoded page numbers or one-off assumptions baked into the shared logic
  • Two BDCs validated end-to-end (Ares Capital / ARCC, Blackstone Secured Lending / BXSL), each reconciled against the company's own Balance Sheet with effectively 0% error across 8 quarters
  • Non-accrual detection that dynamically identifies the correct footnote number in each filing (numbering isn't consistent across quarters or filers) and cross-checks the result against the officially disclosed percentage
  • Watchlist Score: a transparent, rule-based early warning score per portfolio company (non-accrual, fair value markdowns, sector concentration), explicitly framed as a prioritization tool rather than a black-box prediction model
  • Deterministic IC Memo generator: every number and conclusion is calculated, not generated by a language model - reproducible by design
  • Interactive Streamlit dashboard: portfolio composition, concentration, non-accrual trends, watchlist, and reconciliation history, each downloadable to Excel

Tech Stack

Python, pandas, BeautifulSoup/lxml, SQLite, Streamlit, Plotly

Screenshots

Portfolio Composition

Composition

Watchlist

Watchlist 1

Watchlist 2

Watchlist 3

Quick Start

One command, every time - always includes your email, always refreshes data, then opens the dashboard:

git clone https://github.com/HoyeonJoeKang/private-credit-due-diligence-platform.git
cd private-credit-due-diligence-platform
pip install -r requirements.txt
python start.py your_email@example.com

start.py checks whether parsed/dd_platform.db already exists. If not, it runs run_pipeline.py (collects 8 quarters of data for both ARCC and BXSL from SEC EDGAR - this takes a while, it's fetching and parsing real filings) and then launches the Streamlit dashboard. If the database already exists, it skips data collection entirely and jumps straight to the dashboard - so this is the one command to use every time, first run or the hundredth. To force a fresh re-collection, delete parsed/dd_platform.db first (or run python run_pipeline.py <email> directly).

Completed (as of 2026-07-05)

  • Phase 1: Filing Discovery (src/discovery.py) - most recent 8 10-Q/10-K filings per company
  • Phase 2: Automatic R-file discovery from FilingSummary.xml (src/filing_summary.py) - used to locate the Balance Sheet; the Schedule of Investments R-file is not actually used since it lacks narrative columns (see notes below)
  • Phase 3: Extracting total investments at fair value from the Balance Sheet, with automatic unit detection (millions/thousands) (src/balance_sheet.py, src/fetch_balance_sheet.py) - validated across all 8 quarters for both companies
  • Phase 4: Parsing the real Schedule of Investments table from the original filing document (iXBRL) (src/soi_table.py, src/fetch_soi_full.py, src/pipeline_phase4.py)
    • Automatically groups colspan-duplicated columns by header label
    • Automatically classifies sector headers / company first row / tranche continuation rows / company subtotals / grand total
    • The comparative (prior year-end) schedule that appears alongside the current quarter's schedule in each 10-Q is automatically cut off at the grand total row
    • All 8 quarters reconcile against the Balance Sheet total with effectively 0% error
    • Line-item data is stored in portfolio_holdings; reconciliation results are logged in data_quality_checks
  • Non-Accrual detection (src/footnote_legend.py) - finds the phrase "Loan was on non-accrual status" in the filing's own footnote legend and dynamically detects which footnote number it corresponds to for that specific filing, then matches it against portfolio_holdings.non_accrual (no hardcoded footnote numbers)
  • Grade Distribution parsing (src/grade_distribution.py) - portfolio-wide grade (1-4) distribution, weighted average grade, and the officially disclosed non-accrual % are stored in financial_metrics. Confirmed that line-item risk ratings are not disclosed in these filings (only portfolio-level aggregates are)
  • Our computed non-accrual % (fair value basis) is automatically cross-checked against the officially disclosed figure in the filing (logged as non_accrual_pct_reconciliation in data_quality_checks, tolerance 0.2 percentage points)
  • Watchlist Score (src/watchlist_score.py) - rule-based early warning score per issuer
    • Non-accrual (+3, plus +1 if newly entered this quarter) / Fair Value below 80% of Cost (+2) / markdown deteriorating for 2 consecutive quarters (+2) / sector concentration above 15% (+1)
    • Buckets: Green (0-2) / Yellow (3-5) / Orange (6-8) / Red (9+)
    • Explicitly framed as "an illustrative rule-based early warning framework intended to prioritize analyst review rather than predict default" - a prioritization tool, not a prediction model
    • Visible in the Streamlit Watchlist tab as bucket counts plus a color-coded detail table
  • MD&A text extraction (src/mdna_extract.py) - distinguishes the real "Item 2." section header from its appearance in the table of contents by measuring the distance to the next "Item 3." (no hardcoded character positions)
  • IC Memo generator (src/ic_memo.py) - 100% rule-based, generated deterministically without any LLM
    • Portfolio snapshot, Grade Distribution/Non-Accrual trends, Watchlist summary, new/exited issuers, data limitation disclosures, and an overall assessment (Improving/Stable/ Deteriorating) are all derived from calculated figures
    • Issuer name normalization (normalize_issuer_name) prevents false new/exited issuer counts caused by footnote-number suffixes or separator differences (& vs /) - while protecting abbreviations like d/b/a

Peer Expansion: BXSL (as of 2026-07-05)

A second BDC (Blackstone Secured Lending Fund) is now fully validated across all 8 quarters. Its filing format differs substantially from ARCC's, so a BXSL-specific adapter (src/soi_table_bxsl.py, src/pipeline_bxsl.py) was built. Shared logic (collapse_grouped_columns / _clean_value in src/soi_table.py, src/watchlist_score.py, src/ic_memo.py, src/mdna_extract.py) was reused without any code changes.

Differences between ARCC and BXSL

  • Balance Sheet statement name: ARCC uses "Consolidated Balance Sheets", BXSL uses "Statements of Assets and Liabilities" - find_balance_sheet_reports's pattern was broadened to match both
  • How "Investments at fair value" is presented: ARCC has a separate "Fair Value" row below the header, while BXSL puts the number directly on the "Investments at fair value" label - extract_total_investments_fair_value now takes a label_candidates parameter instead of a hardcoded "fair value" string
  • Schedule of Investments header keywords: ARCC uses "Company" + "Business Description", BXSL uses entirely different labels ("Investments", "Footnotes", etc.) - generalized via find_header_row_generic
  • BXSL packs two distinct values into a single column group ("Reference Rate and Spread" contains both "SOFR +" and "6.00%") - collapse_grouped_columns was generalized to concatenate distinct de-duplicated values within a group instead of taking only the first (this doesn't change behavior for ARCC's single-value groups)
  • BXSL has no per-company subtotal rows, only sector-level subtotals, and the company name is repeated on every tranche row (ARCC only shows the company name on the first tranche row, leaving it blank afterward) - handled by a separate classify_and_assign_bxsl
  • BXSL identifies banner rows by checking whether the Cost field fails to parse as a number - depending on the quarter, a banner row's Cost field is either a repeated copy of the banner text or simply blank
  • BXSL's grand total line reads "Total Portfolio Investments, Cash and Cash Equivalents", different from ARCC's "Total Investments". Several asset-class-level subtotals also appear along the way (e.g. "Total First Lien Debt") - these are classified separately as asset_class_subtotal and excluded from the line-item total, not mistaken for the grand total
  • BXSL's Schedule of Investments grand total includes cash and cash equivalents (money market funds, etc.), while the Balance Sheet's "Investments at fair value" excludes cash - this discrepancy (which matched the Balance Sheet's Cash and Cash Equivalent line exactly) was identified, and cash-equivalent positions are now excluded from both the reconciliation total and the portfolio_holdings storage
  • Unit normalization: ARCC's filing is already labeled "$ in Millions", so storing the parsed values directly was already correct. BXSL is labeled "$ in Thousands", so converting to raw dollars before storing would have produced inconsistent units across companies - storage_scale = unit_multiplier / 1_000_000 ensures every company's data is stored in millions of dollars, matching what the IC Memo and Streamlit dashboard both assume

Design principle reaffirmed

With only two adapters (ARCC, BXSL) built so far, we have deliberately not abstracted them behind a common interface (e.g. a BDCParserAdapter) - it's safer to wait until a third BDC is added and see what the two adapters genuinely have in common before generalizing (avoiding premature abstraction). For now, each company's fetch/parse layer lives in its own file, while the analysis layer (Watchlist Score, IC Memo, MD&A extraction) is already company-agnostic and shared as-is.

Additional Modules (as of 2026-07-08)

  • PIK income trend (src/pipeline_phase4.py:parse_pik) - extracts the payment-in-kind portion from coupon/interest rate text (e.g. "9.45% (2.88% PIK)", "10.60% (incl. 2.65% PIK)") and stores it as portfolio_holdings.pik_rate. Exposed in the Streamlit dashboard as a portfolio-wide PIK trend chart and weighted average PIK rate.
  • Amendment filing detection (src/discovery.py:discover_amendments) - flags 10-Q/A and 10-K/A filings in a separate filing_amendments table. Deliberately kept separate from the main filings table and not re-parsed through the main pipeline, since an amendment may only restate a specific section rather than the full filing.
  • Generalized footnote legend parser (src/footnote_legend.py:parse_footnote_legend) - parses the entire footnote legend (not just the non-accrual footnote) into a {number: definition} dictionary. Distinguishes the real legend from incidental footnote-reference markers scattered throughout the Schedule of Investments table by requiring strictly sequential numbering (8, 9, 10, 11...) rather than relying on a distance/proximity heuristic - the Schedule of Investments table's footnote references are non-sequential and repeat the same numbers many times, so this structural signal reliably separates the two.
  • Leverage & Liquidity module (src/balance_sheet.py:extract_line_item, src/fetch_balance_sheet.py:get_leverage_metrics) - extracts Total Debt, Cash and Cash Equivalents, Total Assets, and Total Liabilities from the Balance Sheet, and computes Net Assets and Debt/Equity. Balance sheet labels for these line items aren't always identical between filers (e.g. BXSL's "Debt" label includes a dynamic parenthetical note on unamortized issuance costs), so extraction matches by label prefix rather than exact string equality.
  • Idempotent re-run fix: portfolio_holdings previously relied solely on INSERT OR REPLACE keyed on (accession, issuer, investment_type, reference_rate, principal). Since issuer name normalization logic changed several times during development, re-running the pipeline with updated code left orphaned rows from earlier normalization versions in place (they no longer matched the new key, so they were never replaced), silently inflating portfolio totals on each re-run. Fixed by deleting all existing rows for an accession before re-inserting, making the pipeline safe to re-run at any time regardless of code changes.

Next Steps (Not Yet Implemented)

  • Further peer expansion beyond BXSL - would need to verify whether the grade system and footnote-numbering approach generalize to other filers
  • Watchlist Score weights are currently arbitrary - backtesting the score's leading-indicator performance against historical non-accrual transitions would make it much more defensible
  • IC Memo currently uses only the rule-based template (an LLM phrasing layer was built but removed due to API cost considerations - it can be restored from an earlier version if needed)

Notes / Pitfalls Discovered

  • The original filing document (main_doc.htm) differs from the XBRL viewer's rendered R-file (e.g. R5.htm). The R-file only contains tagged numeric facts, so narrative columns (non-accrual, risk rating, interest rate, etc.) are missing. Always parse from the original document instead.
  • Among the tables returned by pd.read_html(main_doc_path), the candidates found by find_soi_page_tables() mix together the current quarter's schedule and the comparative (prior year-end) schedule. parse_all_soi_pages() automatically stops at the first grand total ("Total Investments") row so only the current quarter's data remains. Never hardcode page indices - they differ from quarter to quarter.
  • Some columns have a "$" sign or footnote markers (e.g. (2)(9)) appearing before the actual value, so "real" values are identified by the pattern of the cell content itself rather than by column position (see _clean_value).
  • A company's tranches can be split across a page boundary, so pages must not be classified independently - concatenate everything first, then run classify_and_assign once on the combined data.
  • The Grade Distribution table has the same pattern where a % sign is attached only to the first item and omitted afterward, so it's parsed by numeric position rather than relying on the symbol.
  • Line-item (per-portfolio-company) covenant compliance is not disclosed in these filings - "covenant" only appears as part of the Grade Distribution's narrative definitions (e.g. what a Grade 2 rating generally implies), not as a separate structured disclosure. Covenant terms live in the private credit agreement, not the public 10-Q/10-K. What is disclosed and extractable is the BDC's own debt covenants (leverage/asset coverage), which is what the Leverage & Liquidity module covers instead.
  • Depending on the pandas/pyarrow version, .astype(str) on a column containing real NaN values doesn't reliably coerce them to the string "nan" - the null can survive as a float and later break any .str accessor method (e.g. AttributeError: 'float' object has no attribute 'startswith'). Safer to convert element-wise with an explicit pd.notna() check (see extract_line_item) rather than trusting .astype(str) alone when a column may contain nulls.

Design Principles (for future changes)

  • Keep the fetch / parse / analyze layers separated
  • Manage the DB schema in one place only (src/schema.py); financial_metrics.metric_name is free text, so adding a new metric never requires a schema change
  • Adding a company only requires a new entry in config/companies.py (a separate parser adapter may still be needed per company)
  • Any parsed numeric total must be reconciled against an independent reference value (Balance Sheet, an officially disclosed figure in the filing, etc.) before being trusted

Silent Restatement Cross-Check (as of 2026-07-28)

Beyond flagging formal amendment filings (10-Q/A, 10-K/A), a separate concern raised during external review was whether a figure could be quietly revised in a later filing's comparative-period column without ever triggering a formal amendment. Investigated and built a cross-check for this (src/silent_restatement.py):

  • Every filing's Balance Sheet contains both the current period and a comparative period (typically the prior fiscal year-end). Instead of assuming any fixed adjacency between quarters (which is incorrect - BDC balance sheets compare each interim quarter against the last fiscal year-end, not the immediately preceding quarter), the check collects every value any filing reports for any period (collect_all_reported_values) and verifies that all filings mentioning the same period agree, regardless of which specific filings happen to report it.
  • Extended the same principle to Grade Distribution (collect_grade_distribution_observations), which also carries a current + prior period side by side, after confirming from the filing's own text which specific date "prior" refers to (grade_distribution_dates.py) rather than assuming it matches the Balance Sheet's convention.
  • Also checked the Schedule of Investments' own comparative schedule (the prior year-end schedule that appears alongside the current quarter's in every 10-Q, previously discarded after the grand-total cutoff) at the aggregate level: the total of the comparative schedule now reconciles exactly against that period's own originally-reported total. Line-item-level cross-checking (matching individual portfolio companies between the two schedules) was scoped out for now, since issuer name matching across filings already has known limitations (footnote suffixes, legal entity renames) documented above.
  • Result: across all 8 quarters of ARCC data checked (Balance Sheet line items, Grade Distribution, and the SOI comparative schedule aggregate total), zero inconsistencies were found. The concern is a legitimate one in principle, but did not manifest in this company's filing history.

About

A private credit portfolio risk monitoring and due diligence tool built on public SEC filings (ARCC, BXSL)

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages