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/6xxb.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**BDC `portfolio_investments()` no longer counts holdings twice.** Company totals, tranche parents and a second schedule's copies of a row were read as holdings, so rows summed 2-48% above the filer's own total on every BDC checked; MAIN's FY2025 10-K summed to $6.30B against a reported $5.52B and now reconciles within 0.2%. Foreign-currency values no longer replace USD ones, and new `reported_total_fair_value` and `reconciliation_gap` show any remaining difference.
12 changes: 12 additions & 0 deletions edgar/ai/mcp/tools/fund.py
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,18 @@ async def _bdc_portfolio(identifier: str, limit: int) -> Any:
result["total_investments"] = total_count
if total_fair_value is not None:
result["total_fair_value"] = total_fair_value
# The filer's own balance-sheet figure, and a warning when the rows
# do not add up to it (a holding tagged in two schedules; edgartools-6xxb)
reported = getattr(investments, 'reported_total_fair_value', None)
if reported is not None:
result["reported_total_fair_value"] = float(reported)
gap = investments.reconciliation_gap
if gap is not None and abs(gap) > 0.02:
result["warning"] = (
f"Investment rows sum {gap:+.1%} away from the filer's reported total "
f"({float(reported):,.0f}); some holdings may be counted twice. "
f"Use reported_total_fair_value for the portfolio total."
)
if total_cost is not None:
result["total_cost"] = total_cost
result["investments"] = inv_records
Expand Down
3 changes: 3 additions & 0 deletions edgar/ai/skills/funds/skill.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ patterns:
investments = arcc.portfolio_investments() # Parsed individual investments
investments.filter(industry="software") # by industry as filed or normalized sector
investments.by_industry() # sector | num_investments | total_fair_value | pct_of_portfolio
investments.reported_total_fair_value # The filer's balance-sheet total: use this for the portfolio size
investments.reconciliation_gap # Rows vs reported total, e.g. 0.003; check before trusting sums
investments.excluded # Subtotal / duplicate rows left out, each with .reason
inv = investments[0]
inv.industry / inv.sector / inv.industry_source # as filed / normalized / axis|enumeration|identifier|peer|None

Expand Down
195 changes: 194 additions & 1 deletion edgar/bdc/investments.py
Original file line number Diff line number Diff line change
Expand Up @@ -2081,6 +2081,135 @@ def __repr__(self):
return repr_rich(self.__rich__())


class ExcludedInvestment(NamedTuple):
"""An identifier row left out of a portfolio because it restates others."""
investment: PortfolioInvestment
reason: str


def _reported_total_fair_value(all_facts, period: Optional[str]):
"""The filer's undimensioned total investments at fair value and its unit.

Filers tag the same total at several precisions (ARCC: decimals -6 and -5);
the most precise one is returned. ``(None, None)`` when there is none.
"""
best = None
for fact in all_facts:
if (fact.get('concept') != 'us-gaap:InvestmentOwnedAtFairValue'
or fact.get('period_instant') != period
or any(key.startswith('dim_') and fact.get(key) for key in fact)):
continue
value = fact.get('numeric_value')
if value is None or pd.isna(value):
continue
decimals = fact.get('decimals')
precision = float('inf') if str(decimals).upper() == 'INF' else _as_float(decimals, -99)
if best is None or precision > best[0]:
best = (precision, Decimal(str(value)), fact.get('unit_ref'))
return (best[1], best[2]) if best else (None, None)


def _as_float(value, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default


# "Debt Investments Healthcare Services, Other and Total Marathon Health, LLC"
_COMPANY_TOTAL_RE = re.compile(r'\bTotal\s+(.+)$')


def _exclude_restated_rows(rows: list[PortfolioInvestment]):
"""Split identifier rows into holdings and rows that restate holdings already counted.

``InvestmentIdentifierAxis`` members carry no hierarchy, so a filer's
subtotals and second-schedule copies look like holdings. Measured against
each filer's own balance-sheet total, the rows summed high on every BDC
checked (FSK +48%, HTGC +21%, ARCC +16%, MAIN +14%). A row is excluded only
when the other rows account for its value exactly:

- a company total ("... and Total Marathon Health, LLC", HTGC) equal to the
sum of the rows naming that company;
- a parent equal to the sum of the rows whose identifier extends it
("Bolder Panther Group, LLC | Secured Debt" = tranches "... 1.1", "1.2",
"1.3" on MAIN; "Ivy Hill Asset Management, L.P." = its instruments on ARCC);
- the same identifier spelled differently with the same fair value
("ITA HOLDINGS GROUP, LLC" / "ITA Holdings Group, LLC", CSWC);
- a row with no cost whose fair value equals a costed row's: the affiliate
roll-forward restating a holding under its own member name
("Blue Owl Credit SLF LLC(c)", OBDC; "Production Resource Group LLC 8", FSK).

Returns ``(kept, excluded)``.
"""
excluded: list[ExcludedInvestment] = []
kept = list(rows)

def fv(inv) -> Decimal:
return inv.fair_value or Decimal(0)

def tolerance(n: int) -> Decimal:
# Each row is rounded to the filer's precision, typically thousands
return Decimal(1000) * max(n, 1)

def drop(inv, reason):
kept.remove(inv)
excluded.append(ExcludedInvestment(inv, reason))

# Company totals
for inv in list(kept):
match = _COMPANY_TOTAL_RE.search(inv.identifier)
if not match or not inv.fair_value:
continue
# Only rows under the same heading: HTGC's "Debt Investments ... Total X"
# covers X's debt rows, not X's warrants filed under another heading
name = match.group(1).strip()
heading = inv.identifier[:match.start()]
parts = [o for o in kept if o is not inv and o.identifier.startswith(heading)
and name in o.identifier and not _COMPANY_TOTAL_RE.search(o.identifier)]
if parts and abs(sum(map(fv, parts)) - fv(inv)) <= tolerance(len(parts)):
drop(inv, f"company total of {len(parts)} holding(s)")

# Parents of their own tranches or instruments, deepest first
for inv in sorted(kept, key=lambda i: -len(i.identifier)):
if not inv.fair_value:
continue
ident = inv.identifier
parts = [o for o in kept if o is not inv and o.identifier.startswith(ident)
and len(o.identifier) > len(ident) and not o.identifier[len(ident)].isalnum()]
if parts and abs(sum(map(fv, parts)) - fv(inv)) <= tolerance(len(parts)):
drop(inv, f"total of {len(parts)} row(s) that extend it")

# One identifier spelled two ways
seen: dict = {}
for inv in list(kept):
if not inv.fair_value:
continue
key = (re.sub(r'\W+', ' ', inv.identifier).strip().lower(), inv.fair_value)
other = seen.get(key)
if other is None:
seen[key] = inv
continue
# Keep the row that carries more of the schedule's fields
loser = inv if _field_count(inv) <= _field_count(other) else other
if loser is other:
seen[key] = inv
drop(loser, "same identifier and fair value as another row")

# Cost-less copies from another schedule
costed = {inv.fair_value for inv in kept if inv.cost is not None and inv.fair_value}
for inv in list(kept):
if inv.fair_value and inv.cost is None and inv.fair_value in costed:
drop(inv, "no cost, fair value equal to a costed row")

return kept, excluded


def _field_count(inv: PortfolioInvestment) -> int:
return sum(value is not None for value in (
inv.fair_value, inv.cost, inv.principal_amount, inv.shares, inv.interest_rate))


class PortfolioInvestments:
"""
A collection of portfolio investments from a BDC's Schedule of Investments.
Expand All @@ -2097,10 +2226,14 @@ def __init__(
investments: list[PortfolioInvestment],
period: Optional[str] = None,
nonaccrual_fair_value: Optional[Decimal] = None,
reported_total_fair_value: Optional[Decimal] = None,
excluded: Optional[list['ExcludedInvestment']] = None,
):
self._investments = investments
self._period = period
self._nonaccrual_fair_value = nonaccrual_fair_value
self._reported_total_fair_value = reported_total_fair_value
self._excluded = excluded or []

def __len__(self) -> int:
return len(self._investments)
Expand Down Expand Up @@ -2169,6 +2302,39 @@ def total_fair_value(self) -> Decimal:
Decimal(0)
)

@property
def reported_total_fair_value(self) -> Optional[Decimal]:
"""The total investments at fair value the filer reports on its balance sheet.

This is the filing's own figure (``us-gaap:InvestmentOwnedAtFairValue``
with no dimension), the ground truth ``total_fair_value`` should agree
with. None when the filing does not report it, or for a filtered subset.
"""
return self._reported_total_fair_value

@property
def reconciliation_gap(self) -> Optional[float]:
"""How far ``total_fair_value`` is from the filer's reported total, as a fraction.

``0.02`` means the rows sum 2% above the balance sheet. Some filers tag
a holding in more than one schedule under different identifiers, and not
every restatement can be recognised, so check this before relying on a
total or a share of the portfolio. None when there is no reported total.
"""
reported = self._reported_total_fair_value
if not reported:
return None
return float(self.total_fair_value / reported - 1)

@property
def excluded(self) -> list['ExcludedInvestment']:
"""Identifier rows left out because they restate holdings already counted.

Each entry holds the ``investment`` and the ``reason``: a company or
tranche total, or a second schedule's copy of a row.
"""
return list(self._excluded)

@property
def total_cost(self) -> Decimal:
"""Total cost basis of all investments."""
Expand Down Expand Up @@ -2347,6 +2513,12 @@ def to_context(self, detail: str = 'standard') -> str:
lines.append(f'Period: {self._period}')
lines.append(f'Holdings: {len(self._investments)}')
lines.append(f'Total Fair Value: ${self.total_fair_value:,.0f}')
if self._reported_total_fair_value is not None:
lines.append(f'Reported Total Fair Value: ${self._reported_total_fair_value:,.0f}')
gap = self.reconciliation_gap
if gap is not None and abs(gap) > 0.02:
lines.append(f'WARNING: holdings sum {gap:+.1%} from the reported total; '
f'some may be counted twice')
lines.append(f'Total Cost: ${self.total_cost:,.0f}')

gain_loss = self.total_unrealized_gain_loss
Expand Down Expand Up @@ -2688,8 +2860,15 @@ def from_xbrl(
'us-gaap:InvestmentIndustrySectorExtensibleEnumeration': 'industry_enumeration',
}

# The filer's own total, and the currency it reports in. A holding in a
# foreign currency is tagged twice, e.g. TSLX's Hippo XPA Bidco at
# U_USD 23,226,000 and U_SEK 214,115,000, and keeping whichever came last
# put SEK into a USD total (edgartools-6xxb).
reported_total, reporting_unit = _reported_total_fair_value(all_facts, period)

# Group facts by investment identifier
investments = {}
member_units: dict = {}
member_candidates = _get_investment_member_candidates(xbrl)
for fact in all_facts:
# Check if this is a relevant concept
Expand Down Expand Up @@ -2744,6 +2923,14 @@ def from_xbrl(

try:
if field_name in ('fair_value', 'cost', 'principal_amount'):
# A value in the reporting currency is never replaced by one in
# another; a foreign-only value is kept when that is all there is.
unit = fact.get('unit_ref')
units = member_units.setdefault(inv_identifier, {})
if (reporting_unit and field_name in units
and units[field_name] == reporting_unit and unit != reporting_unit):
continue
units[field_name] = unit
investments[inv_identifier][field_name] = Decimal(str(value))
elif field_name == 'shares':
investments[inv_identifier][field_name] = int(float(value))
Expand Down Expand Up @@ -2782,6 +2969,11 @@ def from_xbrl(
for inv_data in investments.values()
]

# Drop members that restate holdings already counted: company totals,
# parents of their own tranches, and a second schedule's copy of a row.
# Before this every BDC measured summed 2-48% above its own balance sheet.
portfolio, excluded = _exclude_restated_rows(portfolio)

# Filter out Unknown types unless include_untyped is True
if not include_untyped:
portfolio = [inv for inv in portfolio if inv.investment_type != "Unknown"]
Expand All @@ -2792,4 +2984,5 @@ def from_xbrl(
reverse=True
)

return cls(portfolio, period=period, nonaccrual_fair_value=nonaccrual_fv)
return cls(portfolio, period=period, nonaccrual_fair_value=nonaccrual_fv,
reported_total_fair_value=reported_total, excluded=excluded)
5 changes: 4 additions & 1 deletion tests/bdc/test_bdc_industry.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,10 @@ def test_axis_enumeration_identifier_peer_and_none(self):
assert (alpha_second.industry, alpha_second.industry_source) == ('Software Sector', 'peer')
gamma = by_company['Gamma Widgets Corp., First lien senior secured loan']
assert gamma.industry is None and gamma.industry_source is None
assert by_company['Debt Investments Automotive'].industry is None
# The grouping subtotal restates Truck-Lite's row, so it is not a holding (edgartools-6xxb)
assert 'Debt Investments Automotive' not in by_company
assert [(e.investment.identifier, e.reason) for e in investments.excluded] == [
('Debt Investments Automotive', 'total of 1 row(s) that extend it')]

by_sector = investments.by_industry().set_index('sector')
assert by_sector.loc['Software', 'num_investments'] == 2
Expand Down
Loading
Loading