diff --git a/changelog.d/6xxb.fixed.md b/changelog.d/6xxb.fixed.md new file mode 100644 index 000000000..e4bbb9d4d --- /dev/null +++ b/changelog.d/6xxb.fixed.md @@ -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. diff --git a/edgar/ai/mcp/tools/fund.py b/edgar/ai/mcp/tools/fund.py index 3655f6d90..584170293 100644 --- a/edgar/ai/mcp/tools/fund.py +++ b/edgar/ai/mcp/tools/fund.py @@ -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 diff --git a/edgar/ai/skills/funds/skill.yaml b/edgar/ai/skills/funds/skill.yaml index b25e0b924..c49d21e4e 100644 --- a/edgar/ai/skills/funds/skill.yaml +++ b/edgar/ai/skills/funds/skill.yaml @@ -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 diff --git a/edgar/bdc/investments.py b/edgar/bdc/investments.py index 78c17ae6e..f9a958056 100644 --- a/edgar/bdc/investments.py +++ b/edgar/bdc/investments.py @@ -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. @@ -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) @@ -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.""" @@ -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 @@ -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 @@ -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)) @@ -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"] @@ -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) diff --git a/tests/bdc/test_bdc_industry.py b/tests/bdc/test_bdc_industry.py index 3e5f68bc3..3918380a0 100644 --- a/tests/bdc/test_bdc_industry.py +++ b/tests/bdc/test_bdc_industry.py @@ -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 diff --git a/tests/issues/regression/test_6xxb_bdc_restated_rows.py b/tests/issues/regression/test_6xxb_bdc_restated_rows.py new file mode 100644 index 000000000..c4ffca919 --- /dev/null +++ b/tests/issues/regression/test_6xxb_bdc_restated_rows.py @@ -0,0 +1,196 @@ +"""Regression: a BDC portfolio no longer counts the same holdings twice. + +Bead: edgartools-6xxb + +``PortfolioInvestments.from_xbrl`` makes one row per ``InvestmentIdentifierAxis`` +member. The members carry no hierarchy, so a filer's subtotals and a second +schedule's copies of its holdings looked like holdings too. Measured against +each filer's own balance-sheet total (``InvestmentOwnedAtFairValue``, no +dimension) on its latest 10-K, the rows summed high on all nine BDCs checked: + + FSK +48.4% HTGC +21.0% CSWC +18.9% OBDC +17.7% ARCC +15.7% + MAIN +14.1% TSLX +5.9% MSIF +5.6% GBDC +2.0% + +MAIN's FY2025 10-K (0001396440-26-000016) summed to 6,296,964,000 against a +reported 5,518,117,000. The restatements, each seen in real filings: + +- company totals: HTGC's "Debt Investments ... and Total Phathom Pharmaceuticals, Inc."; +- parents of their own tranches: MAIN's "Bolder Panther Group, LLC | Secured Debt" + (101,046,000) = "... 1.1" (101,046,000) + "... 1.2" (0) + "... 1.3" (0); +- one identifier spelled two ways: CSWC's "ITA HOLDINGS GROUP, LLC" / "ITA Holdings Group, LLC"; +- cost-less copies from the affiliate roll-forward: OBDC's "Blue Owl Credit SLF LLC(c)". + +TSLX was also wrong for a separate reason: a foreign holding is tagged in USD and +in its own currency, and the last fact won, so Hippo XPA Bidco counted +SEK 214,115,000 instead of USD 23,226,000. + +After the fix MAIN is -0.13%, MSIF 0.00%, ARCC +0.28%, CSWC +0.46%, GBDC +0.70%, +HTGC +1.37%, TSLX +1.74%. OBDC (+6.4%) and FSK (+11.5%) still tag holdings in a +way no rule here recognises, so ``reconciliation_gap`` reports it. +""" +from decimal import Decimal +from types import SimpleNamespace + +import pytest + +from edgar.bdc.investments import ( + PortfolioInvestment, + PortfolioInvestments, + _exclude_restated_rows, + _reported_total_fair_value, +) + +AXIS = 'dim_us-gaap_InvestmentIdentifierAxis' +PERIOD = '2025-12-31' + + +def holding(identifier, fair_value, cost=None, principal=None): + return PortfolioInvestment( + identifier=identifier, company_name=identifier.split(' | ')[0], investment_type='First lien', + fair_value=Decimal(fair_value), cost=None if cost is None else Decimal(cost), + principal_amount=None if principal is None else Decimal(principal)) + + +def identifiers(rows): + return sorted(inv.identifier for inv in rows) + + +@pytest.mark.fast +def test_a_parent_equal_to_its_tranches_is_excluded(): + """MAIN: the parent restates its tranches.""" + rows = [ + holding('Bolder Panther Group, LLC | Secured Debt', 101_046_000), + holding('Bolder Panther Group, LLC | Secured Debt 1.1', 101_046_000, cost=101_500_000), + holding('Bolder Panther Group, LLC | Secured Debt 1.2', 0, cost=0), + holding('Bolder Panther Group, LLC | Secured Debt 1.3', 0, cost=0), + ] + kept, excluded = _exclude_restated_rows(rows) + assert identifiers(kept) == ['Bolder Panther Group, LLC | Secured Debt 1.1', + 'Bolder Panther Group, LLC | Secured Debt 1.2', + 'Bolder Panther Group, LLC | Secured Debt 1.3'] + assert [(e.investment.identifier, e.reason) for e in excluded] == [ + ('Bolder Panther Group, LLC | Secured Debt', 'total of 3 row(s) that extend it')] + + +@pytest.mark.fast +def test_a_company_total_is_excluded_but_only_against_its_own_heading(): + """HTGC: 'Total X' covers X's rows under that heading, not X's warrants elsewhere.""" + heading = 'Debt Investments Drug Discovery & Development and ' + rows = [ + holding(heading + 'Total Phathom Pharmaceuticals, Inc.', 180_300_000), + holding(heading + 'Phathom Pharmaceuticals, Inc. and Senior Secured Tranche 1', 147_700_000, cost=145_000_000), + holding(heading + 'Phathom Pharmaceuticals, Inc. and Senior Secured Tranche 2', 32_600_000, cost=32_200_000), + holding('Warrant Investments Drug Discovery & Development and Phathom Pharmaceuticals, Inc.', 900_000, + cost=400_000), + ] + kept, excluded = _exclude_restated_rows(rows) + assert [e.investment.identifier for e in excluded] == [heading + 'Total Phathom Pharmaceuticals, Inc.'] + assert sum(inv.fair_value for inv in kept) == Decimal(181_200_000) + + +@pytest.mark.fast +def test_one_identifier_spelled_two_ways_counts_once(): + """CSWC: the copy with fewer fields goes.""" + rows = [ + holding('ITA HOLDINGS GROUP, LLC | First Lien - Term Loan C', 21_150_000, cost=21_100_000, principal=21_150_000), + holding('ITA Holdings Group, LLC | First Lien - Term Loan C', 21_150_000, principal=21_150_000), + ] + kept, excluded = _exclude_restated_rows(rows) + assert identifiers(kept) == ['ITA HOLDINGS GROUP, LLC | First Lien - Term Loan C'] + assert excluded[0].reason == 'same identifier and fair value as another row' + + +@pytest.mark.fast +def test_a_costless_copy_of_a_costed_row_is_excluded(): + """OBDC: the affiliate roll-forward restates the holding under its own member.""" + rows = [ + holding('Blue Owl Credit SLF LLC | LLC Interest | Affiliated', 415_200_000, cost=421_353_000), + holding('Blue Owl Credit SLF LLC(c)', 415_200_000), + ] + kept, excluded = _exclude_restated_rows(rows) + assert identifiers(kept) == ['Blue Owl Credit SLF LLC | LLC Interest | Affiliated'] + assert excluded[0].reason == 'no cost, fair value equal to a costed row' + + +@pytest.mark.fast +def test_real_holdings_that_only_look_like_restatements_are_kept(): + """Silence check: nothing is dropped unless other rows account for its value exactly.""" + rows = [ + # A company whose name starts with "Total", with no rows to be the total of + holding('Total Quality Logistics, LLC | First lien senior secured loan', 12_000_000, cost=12_100_000), + # A parent that is not the sum of the rows extending it is a holding of its own + holding('Acme Holdings, LLC', 5_000_000, cost=4_000_000), + holding('Acme Holdings, LLC, Warrants', 1_000_000, cost=0), + # Two costed tranches that happen to be worth the same + holding('Beta Corp | Secured Debt 1', 7_500_000, cost=7_400_000), + holding('Beta Corp | Secured Debt 2', 7_500_000, cost=7_450_000), + ] + kept, excluded = _exclude_restated_rows(rows) + assert excluded == [] + assert len(kept) == 5 + + +@pytest.mark.fast +def test_the_most_precise_reported_total_wins(): + """ARCC tags its total at decimals -6 and -5.""" + facts = [ + {'concept': 'us-gaap:InvestmentOwnedAtFairValue', 'period_instant': PERIOD, + 'numeric_value': 29_485_000_000.0, 'decimals': '-6', 'unit_ref': 'usd'}, + {'concept': 'us-gaap:InvestmentOwnedAtFairValue', 'period_instant': PERIOD, + 'numeric_value': 29_484_800_000.0, 'decimals': '-5', 'unit_ref': 'usd'}, + {'concept': 'us-gaap:InvestmentOwnedAtFairValue', 'period_instant': PERIOD, AXIS: 'A holding', + 'numeric_value': 1.0, 'decimals': '0', 'unit_ref': 'usd'}, + ] + assert _reported_total_fair_value(facts, PERIOD) == (Decimal('29484800000.0'), 'usd') + assert _reported_total_fair_value(facts, '2024-12-31') == (None, None) + + +def _fact(identifier, concept, value, unit): + return {'concept': f'us-gaap:{concept}', AXIS: identifier, 'period_instant': PERIOD, + 'period_type': 'instant', 'numeric_value': value, 'unit_ref': unit} + + +@pytest.mark.fast +def test_the_reporting_currency_is_never_replaced_by_a_foreign_one(): + """TSLX: Hippo XPA Bidco is tagged in USD and SEK; the SEK fact came last and won.""" + hippo = 'Debt Investments Internet Services Hippo XPA Bidco AB Investment First-lien loan' + facts = [ + {'concept': 'us-gaap:InvestmentOwnedAtFairValue', 'period_instant': PERIOD, 'period_type': 'instant', + 'numeric_value': 23_226_000.0, 'decimals': '-3', 'unit_ref': 'U_USD'}, + _fact(hippo, 'InvestmentOwnedAtFairValue', 23_226_000.0, 'U_USD'), + _fact(hippo, 'InvestmentOwnedAtCost', 21_300_000.0, 'U_USD'), + _fact(hippo, 'InvestmentOwnedAtFairValue', 214_115_000.0, 'U_SEK'), + ] + xbrl = SimpleNamespace(facts=SimpleNamespace(get_facts=lambda: facts), element_catalog={}) + portfolio = PortfolioInvestments.from_xbrl(xbrl, include_untyped=True) + + assert [inv.fair_value for inv in portfolio] == [Decimal('23226000.0')] + assert portfolio.reported_total_fair_value == Decimal('23226000.0') + assert portfolio.reconciliation_gap == 0.0 + + +@pytest.mark.fast +def test_the_gap_is_reported_not_hidden(): + """When the rows do not reach the reported total, the collection says so.""" + facts = [ + {'concept': 'us-gaap:InvestmentOwnedAtFairValue', 'period_instant': PERIOD, 'period_type': 'instant', + 'numeric_value': 100_000_000.0, 'decimals': '-3', 'unit_ref': 'usd'}, + _fact('Gamma LLC | First lien senior secured loan', 'InvestmentOwnedAtFairValue', 110_000_000.0, 'usd'), + _fact('Gamma LLC | First lien senior secured loan', 'InvestmentOwnedAtCost', 108_000_000.0, 'usd'), + ] + xbrl = SimpleNamespace(facts=SimpleNamespace(get_facts=lambda: facts), element_catalog={}) + portfolio = PortfolioInvestments.from_xbrl(xbrl, include_untyped=True) + + assert portfolio.reconciliation_gap == pytest.approx(0.10) + assert 'WARNING: holdings sum +10.0% from the reported total' in portfolio.to_context() + + +@pytest.mark.network +def test_main_fy2025_reconciles_to_its_balance_sheet(): + """Ground truth: MAIN reports 5,518,117,000; the rows used to sum to 6,296,964,000.""" + from edgar import find + + portfolio = PortfolioInvestments.from_xbrl(find('0001396440-26-000016').xbrl(), include_untyped=True) + assert portfolio.reported_total_fair_value == Decimal('5518117000.0') + assert abs(portfolio.reconciliation_gap) < 0.002 + assert len(portfolio.excluded) == 76