diff --git a/changelog.d/rd23.fixed.md b/changelog.d/rd23.fixed.md new file mode 100644 index 000000000..4ddec8920 --- /dev/null +++ b/changelog.d/rd23.fixed.md @@ -0,0 +1 @@ +**MCP `bdc_portfolio` investment records named no borrower.** Every record had only type, fair value, cost and rate because the builder read a `name` field `PortfolioInvestment` does not have. Records now carry `company_name`, `identifier`, principal, PIK rate, spread and percent of net assets, with `None` for a missing figure; MAIN's first record is "MSC Adviser I, LLC", $255.0M. (bead edgartools-rd23) diff --git a/changelog.d/vblr.fixed.md b/changelog.d/vblr.fixed.md new file mode 100644 index 000000000..aa22ce338 --- /dev/null +++ b/changelog.d/vblr.fixed.md @@ -0,0 +1 @@ +**`NonAccrualResult.num_nonaccrual` reported 0 when a filing gave only a portfolio-level non-accrual figure, or none.** WhiteHorse Finance files $10.6M of non-accrual investments for 2025 with no per-investment detail and showed 0; the count is now `None` unless the filing itemizes, and the new `evidence_level` says which (`investment`, `aggregate`, `none`). (bead edgartools-vblr) diff --git a/docs/guides/bdc-guide.md b/docs/guides/bdc-guide.md index 72e281ca8..0adbb0c07 100644 --- a/docs/guides/bdc-guide.md +++ b/docs/guides/bdc-guide.md @@ -428,6 +428,7 @@ print(f"Non-accrual count: {result.num_nonaccrual}") print(f"Non-accrual FV: ${result.nonaccrual_fair_value:,.0f}") print(f"Total portfolio FV: ${result.total_portfolio_fair_value:,.0f}") print(f"Extraction method: {result.extraction_method}") +print(f"Evidence level: {result.evidence_level}") ``` **Output:** @@ -437,6 +438,7 @@ Non-accrual count: 20 Non-accrual FV: $332,500,000 Total portfolio FV: $26,800,000,000 Extraction method: footnote +Evidence level: investment ``` ### Inspect Individual Investments @@ -514,12 +516,25 @@ elif result.nonaccrual_rate == 0.0: print("BDC reported zero non-accrual investments") ``` +### Counting Non-Accrual Investments + +`num_nonaccrual` is only a count when the filing itemizes its non-accrual investments, and `evidence_level` says whether it does: + +| `evidence_level` | What the filing gave | `num_nonaccrual` | +|------------------|----------------------|------------------| +| `'investment'` | Footnotes flagging individual investments | Count of flagged rows | +| `'aggregate'` | Only a portfolio-level rate or amount | `None` | +| `'none'` | No non-accrual data in the XBRL | `None` | + +An aggregate figure proves non-accruals exist without saying how many. WhiteHorse Finance's 10-K for 2025 reports $10.6 million of non-accrual investments at fair value and no per-investment footnotes, so its count is `None`, not 0. The count is of investment rows, so a company with two non-accrual loans counts twice. + ### NonAccrualResult Reference | Property | Type | Description | |----------|------|-------------| | `nonaccrual_rate` | `float \| None` | Non-accrual fair value / total portfolio FV | -| `num_nonaccrual` | `int` | Count of non-accrual investments | +| `num_nonaccrual` | `int \| None` | Count of non-accrual investment rows; `None` unless `evidence_level` is `'investment'` | +| `evidence_level` | `str` | `'investment'`, `'aggregate'`, or `'none'` — see [Counting Non-Accrual Investments](#counting-non-accrual-investments) | | `nonaccrual_fair_value` | `Decimal \| None` | Sum of non-accrual investment fair values | | `total_portfolio_fair_value` | `Decimal \| None` | Sum of all portfolio investment fair values | | `investments` | `list[NonAccrualInvestment]` | Individual non-accrual positions (footnote method only) | diff --git a/edgar/ai/mcp/tools/fund.py b/edgar/ai/mcp/tools/fund.py index 584170293..915a8b3ea 100644 --- a/edgar/ai/mcp/tools/fund.py +++ b/edgar/ai/mcp/tools/fund.py @@ -496,6 +496,28 @@ async def _bdc_search(query: str, limit: int) -> Any: return error(str(e), suggestions=get_error_suggestions(e)) +def _bdc_investment_record(inv) -> dict[str, Any]: + """One PortfolioInvestment as a record. Every key is always present; a missing + value is None, never 0, so an absent figure cannot read as a zero position.""" + def number(value): + return float(value) if isinstance(value, Decimal) else value + + return { + "company_name": inv.company_name or None, + "identifier": inv.identifier or None, + "type": inv.investment_type or None, + "industry": inv.industry, + "principal_amount": number(inv.principal_amount), + "cost": number(inv.cost), + "fair_value": number(inv.fair_value), + "shares": inv.shares, + "interest_rate": number(inv.interest_rate), + "pik_rate": number(inv.pik_rate), + "spread": number(inv.spread), + "percent_of_net_assets": number(inv.percent_of_net_assets), + } + + async def _bdc_portfolio(identifier: str, limit: int) -> Any: """Get BDC portfolio investments from Schedule of Investments.""" try: @@ -551,19 +573,7 @@ async def _bdc_portfolio(identifier: str, limit: int) -> Any: # Extract investment data inv_records = [] for inv in investments[:limit]: - inv_dict: dict[str, Any] = {} - if hasattr(inv, 'name'): - inv_dict["name"] = inv.name - if hasattr(inv, 'investment_type'): - inv_dict["type"] = str(inv.investment_type) if inv.investment_type else None - if hasattr(inv, 'fair_value') and inv.fair_value is not None: - inv_dict["fair_value"] = float(inv.fair_value) if isinstance(inv.fair_value, Decimal) else inv.fair_value - if hasattr(inv, 'cost') and inv.cost is not None: - inv_dict["cost"] = float(inv.cost) if isinstance(inv.cost, Decimal) else inv.cost - if hasattr(inv, 'interest_rate') and inv.interest_rate is not None: - inv_dict["interest_rate"] = float(inv.interest_rate) if isinstance(inv.interest_rate, Decimal) else inv.interest_rate - if inv_dict: - inv_records.append(inv_dict) + inv_records.append(_bdc_investment_record(inv)) total_count = len(investments) diff --git a/edgar/ai/skills/funds/skill.yaml b/edgar/ai/skills/funds/skill.yaml index c49d21e4e..87e51fded 100644 --- a/edgar/ai/skills/funds/skill.yaml +++ b/edgar/ai/skills/funds/skill.yaml @@ -78,6 +78,21 @@ patterns: which. An investment with no recoverable industry has industry, sector and industry_source all None, never an empty string. + - name: BDC Non-Accrual + when: Which BDC loans are on non-accrual, or the non-accrual rate + code: | + from edgar.bdc import extract_nonaccrual + result = extract_nonaccrual(filing) # a BDC 10-K/10-Q Filing, or a BDCEntity + result.nonaccrual_rate # non-accrual FV / portfolio FV + result.evidence_level # 'investment' | 'aggregate' | 'none' + result.num_nonaccrual # int only when 'investment'; else None + result.investments # NonAccrualInvestment rows (company_name, fair_value, cost) + note: | + num_nonaccrual None means the filing does not itemize non-accruals: an + aggregate rate (evidence_level 'aggregate') proves some exist without a + count, and 'none' means no XBRL signal. Never read None as zero. The count + is of schedule rows, so one company with two loans counts twice. + - name: Report Backlinks when: Navigate from report to filing code: | diff --git a/edgar/bdc/nonaccrual.py b/edgar/bdc/nonaccrual.py index 5465f5041..3fbc7d956 100644 --- a/edgar/bdc/nonaccrual.py +++ b/edgar/bdc/nonaccrual.py @@ -17,7 +17,8 @@ filing = Company("ARCC").get_filings(form="10-K").latest() result = extract_nonaccrual(filing) print(result.nonaccrual_rate) # e.g. 0.012 - print(result.num_nonaccrual) # e.g. 20 + print(result.num_nonaccrual) # e.g. 20, or None when the filing gives only a total + print(result.evidence_level) # 'investment' | 'aggregate' | 'none' for inv in result.investments: print(inv.company_name, inv.fair_value) """ @@ -184,7 +185,31 @@ def nonaccrual_rate(self) -> Optional[float]: return float(self.nonaccrual_fair_value / self.total_portfolio_fair_value) @property - def num_nonaccrual(self) -> int: + def evidence_level(self) -> str: + """What the filing gave as non-accrual evidence. + + * ``'investment'`` — footnotes resolved individual investments, so they can be counted. + * ``'aggregate'`` — only a portfolio-level rate or amount (a custom or us-gaap + concept); nothing says which investments, or how many. + * ``'none'`` — no signal at all. That means unknown, not zero: a BDC can + disclose non-accruals only in prose. + """ + if self.investments: + return 'investment' + if self.extraction_method in ('custom_concept', 'aggregate_concept'): + return 'aggregate' + return 'none' + + @property + def num_nonaccrual(self) -> Optional[int]: + """Count of non-accrual investment rows, or None when the filing does not identify them. + + Counts rows as the schedule lists them, so a company with two non-accrual + loans counts twice. None when ``evidence_level`` is ``'aggregate'`` or ``'none'``: + an aggregate rate proves non-accruals exist without saying how many. + """ + if self.evidence_level != 'investment': + return None return len(self.investments) @property @@ -228,11 +253,17 @@ def to_context(self, detail: str = 'standard') -> str: lines.append(f'Period: {self.period}') lines.append(f'Filing: {self.source_filing}') lines.append(f'Non-Accrual Rate: {rate_pct}') - lines.append(f'Non-Accrual Investments: {self.num_nonaccrual}') + if self.num_nonaccrual is not None: + lines.append(f'Non-Accrual Investments: {self.num_nonaccrual}') + elif self.evidence_level == 'aggregate': + lines.append('Non-Accrual Investments: not itemized (the filing gives only a portfolio-level figure)') + else: + lines.append('Non-Accrual Investments: unknown (no non-accrual data in the XBRL)') if self.nonaccrual_fair_value is not None: lines.append(f'Non-Accrual Fair Value: ${self.nonaccrual_fair_value:,.0f}') if self.total_portfolio_fair_value is not None: lines.append(f'Total Portfolio Fair Value: ${self.total_portfolio_fair_value:,.0f}') + lines.append(f'Evidence Level: {self.evidence_level}') lines.append(f'Extraction Method: {self.extraction_method}') # Data-quality warnings — surfaced at all detail levels so an LLM never @@ -280,7 +311,8 @@ def to_context(self, detail: str = 'standard') -> str: lines.append('AVAILABLE ACTIONS:') lines.append(' .investments List of NonAccrualInvestment objects') lines.append(' .nonaccrual_rate Computed non-accrual rate at fair value') - lines.append(' .num_nonaccrual Count of non-accrual investments') + lines.append(' .num_nonaccrual Count of non-accrual investments (None unless itemized)') + lines.append(' .evidence_level investment | aggregate | none') lines.append(' .nonaccrual_fair_value Sum of non-accrual fair values') return '\n'.join(lines) @@ -305,7 +337,8 @@ def to_context(self, detail: str = 'standard') -> str: lines.append('AVAILABLE ACTIONS:') lines.append(' .investments List of NonAccrualInvestment objects') lines.append(' .nonaccrual_rate Computed non-accrual rate at fair value') - lines.append(' .num_nonaccrual Count of non-accrual investments') + lines.append(' .num_nonaccrual Count of non-accrual investments (None unless itemized)') + lines.append(' .evidence_level investment | aggregate | none') lines.append(' .nonaccrual_fair_value Sum of non-accrual fair values') lines.append(' .unique_footnote_texts Distinct source footnote texts') return '\n'.join(lines) diff --git a/tests/bdc/test_bdc_mcp_portfolio.py b/tests/bdc/test_bdc_mcp_portfolio.py new file mode 100644 index 000000000..b85e5fe0d --- /dev/null +++ b/tests/bdc/test_bdc_mcp_portfolio.py @@ -0,0 +1,83 @@ +"""MCP bdc_portfolio records named no borrower (edgartools-rd23). + +The record builder checked ``hasattr(inv, 'name')``, but PortfolioInvestment has +``company_name`` and ``identifier`` and no ``name``, so every record came back as +type/fair_value/cost/interest_rate with nothing saying whose loan it was. It also +dropped principal, PIK rate, spread and percent of net assets. + +The rows below are MAIN's 10-K for 2025-12-31 (0001396440-26-000016) as +``bdc.portfolio_investments()`` returned them on 2026-10-01. +""" +from decimal import Decimal +from unittest.mock import MagicMock, patch + +import pytest + +from edgar.bdc.investments import PortfolioInvestment + +MAIN_ROWS = [ + PortfolioInvestment( + identifier="MSC Adviser I, LLC | Member Units", + company_name="MSC Adviser I, LLC", + investment_type="Member Units", + fair_value=Decimal("255020000"), + cost=Decimal("29500000"), + ), + PortfolioInvestment( + identifier="Bolder Panther Group, LLC | Secured Debt 1.1", + company_name="Bolder Panther Group, LLC", + investment_type="Secured Debt", + fair_value=Decimal("101046000"), + interest_rate=0.111, + spread=0.0722, + ), +] + + +def _fake_main(): + bdc = MagicMock() + bdc.name = "MAIN STREET CAPITAL CORP" + bdc.cik = 1396440 + bdc.is_active = True + bdc.state = "TX" + bdc.portfolio_investments.return_value = MAIN_ROWS + bdcs = MagicMock() + bdcs.get_by_ticker.return_value = bdc + return bdcs + + +async def _records(): + from edgar.ai.mcp.tools.fund import _bdc_portfolio + with patch("edgar.bdc.reference.get_bdc_list", return_value=_fake_main()): + response = await _bdc_portfolio(identifier="MAIN", limit=5) + assert response.success + return response.data["investments"] + + +@pytest.mark.fast +@pytest.mark.asyncio +async def test_each_record_names_the_borrower_and_the_holding(): + records = await _records() + + assert [r["company_name"] for r in records] == ["MSC Adviser I, LLC", "Bolder Panther Group, LLC"] + assert [r["identifier"] for r in records] == [ + "MSC Adviser I, LLC | Member Units", + "Bolder Panther Group, LLC | Secured Debt 1.1", + ] + assert records[0]["fair_value"] == 255020000.0 + assert records[0]["cost"] == 29500000.0 + assert records[1]["interest_rate"] == 0.111 + assert records[1]["spread"] == 0.0722 + + +@pytest.mark.fast +@pytest.mark.asyncio +async def test_every_record_has_the_same_keys_and_a_missing_figure_is_none(): + records = await _records() + + keys = {"company_name", "identifier", "type", "industry", "principal_amount", "cost", "fair_value", + "shares", "interest_rate", "pik_rate", "spread", "percent_of_net_assets"} + assert all(set(r) == keys for r in records) + # Bolder Panther's cost is not tagged: None, never a zero position + assert records[1]["cost"] is None + assert records[1]["principal_amount"] is None diff --git a/tests/bdc/test_bdc_nonaccrual.py b/tests/bdc/test_bdc_nonaccrual.py index fb885ff39..3be9c7ff5 100644 --- a/tests/bdc/test_bdc_nonaccrual.py +++ b/tests/bdc/test_bdc_nonaccrual.py @@ -91,6 +91,54 @@ def test_no_investment_detail(self): assert result.has_investment_detail is False +def _result(method, investments=(), custom_rate=None, aggregate=None, nonaccrual_fv=None): + return NonAccrualResult( + cik=1, + entity_name="Test BDC", + period="2025-12-31", + source_filing="0001-00-000001", + investments=list(investments), + nonaccrual_fair_value=nonaccrual_fv, + total_portfolio_fair_value=Decimal("1000000"), + extraction_method=method, + custom_concept_rate=custom_rate, + aggregate_concept_value=aggregate, + ) + + +@pytest.mark.fast +class TestEvidenceLevel: + """A count needs itemized evidence; anything less is None, not 0 (edgartools-vblr).""" + + def test_footnote_evidence_is_counted(self): + inv = NonAccrualInvestment( + identifier="Company A, First lien", company_name="Company A", investment_type="First lien", + fair_value=Decimal("1000"), cost=Decimal("1100"), footnote_text="Non-accrual status.", + ) + result = _result("footnote", investments=[inv, inv], nonaccrual_fv=Decimal("2000")) + assert result.evidence_level == "investment" + assert result.num_nonaccrual == 2 + + @pytest.mark.parametrize("method, kwargs", [ + ("custom_concept", {"custom_rate": 0.0101}), + ("aggregate_concept", {"aggregate": Decimal("10561000")}), + ]) + def test_an_aggregate_figure_does_not_count_as_zero(self, method, kwargs): + result = _result(method, nonaccrual_fv=Decimal("10561"), **kwargs) + assert result.evidence_level == "aggregate" + assert result.num_nonaccrual is None + context = result.to_context() + assert "Non-Accrual Investments: not itemized" in context + assert "Non-Accrual Investments: 0" not in context + assert "Evidence Level: aggregate" in context + + def test_no_signal_is_unknown_not_zero(self): + result = _result("none") + assert result.evidence_level == "none" + assert result.num_nonaccrual is None + assert "Non-Accrual Investments: unknown" in result.to_context() + + class TestHelperFunctions: """Tests for internal helper functions.""" @@ -232,6 +280,33 @@ def test_gbdc_has_aggregate_value(self): assert result.aggregate_concept_value is not None +class TestEvidenceLevelGroundTruth: + """Pinned filings, so a newer filing cannot move the answer (edgartools-vblr).""" + + @pytest.mark.network + def test_whf_files_only_a_portfolio_level_amount(self): + """WhiteHorse Finance 10-K, 2025-12-31: whfcl:FinancingReceivablesRecordedInvestment- + NonAccrualStatusFairValueDisclosure = $10,561,000, with no per-investment footnote. + Non-accruals exist, so the count is unknown, not 0.""" + from edgar import find + result = extract_nonaccrual(find("0001104659-26-024735")) + + assert result.evidence_level == "aggregate" + assert result.extraction_method == "custom_concept" + assert result.num_nonaccrual is None + assert result.nonaccrual_fair_value == pytest.approx(Decimal("10561000"), abs=Decimal("1")) + + @pytest.mark.network + def test_main_itemizes_its_nonaccrual_investments(self): + """Main Street 10-K, 2025-12-31: footnotes flag 33 investment rows across 16 companies.""" + from edgar import find + result = extract_nonaccrual(find("0001396440-26-000016")) + + assert result.evidence_level == "investment" + assert result.num_nonaccrual == 33 + assert len({inv.company_name for inv in result.investments}) == 16 + + class TestToContext: """Tests for to_context output."""