Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/rd23.fixed.md
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions changelog.d/vblr.fixed.md
Original file line number Diff line number Diff line change
@@ -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)
17 changes: 16 additions & 1 deletion docs/guides/bdc-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand All @@ -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
Expand Down Expand Up @@ -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) |
Expand Down
36 changes: 23 additions & 13 deletions edgar/ai/mcp/tools/fund.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down
15 changes: 15 additions & 0 deletions edgar/ai/skills/funds/skill.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
43 changes: 38 additions & 5 deletions edgar/bdc/nonaccrual.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
"""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand Down
83 changes: 83 additions & 0 deletions tests/bdc/test_bdc_mcp_portfolio.py
Original file line number Diff line number Diff line change
@@ -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
75 changes: 75 additions & 0 deletions tests/bdc/test_bdc_nonaccrual.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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."""

Expand Down
Loading