From 333d862ed8d0f157192c61f4b3b33a66d7657af2 Mon Sep 17 00:00:00 2001 From: monody0007 <52037177+monody0007@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:18:58 -0700 Subject: [PATCH 1/4] fix(schedule13): OwnershipComparison measures the group total, not the sum of rows OwnershipComparison.shares_change, percent_change and get_summary() added up aggregate_amount and percent_of_class across every reporting person. The reporting persons in one 13D/G restate overlapping parts of the same position, which is why Schedule13D/13G.total_shares and total_percent were moved from sum() to max() in 342ec911. The comparison kept the sum, so a joint filing's change was inflated by roughly the number of filers, and a filer joining or leaving the group could flip its direction. Icahn Enterprises 13D/A 0001539497-26-002605 (2026-09-25, 10 reporting persons) names 0001539497-26-001890 (2026-06-29, 9 persons) as its previousAccessionNumber. Carl C. Icahn's row, the top of the control chain, went from 618,393,343 units (87.28%) to 658,924,537 (87.69%). The comparison reported +203,038,395 units and +11.00 points, from row sums of 1,987,304,001 (280.49%) and 2,190,342,396 (291.49%). A pre-2025 header-only filing (has_structured_data False) carries placeholder zeros, and its total_shares/total_percent are None. Summing those zeros made a structured amendment compared against it report the whole position as newly accumulated: against IEP's SC 13D/A 0001539497-24-002049 the change came back as +2,190,342,396 units and +291.49 points. The change is now None when either side has no structured data, and is_accumulating/is_liquidating/is_unchanged are all False. The two IEP primary_doc.xml files are saved verbatim from SEC as fixtures. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../schedule13-ownership-comparison.fixed.md | 1 + docs/guides/schedule13dg-data-object-guide.md | 2 + edgar/beneficial_ownership/amendments.py | 53 +-- .../test_beneficial_ownership.py | 170 +++++++++ ...icahn_enterprises_0001539497-26-001890.xml | 310 ++++++++++++++++ ...icahn_enterprises_0001539497-26-002605.xml | 336 ++++++++++++++++++ 6 files changed, 852 insertions(+), 20 deletions(-) create mode 100644 changelog.d/schedule13-ownership-comparison.fixed.md create mode 100644 tests/data/beneficial_ownership/schedule13d_icahn_enterprises_0001539497-26-001890.xml create mode 100644 tests/data/beneficial_ownership/schedule13d_icahn_enterprises_0001539497-26-002605.xml diff --git a/changelog.d/schedule13-ownership-comparison.fixed.md b/changelog.d/schedule13-ownership-comparison.fixed.md new file mode 100644 index 000000000..2ea58894e --- /dev/null +++ b/changelog.d/schedule13-ownership-comparison.fixed.md @@ -0,0 +1 @@ +**`OwnershipComparison` added up every reporting person's shares, so a joint 13D/G amendment's change was multiplied by the number of filers.** Icahn Enterprises' 13D/A of 2026-09-25 against 2026-06-29 showed +203,038,395 units and +11.00 points instead of +40,531,194 and +0.41. The change now comes from each filing's `total_shares` and `total_percent`, and is None against a header-only filing. diff --git a/docs/guides/schedule13dg-data-object-guide.md b/docs/guides/schedule13dg-data-object-guide.md index d5f32e1f9..501409b89 100644 --- a/docs/guides/schedule13dg-data-object-guide.md +++ b/docs/guides/schedule13dg-data-object-guide.md @@ -312,6 +312,8 @@ print(f"Accumulating: {comparison.is_accumulating}") print(f"Liquidating: {comparison.is_liquidating}") ``` +The change is measured between the two filings' `total_shares` and `total_percent`, so joint filers are not counted once per reporting person. When either filing is a pre-2025 header-only filing (`has_structured_data` is `False`), `shares_change` and `percent_change` are `None` and `is_accumulating`, `is_liquidating` and `is_unchanged` are all `False`. + --- ## Key Differences: Visual Comparison diff --git a/edgar/beneficial_ownership/amendments.py b/edgar/beneficial_ownership/amendments.py index c5a179676..41d4cd4e9 100644 --- a/edgar/beneficial_ownership/amendments.py +++ b/edgar/beneficial_ownership/amendments.py @@ -78,42 +78,54 @@ class OwnershipComparison: previous: 'Schedule13D | Schedule13G' @property - def shares_change(self) -> int: + def shares_change(self) -> Optional[int]: """ - Change in total shares owned. + Change in total shares owned, from each filing's ``total_shares``. + + The reporting persons in one filing restate overlapping parts of the same + position, so their rows are not added up (see ``Schedule13D.total_shares``). Returns: - Net change in share count (positive = increased, negative = decreased) + Net change in share count (positive = increased, negative = decreased), + or None when either filing has no structured ownership data + (``total_shares`` is None for a pre-2025 header-only filing). """ - curr_shares = sum(p.aggregate_amount for p in self.current.reporting_persons) - prev_shares = sum(p.aggregate_amount for p in self.previous.reporting_persons) + curr_shares = self.current.total_shares + prev_shares = self.previous.total_shares + if curr_shares is None or prev_shares is None: + return None return curr_shares - prev_shares @property - def percent_change(self) -> float: + def percent_change(self) -> Optional[float]: """ - Change in ownership percentage. + Change in ownership percentage, from each filing's ``total_percent``. Returns: - Net change in ownership percentage (e.g., 1.5 means increased by 1.5%) + Net change in ownership percentage (e.g., 1.5 means increased by 1.5%), + or None when either filing has no structured ownership data. """ - curr_pct = sum(p.percent_of_class for p in self.current.reporting_persons) - prev_pct = sum(p.percent_of_class for p in self.previous.reporting_persons) + curr_pct = self.current.total_percent + prev_pct = self.previous.total_percent + if curr_pct is None or prev_pct is None: + return None return curr_pct - prev_pct @property def is_accumulating(self) -> bool: - """Check if shares increased""" - return self.shares_change > 0 + """Check if shares increased (False when the change is unknown)""" + change = self.shares_change + return change is not None and change > 0 @property def is_liquidating(self) -> bool: - """Check if shares decreased""" - return self.shares_change < 0 + """Check if shares decreased (False when the change is unknown)""" + change = self.shares_change + return change is not None and change < 0 @property def is_unchanged(self) -> bool: - """Check if shareholding is unchanged""" + """Check if shareholding is unchanged (False when the change is unknown)""" return self.shares_change == 0 def get_summary(self) -> dict: @@ -121,16 +133,17 @@ def get_summary(self) -> dict: Get summary of changes. Returns: - Dictionary with change metrics + Dictionary with change metrics. Share and percent values are None + where a filing has no structured ownership data. """ return { 'previous_filing_date': self.previous.filing_date, 'current_filing_date': self.current.filing_date, - 'previous_shares': sum(p.aggregate_amount for p in self.previous.reporting_persons), - 'current_shares': sum(p.aggregate_amount for p in self.current.reporting_persons), + 'previous_shares': self.previous.total_shares, + 'current_shares': self.current.total_shares, 'shares_change': self.shares_change, - 'previous_percent': sum(p.percent_of_class for p in self.previous.reporting_persons), - 'current_percent': sum(p.percent_of_class for p in self.current.reporting_persons), + 'previous_percent': self.previous.total_percent, + 'current_percent': self.current.total_percent, 'percent_change': self.percent_change, 'is_accumulating': self.is_accumulating, 'is_liquidating': self.is_liquidating, diff --git a/tests/beneficial_ownership/test_beneficial_ownership.py b/tests/beneficial_ownership/test_beneficial_ownership.py index 2849357ca..82276181a 100644 --- a/tests/beneficial_ownership/test_beneficial_ownership.py +++ b/tests/beneficial_ownership/test_beneficial_ownership.py @@ -377,6 +377,176 @@ def test_ownership_comparison(): assert summary['is_unchanged'] is True +# Consecutive amendments by the Icahn group on Icahn Enterprises L.P., saved +# verbatim from SEC. The later filing names the earlier one as its +# . Every reporting person is a link in one control +# chain ending in Carl C. Icahn, so each row restates part of the same block. +ICAHN_13DA_PREVIOUS_PATH = TEST_DATA_DIR / 'schedule13d_icahn_enterprises_0001539497-26-001890.xml' +ICAHN_13DA_CURRENT_PATH = TEST_DATA_DIR / 'schedule13d_icahn_enterprises_0001539497-26-002605.xml' + + +def _schedule13d_from_xml(path, form, filing_date): + filing = Mock() + filing.form = form + filing.filing_date = filing_date + filing.xml = Mock(return_value=path.read_text()) + return Schedule13D.from_filing(filing) + + +def _joint_person(name, shares, percent, exclude=False): + return ReportingPerson( + cik='', name=name, citizenship='DE', + sole_voting_power=0, shared_voting_power=shares, + sole_dispositive_power=0, shared_dispositive_power=shares, + aggregate_amount=shares, percent_of_class=percent, + type_of_reporting_person='PN', member_of_group='a', + is_aggregate_exclude_shares=exclude, + ) + + +def _schedule13d_with_persons(persons, filing_date): + filing = Mock() + filing.form = 'SCHEDULE 13D/A' + filing.filing_date = filing_date + return Schedule13D( + filing=filing, + issuer_info=IssuerInfo(cik='0000000001', name='Issuer', cusip='000000000'), + security_info=SecurityInfo(title='Common Stock', cusip='000000000'), + reporting_persons=persons, + items=None, + signatures=[], + date_of_event='', + ) + + +@pytest.mark.fast +def test_ownership_comparison_uses_group_total_not_sum_of_rows(): + """A joint filing's rows overlap, so the change is total_shares minus total_shares. + + Icahn Enterprises 13D/A 0001539497-26-001890 (2026-06-29) has 9 reporting + persons; 0001539497-26-002605 (2026-09-25) has 10 after adding MODAL LLC. + Carl C. Icahn's row is the group's position: 618,393,343 units (87.28%), + then 658,924,537 (87.69%). Adding up the rows reported 1,987,304,001 units + (280.49% of the class), then 2,190,342,396 (291.49%). + """ + previous = _schedule13d_from_xml(ICAHN_13DA_PREVIOUS_PATH, 'SCHEDULE 13D/A', date(2026, 6, 29)) + current = _schedule13d_from_xml(ICAHN_13DA_CURRENT_PATH, 'SCHEDULE 13D/A', date(2026, 9, 25)) + assert len(previous.reporting_persons) == 9 + assert len(current.reporting_persons) == 10 + assert previous.total_shares == 618_393_343 + assert current.total_shares == 658_924_537 + + comparison = OwnershipComparison(current=current, previous=previous) + + assert comparison.shares_change == 40_531_194 # not 203,038,395 + assert comparison.percent_change == pytest.approx(0.41) # not 11.00 + assert comparison.is_accumulating is True + + summary = comparison.get_summary() + assert summary['previous_shares'] == 618_393_343 + assert summary['current_shares'] == 658_924_537 + assert summary['previous_percent'] == pytest.approx(87.28) + assert summary['current_percent'] == pytest.approx(87.69) + assert summary['shares_change'] == 40_531_194 + assert summary['percent_change'] == pytest.approx(0.41) + + +@pytest.mark.fast +def test_ownership_comparison_direction_survives_a_person_leaving_the_group(): + """Dropping one joint filer must not turn a purchase into a sale. + + Three joint filers each report the group's 1,000,000 shares (5.0%); in the + amendment one entity has left and the remaining two report 1,200,000 (6.0%). + The group bought 200,000 shares. Adding up the rows gave -600,000 and a + liquidation. + """ + previous = _schedule13d_with_persons([ + _joint_person('Fund LP', 1_000_000, 5.0), + _joint_person('Feeder LP', 1_000_000, 5.0), + _joint_person('Manager LLC', 1_000_000, 5.0), + ], date(2026, 1, 5)) + current = _schedule13d_with_persons([ + _joint_person('Fund LP', 1_200_000, 6.0), + _joint_person('Manager LLC', 1_200_000, 6.0), + ], date(2026, 3, 5)) + + comparison = OwnershipComparison(current=current, previous=previous) + + assert comparison.shares_change == 200_000 + assert comparison.percent_change == pytest.approx(1.0) + assert comparison.is_accumulating is True + assert comparison.is_liquidating is False + + +@pytest.mark.fast +def test_ownership_comparison_ignores_rows_excluded_from_the_aggregate(): + """Rows flagged isAggregateExcludeShares stay out of the change, as in total_shares.""" + previous = _schedule13d_with_persons([ + _joint_person('Fund LP', 1_000_000, 5.0), + _joint_person('Affiliate disclaiming', 400_000, 2.0, exclude=True), + ], date(2026, 1, 5)) + current = _schedule13d_with_persons([ + _joint_person('Fund LP', 1_000_000, 5.0), + ], date(2026, 3, 5)) + + comparison = OwnershipComparison(current=current, previous=previous) + + assert previous.total_shares == 1_000_000 + assert comparison.shares_change == 0 + assert comparison.is_unchanged is True + + +@pytest.mark.fast +def test_ownership_comparison_single_person_filings_unchanged_behaviour(): + """Control: with one reporting person per filing the sum and the total agree.""" + previous = _schedule13d_with_persons([_joint_person('Solo LP', 2_000_000, 8.0)], date(2026, 1, 5)) + current = _schedule13d_with_persons([_joint_person('Solo LP', 1_500_000, 6.0)], date(2026, 3, 5)) + + comparison = OwnershipComparison(current=current, previous=previous) + + assert comparison.shares_change == -500_000 + assert comparison.percent_change == pytest.approx(-2.0) + assert comparison.is_liquidating is True + assert comparison.is_accumulating is False + assert comparison.is_unchanged is False + + +@pytest.mark.fast +def test_ownership_comparison_against_header_only_filing_is_unknown(): + """A pre-2025 header-only filing has no ownership numerics, so there is no change to report. + + Its reporting persons carry placeholder zeros and its total_shares is None. + Comparing a structured amendment against it used to report the amendment's + whole position as newly accumulated. + """ + previous = Schedule13D( + filing=Mock(form='SCHEDULE 13D', filing_date=date(2021, 3, 1)), + issuer_info=IssuerInfo(cik='', name='Icahn Enterprises L.P.', cusip=''), + security_info=SecurityInfo(title='', cusip=''), + reporting_persons=[_joint_person('ICAHN CARL C', 0, 0.0)], + items=None, + signatures=[], + date_of_event='', + has_structured_data=False, + ) + current = _schedule13d_from_xml(ICAHN_13DA_CURRENT_PATH, 'SCHEDULE 13D/A', date(2026, 9, 25)) + assert previous.total_shares is None + + for comparison in (OwnershipComparison(current=current, previous=previous), + OwnershipComparison(current=previous, previous=current)): + assert comparison.shares_change is None + assert comparison.percent_change is None + assert comparison.is_accumulating is False + assert comparison.is_liquidating is False + assert comparison.is_unchanged is False + + summary = OwnershipComparison(current=current, previous=previous).get_summary() + assert summary['previous_shares'] is None + assert summary['previous_percent'] is None + assert summary['current_shares'] == 658_924_537 + assert summary['shares_change'] is None + + @pytest.mark.fast def test_reporting_person_properties(): """Test ReportingPerson computed properties""" diff --git a/tests/data/beneficial_ownership/schedule13d_icahn_enterprises_0001539497-26-001890.xml b/tests/data/beneficial_ownership/schedule13d_icahn_enterprises_0001539497-26-001890.xml new file mode 100644 index 000000000..a0a135f2e --- /dev/null +++ b/tests/data/beneficial_ownership/schedule13d_icahn_enterprises_0001539497-26-001890.xml @@ -0,0 +1,310 @@ + + X0202 + + SCHEDULE 13D/A + 0001539497-26-001145 + + + + + 0000921669 + XXXXXXXX + + + LIVE + + + + + + + + 85 + Depositary Units Representing Limited Partner Interests + 06/25/2026 + false + + 0000813762 + + 451100101 + + Icahn Enterprises L.P. +
+ 16690 COLLINS AVE, + PH-1 + SUNNY ISLES BEACH + FL + 33160 +
+
+ + + Jesse Lynn, Esq. + (305) 422-4100 + + 16690 Collins Avenue, Suite PH-1 + Sunny Isles Beach + FL + 33160 + + + +
+ + + 0001327741 + N + CCI Onshore LLC + a + OO + N + DE + 132123972.00 + 0.00 + 132123972.00 + 0.00 + 132123972.00 + N + 18.65 + OO + + + 0001315356 + N + Gascon Partners + a + OO + N + NY + 85004265.00 + 0.00 + 85004265.00 + 0.00 + 85004265.00 + N + 12.00 + PN + + + 0000945612 + N + High Coast Limited Partnership + a + OO + N + DE + 309873208.00 + 132123972.00 + 309873208.00 + 132123972.00 + 441997180.00 + N + 62.38 + PN + + + 0000928475 + N + Highcrest Investors LLC + a + OO + N + DE + 68307958.00 + 0.00 + 68307958.00 + 0.00 + 68307958.00 + N + 9.64 + OO + + + 0001257325 + N + Thornwood Associates Limited Partnership + a + OO + N + DE + 23083940.00 + 0.00 + 23083940.00 + 0.00 + 23083940.00 + N + 3.26 + PN + + + 0000860851 + N + Barberry Corp. + a + OO + N + DE + 0.00 + 23083940.00 + 0.00 + 23083940.00 + 23083940.00 + N + 3.26 + CO + + + 0001008814 + N + Starfire Holding Corporation + a + OO + N + DE + 0.00 + 68307958.00 + 0.00 + 68307958.00 + 68307958.00 + N + 9.64 + CO + + + 0001044968 + N + Little Meadow Corp. + a + OO + N + DE + 0.00 + 527001445.00 + 0.00 + 527001445.00 + 527001445.00 + N + 74.38 + CO + + + 0000921669 + N + Carl C. Icahn + a + OO + N + X1 + 0.00 + 618393343.00 + 0.00 + 618393343.00 + 618393343.00 + N + 87.28 + IN + + + + + Depositary Units Representing Limited Partner Interests + Icahn Enterprises L.P. + + 16690 COLLINS AVE, + PH-1 + SUNNY ISLES BEACH + FL + 33160 + + The Schedule 13D filed with the U.S. Securities and Exchange Commission ("SEC") on September 24, 1990, as previously amended (the "Initial 13D"), is hereby further amended to furnish the additional information set forth in this Amendment No. 85 to the Initial 13D. All capitalized terms contained herein but not otherwise defined shall have the meanings ascribed to such terms in the Initial 13D. + + + The Reporting Persons may be deemed to beneficially own, in the aggregate, 618,393,343 Depositary Units, representing approximately 87.28% of the Issuer's outstanding Depositary Units (based upon (i) the 672,050,553 Depositary Units stated to be outstanding as of May 6, 2026, by the Issuer in its Form 10-Q filed with the Securities and Exchange Commission on May 6, 2026, plus (ii) the 36,456,030 Depositary Units issued to the Reporting Persons by the Issuer on June 25, 2026, in connection with a regular quarterly distribution of Depositary Units by the Issuer). + CCI Onshore has sole voting power and sole dispositive power with respect to 132,123,972 Depositary Units. Pursuant to Rule 13d-3(a) under the Exchange Act, each of High Coast, Little Meadow and Mr. Icahn (by virtue of their relationships to CCI Onshore) may be deemed to indirectly beneficially own the Depositary Units which CCI Onshore owns. Each of High Coast, Little Meadow and Mr. Icahn disclaims beneficial ownership of the Depositary Units for all other purposes. + +Gascon has sole voting power and sole dispositive power with respect to 85,004,265 Depositary Units. Pursuant to Rule 13d-3(a) under the Exchange Act, each of Little Meadow and Mr. Icahn (by virtue of their relationships to Gascon) may be deemed to indirectly beneficially own the Depositary Units which Gascon owns. Each of Little Meadow and Mr. Icahn disclaims beneficial ownership of the Depositary Units for all other purposes. + +High Coast has sole voting power and sole dispositive power with respect to 309,873,208 Depositary Units. Pursuant to Rule 13d-3(a) under the Exchange Act, each of Little Meadow and Mr. Icahn (by virtue of their relationships to High Coast) may be deemed to indirectly beneficially own the Depositary Units which High Coast owns. Each of Little Meadow and Mr. Icahn disclaims beneficial ownership of the Depositary Units for all other purposes. + +Highcrest has sole voting power and sole dispositive power with respect to 68,307,958 Depositary Units. Pursuant to Rule 13d-3(a) under the Exchange Act, each of Starfire and Mr. Icahn (by virtue of their relationships to Highcrest) may be deemed to indirectly beneficially own the Depositary Units which Highcrest owns. Each of Starfire and Mr. Icahn disclaims beneficial ownership of the Depositary Units for all other purposes. + +Thornwood has sole voting power and sole dispositive power with respect to 23,083,940 Depositary Units. Pursuant to Rule 13d-3(a) under the Exchange Act, each of Barberry and Mr. Icahn (by virtue of their relationships to Thornwood) may be deemed to indirectly beneficially own the Depositary Units which Thornwood owns. Each of Barberry and Mr. Icahn disclaims beneficial ownership of the Depositary Units for all other purposes. + Except for the acquisition of Depositary Units from the Issuer in connection with a quarterly dividend, none of the Reporting Persons effected any transactions in the Issuer's securities during the past sixty (60) days. On June 25, 2026, in connection with the quarterly dividend, CCI Onshore received 7,789,081 Depositary Units, Gascon received 5,011,241 Depositary Units, High Coast received 18,267,900 Depositary Units, Highcrest received 4,026,945 Depositary Units, and Thornwood received 1,360,863 Depositary Units. + + + + + CCI Onshore LLC + + /s/ Rowella Asuncion-Gumabong + Rowella Asuncion-Gumabong / Vice President + 06/29/2026 + + + + Gascon Partners + + /s/ Rowella Asuncion-Gumabong + Rowella Asuncion-Gumabong / Vice President + 06/29/2026 + + + + High Coast Limited Partnership + + /s/ Rowella Asuncion-Gumabong + Rowella Asuncion-Gumabong / Vice President + 06/29/2026 + + + + Highcrest Investors LLC + + /s/ Rowella Asuncion-Gumabong + Rowella Asuncion-Gumabong / Vice President + 06/29/2026 + + + + Thornwood Associates Limited Partnership + + /s/ Rowella Asuncion-Gumabong + Rowella Asuncion-Gumabong / Vice President + 06/29/2026 + + + + Barberry Corp. + + /s/ Rowella Asuncion-Gumabong + Rowella Asuncion-Gumabong / Vice President + 06/29/2026 + + + + Starfire Holding Corporation + + /s/ Rowella Asuncion-Gumabong + Rowella Asuncion-Gumabong / Vice President + 06/29/2026 + + + + Little Meadow Corp. + + /s/ Rowella Asuncion-Gumabong + Rowella Asuncion-Gumabong / Vice President + 06/29/2026 + + + + Carl C. Icahn + + /s/ Carl C. Icahn + CARL C. ICAHN + 06/29/2026 + + + +
+
diff --git a/tests/data/beneficial_ownership/schedule13d_icahn_enterprises_0001539497-26-002605.xml b/tests/data/beneficial_ownership/schedule13d_icahn_enterprises_0001539497-26-002605.xml new file mode 100644 index 000000000..336a943e3 --- /dev/null +++ b/tests/data/beneficial_ownership/schedule13d_icahn_enterprises_0001539497-26-002605.xml @@ -0,0 +1,336 @@ + + X0202 + + SCHEDULE 13D/A + 0001539497-26-001890 + + + + + 0000921669 + XXXXXXXX + + + LIVE + + + + + + + + 86 + Depositary Units Representing Limited Partner Interests + 09/23/2026 + false + + 0000813762 + + 451100101 + + Icahn Enterprises L.P. +
+ 16690 COLLINS AVE, + PH-1 + SUNNY ISLES BEACH + FL + 33160 +
+
+ + + Jesse Lynn, Esq. + (305) 422-4100 + + 16690 Collins Avenue, Suite PH-1 + Sunny Isles Beach + FL + 33160 + + + +
+ + + 0001327741 + N + CCI Onshore LLC + a + OO + N + DE + 140783739.00 + 0.00 + 140783739.00 + 0.00 + 140783739.00 + N + 18.74 + OO + + + 0001315356 + N + Gascon Partners + a + OO + N + NY + 90575678.00 + 0.00 + 90575678.00 + 0.00 + 90575678.00 + N + 12.05 + PN + + + 0000945612 + N + High Coast Limited Partnership + a + OO + N + DE + 330183150.00 + 140783739.00 + 330183150.00 + 140783739.00 + 470966889.00 + N + 62.67 + PN + + + 0000928475 + N + Highcrest Investors LLC + a + OO + N + DE + 72785046.00 + 0.00 + 72785046.00 + 0.00 + 72785046.00 + N + 9.69 + OO + + + 0001257325 + N + Thornwood Associates Limited Partnership + a + OO + N + DE + 24596924.00 + 0.00 + 24596924.00 + 0.00 + 24596924.00 + N + 3.27 + PN + + + 0000860851 + N + Barberry Corp. + a + OO + N + DE + 0.00 + 24596924.00 + 0.00 + 24596924.00 + 24596924.00 + N + 3.27 + CO + + + 0001008814 + N + Starfire Holding Corporation + a + OO + N + DE + 0.00 + 72785046.00 + 0.00 + 72785046.00 + 72785046.00 + N + 9.69 + CO + + + 0001349835 + N + MODAL LLC + a + OO + N + DE + 0.00 + 72785046.00 + 0.00 + 72785046.00 + 72785046.00 + N + 9.69 + CO + OO + + + 0001044968 + N + Little Meadow Corp. + a + OO + N + DE + 0.00 + 561542567.00 + 0.00 + 561542567.00 + 561542567.00 + N + 74.73 + CO + + + 0000921669 + N + Carl C. Icahn + a + OO + N + X1 + 0.00 + 658924537.00 + 0.00 + 658924537.00 + 658924537.00 + N + 87.69 + IN + + + + + Depositary Units Representing Limited Partner Interests + Icahn Enterprises L.P. + + 16690 COLLINS AVE, + PH-1 + SUNNY ISLES BEACH + FL + 33160 + + The Schedule 13D filed with the U.S. Securities and Exchange Commission ("SEC") on September 24, 1990, as previously amended (the "Initial 13D"), is hereby further amended to furnish the additional information set forth in this Amendment No. 86 to the Initial 13D. All capitalized terms contained herein but not otherwise defined shall have the meanings ascribed to such terms in the Initial 13D. + + + The Reporting Persons may be deemed to beneficially own, in the aggregate, 658,924,537 Depositary Units, representing approximately 87.69% of the Issuer's outstanding Depositary Units (based upon (i) the 710,915,093 Depositary Units stated to be outstanding as of August 4, 2026, by the Issuer in its Form 10-Q filed with the Securities and Exchange Commission on August 5, 2026, plus (ii) the 40,531,194 Depositary Units issued to the Reporting Persons by the Issuer on September 23, 2026, in connection with a regular quarterly distribution of Depositary Units by the Issuer). + CCI Onshore has sole voting power and sole dispositive power with respect to 140,783,739 Depositary Units. Pursuant to Rule 13d-3(a) under the Exchange Act, each of High Coast, Little Meadow and Mr. Icahn (by virtue of their relationships to CCI Onshore) may be deemed to indirectly beneficially own the Depositary Units which CCI Onshore owns. Each of High Coast, Little Meadow and Mr. Icahn disclaims beneficial ownership of the Depositary Units for all other purposes. + +Gascon has sole voting power and sole dispositive power with respect to 90,575,678 Depositary Units. Pursuant to Rule 13d-3(a) under the Exchange Act, each of Little Meadow and Mr. Icahn (by virtue of their relationships to Gascon) may be deemed to indirectly beneficially own the Depositary Units which Gascon owns. Each of Little Meadow and Mr. Icahn disclaims beneficial ownership of the Depositary Units for all other purposes. + +High Coast has sole voting power and sole dispositive power with respect to 330,183,150 Depositary Units. Pursuant to Rule 13d-3(a) under the Exchange Act, each of Little Meadow and Mr. Icahn (by virtue of their relationships to High Coast) may be deemed to indirectly beneficially own the Depositary Units which High Coast owns. Each of Little Meadow and Mr. Icahn disclaims beneficial ownership of the Depositary Units for all other purposes. + +Highcrest has sole voting power and sole dispositive power with respect to 72,785,046 Depositary Units. Pursuant to Rule 13d-3(a) under the Exchange Act, each of Starfire, Modal LLC ("Modal"), which owns approximately 99.6% of Starfire and whose sole member is Mr. Icahn, and Mr. Icahn (by virtue of their relationships to Highcrest) may be deemed to indirectly beneficially own the Depositary Units which Highcrest owns. Each of Starfire, Modal and Mr. Icahn disclaims beneficial ownership of the Depositary Units for all other purposes. + +Thornwood has sole voting power and sole dispositive power with respect to 24,596,924 Depositary Units. Pursuant to Rule 13d-3(a) under the Exchange Act, each of Barberry and Mr. Icahn (by virtue of their relationships to Thornwood) may be deemed to indirectly beneficially own the Depositary Units which Thornwood owns. Each of Barberry and Mr. Icahn disclaims beneficial ownership of the Depositary Units for all other purposes. + Except for the acquisition of Depositary Units from the Issuer in connection with a quarterly dividend, none of the Reporting Persons effected any transactions in the Issuer's securities during the past sixty (60) days. On September 23, 2026, in connection with the quarterly dividend, CCI Onshore received 8,659,767 Depositary Units, Gascon received 5,571,413 Depositary Units, High Coast received 20,309,942 Depositary Units, Highcrest received 4,477,088 Depositary Units, and Thornwood received 1,512,984 Depositary Units. + + + + + CCI Onshore LLC + + /s/ Rowella Asuncion-Gumabong + Rowella Asuncion-Gumabong / Vice President + 09/25/2026 + + + + Gascon Partners + + /s/ Rowella Asuncion-Gumabong + Rowella Asuncion-Gumabong / Vice President + 09/25/2026 + + + + High Coast Limited Partnership + + /s/ Rowella Asuncion-Gumabong + Rowella Asuncion-Gumabong / Vice President + 09/25/2026 + + + + Highcrest Investors LLC + + /s/ Rowella Asuncion-Gumabong + Rowella Asuncion-Gumabong / Vice President + 09/25/2026 + + + + Thornwood Associates Limited Partnership + + /s/ Rowella Asuncion-Gumabong + Rowella Asuncion-Gumabong / Vice President + 09/25/2026 + + + + Barberry Corp. + + /s/ Rowella Asuncion-Gumabong + Rowella Asuncion-Gumabong / Vice President + 09/25/2026 + + + + Starfire Holding Corporation + + /s/ Rowella Asuncion-Gumabong + Rowella Asuncion-Gumabong / Vice President + 09/25/2026 + + + + MODAL LLC + + /s/ Jesse Lynn + Jesse Lynn / Vice President + 09/25/2026 + + + + Little Meadow Corp. + + /s/ Rowella Asuncion-Gumabong + Rowella Asuncion-Gumabong / Vice President + 09/25/2026 + + + + Carl C. Icahn + + /s/ Carl C. Icahn + CARL C. ICAHN + 09/25/2026 + + + +
+
From fad33587eb7f2f63eb4ca71ceda5e86462c11ce5 Mon Sep 17 00:00:00 2001 From: monody0007 <52037177+monody0007@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:36:32 -0700 Subject: [PATCH 2/4] fix(schedule13): no ownership change against a filing without holdings data Rescopes the previous commit. That commit switched OwnershipComparison to total_shares, which takes the largest reporting-person row. An independent review found a real 13D/A pair (K Wave Media, accessions 0001829126-25-008079 -> 0001829126-26-000596) whose five reporting persons hold separate blocks: the base sum gives the right -13,445,110 shares and max() gives -1,534,482. No single rule covers both overlapping and separate joint filers, so this commit restores the existing sum of rows and changes only the missing-data case. A pre-2025 header-only filing (has_structured_data False) carries its reporting persons with placeholder zeros, and structured XML with an empty has no rows. Both were read as holding zero shares, so comparing either with a real filing reported the other filing's whole position as bought or sold (4,535,000 shares for the Aadi Bioscience test fixture). shares_change and percent_change are now None when either side has no reporting-person data, the direction flags are all False, and get_summary() reports None for that side. A row that reports 0 is still a number. The guide's example guards the Optional value before formatting it, and states that joint filings are still summed row by row. The Icahn fixtures and max-based tests from the previous commit are removed from the tree; they stay in its history. Co-Authored-By: Claude Opus 5.5 (1M context) --- ...chedule13-comparison-missing-data.fixed.md | 1 + .../schedule13-ownership-comparison.fixed.md | 1 - docs/guides/schedule13dg-data-object-guide.md | 11 +- edgar/beneficial_ownership/amendments.py | 53 ++- .../test_beneficial_ownership.py | 222 +++++------- ...icahn_enterprises_0001539497-26-001890.xml | 310 ---------------- ...icahn_enterprises_0001539497-26-002605.xml | 336 ------------------ 7 files changed, 129 insertions(+), 805 deletions(-) create mode 100644 changelog.d/schedule13-comparison-missing-data.fixed.md delete mode 100644 changelog.d/schedule13-ownership-comparison.fixed.md delete mode 100644 tests/data/beneficial_ownership/schedule13d_icahn_enterprises_0001539497-26-001890.xml delete mode 100644 tests/data/beneficial_ownership/schedule13d_icahn_enterprises_0001539497-26-002605.xml diff --git a/changelog.d/schedule13-comparison-missing-data.fixed.md b/changelog.d/schedule13-comparison-missing-data.fixed.md new file mode 100644 index 000000000..639dd7f1c --- /dev/null +++ b/changelog.d/schedule13-comparison-missing-data.fixed.md @@ -0,0 +1 @@ +**`OwnershipComparison` read a filing without share data as holding zero shares.** Against a pre-2025 header-only filing, the 4,535,000 shares on the Aadi Bioscience 13D in the test data were reported as bought (or sold). `shares_change` and `percent_change` are now None when either filing is header-only or has no reporting-person rows. A reported 0 is still 0. diff --git a/changelog.d/schedule13-ownership-comparison.fixed.md b/changelog.d/schedule13-ownership-comparison.fixed.md deleted file mode 100644 index 2ea58894e..000000000 --- a/changelog.d/schedule13-ownership-comparison.fixed.md +++ /dev/null @@ -1 +0,0 @@ -**`OwnershipComparison` added up every reporting person's shares, so a joint 13D/G amendment's change was multiplied by the number of filers.** Icahn Enterprises' 13D/A of 2026-09-25 against 2026-06-29 showed +203,038,395 units and +11.00 points instead of +40,531,194 and +0.41. The change now comes from each filing's `total_shares` and `total_percent`, and is None against a header-only filing. diff --git a/docs/guides/schedule13dg-data-object-guide.md b/docs/guides/schedule13dg-data-object-guide.md index 501409b89..b7a8327cb 100644 --- a/docs/guides/schedule13dg-data-object-guide.md +++ b/docs/guides/schedule13dg-data-object-guide.md @@ -306,13 +306,18 @@ amendment = Schedule13D.from_filing(amended_filing) comparison = OwnershipComparison(current=amendment, previous=original) -print(f"Shares changed: {comparison.shares_change:+,}") -print(f"Percent changed: {comparison.percent_change:+.1f}%") +if comparison.shares_change is None: + print("Change unknown: a filing has no reporting-person data") +else: + print(f"Shares changed: {comparison.shares_change:+,}") + print(f"Percent changed: {comparison.percent_change:+.1f} points") print(f"Accumulating: {comparison.is_accumulating}") print(f"Liquidating: {comparison.is_liquidating}") ``` -The change is measured between the two filings' `total_shares` and `total_percent`, so joint filers are not counted once per reporting person. When either filing is a pre-2025 header-only filing (`has_structured_data` is `False`), `shares_change` and `percent_change` are `None` and `is_accumulating`, `is_liquidating` and `is_unchanged` are all `False`. +`shares_change` and `percent_change` are `None` when either filing has no reporting-person data: a pre-2025 header-only filing (`has_structured_data` is `False`), or a filing with no reporting-person rows. `is_accumulating`, `is_liquidating` and `is_unchanged` are then all `False`. A reported zero stays `0`. + +The comparison still adds up the rows of every reporting person in each filing. It does not use `total_shares`, and it does not work out whether joint filers report the same shares, so for a joint filing whose rows overlap the change can be overstated. Check the rows in `reporting_persons` before relying on the change for a joint filing. --- diff --git a/edgar/beneficial_ownership/amendments.py b/edgar/beneficial_ownership/amendments.py index 41d4cd4e9..f7cb38bd2 100644 --- a/edgar/beneficial_ownership/amendments.py +++ b/edgar/beneficial_ownership/amendments.py @@ -58,6 +58,25 @@ def from_filing(cls, filing): ) +def _reported_shares(schedule: 'Schedule13D | Schedule13G') -> Optional[int]: + """Sum of the reporting persons' shares, or None when the filing reports none. + + A pre-2025 header-only filing (``has_structured_data`` is False) carries its + reporting persons as identities with placeholder zeros, and a filing parsed + with no reporting-person rows has no numbers at all. Neither holds zero shares. + """ + if not schedule.has_structured_data or not schedule.reporting_persons: + return None + return sum(p.aggregate_amount for p in schedule.reporting_persons) + + +def _reported_percent(schedule: 'Schedule13D | Schedule13G') -> Optional[float]: + """Sum of the reporting persons' percentages, or None as for ``_reported_shares``.""" + if not schedule.has_structured_data or not schedule.reporting_persons: + return None + return sum(p.percent_of_class for p in schedule.reporting_persons) + + @dataclass class OwnershipComparison: """ @@ -71,7 +90,8 @@ class OwnershipComparison: amendment = Schedule13D.from_filing(amended_filing) comparison = OwnershipComparison(current=amendment, previous=original) - print(f"Shares changed by: {comparison.shares_change:,}") + if comparison.shares_change is not None: + print(f"Shares changed by: {comparison.shares_change:,}") print(f"Is accumulating: {comparison.is_accumulating}") """ current: 'Schedule13D | Schedule13G' @@ -80,18 +100,15 @@ class OwnershipComparison: @property def shares_change(self) -> Optional[int]: """ - Change in total shares owned, from each filing's ``total_shares``. - - The reporting persons in one filing restate overlapping parts of the same - position, so their rows are not added up (see ``Schedule13D.total_shares``). + Change in total shares owned. Returns: Net change in share count (positive = increased, negative = decreased), - or None when either filing has no structured ownership data - (``total_shares`` is None for a pre-2025 header-only filing). + or None when either filing has no reporting-person share data + (a pre-2025 header-only filing, or no reporting-person rows) """ - curr_shares = self.current.total_shares - prev_shares = self.previous.total_shares + curr_shares = _reported_shares(self.current) + prev_shares = _reported_shares(self.previous) if curr_shares is None or prev_shares is None: return None return curr_shares - prev_shares @@ -99,14 +116,14 @@ def shares_change(self) -> Optional[int]: @property def percent_change(self) -> Optional[float]: """ - Change in ownership percentage, from each filing's ``total_percent``. + Change in ownership percentage. Returns: Net change in ownership percentage (e.g., 1.5 means increased by 1.5%), - or None when either filing has no structured ownership data. + or None when either filing has no reporting-person data """ - curr_pct = self.current.total_percent - prev_pct = self.previous.total_percent + curr_pct = _reported_percent(self.current) + prev_pct = _reported_percent(self.previous) if curr_pct is None or prev_pct is None: return None return curr_pct - prev_pct @@ -134,16 +151,16 @@ def get_summary(self) -> dict: Returns: Dictionary with change metrics. Share and percent values are None - where a filing has no structured ownership data. + for a filing without reporting-person data. """ return { 'previous_filing_date': self.previous.filing_date, 'current_filing_date': self.current.filing_date, - 'previous_shares': self.previous.total_shares, - 'current_shares': self.current.total_shares, + 'previous_shares': _reported_shares(self.previous), + 'current_shares': _reported_shares(self.current), 'shares_change': self.shares_change, - 'previous_percent': self.previous.total_percent, - 'current_percent': self.current.total_percent, + 'previous_percent': _reported_percent(self.previous), + 'current_percent': _reported_percent(self.current), 'percent_change': self.percent_change, 'is_accumulating': self.is_accumulating, 'is_liquidating': self.is_liquidating, diff --git a/tests/beneficial_ownership/test_beneficial_ownership.py b/tests/beneficial_ownership/test_beneficial_ownership.py index 82276181a..2c6878ad4 100644 --- a/tests/beneficial_ownership/test_beneficial_ownership.py +++ b/tests/beneficial_ownership/test_beneficial_ownership.py @@ -4,6 +4,7 @@ Tests XML parsing, dataclass creation, amendment tracking, and Rich rendering for both Schedule 13D (active ownership) and Schedule 13G (passive ownership). """ +import re from datetime import date from unittest.mock import Mock @@ -377,174 +378,121 @@ def test_ownership_comparison(): assert summary['is_unchanged'] is True -# Consecutive amendments by the Icahn group on Icahn Enterprises L.P., saved -# verbatim from SEC. The later filing names the earlier one as its -# . Every reporting person is a link in one control -# chain ending in Carl C. Icahn, so each row restates part of the same block. -ICAHN_13DA_PREVIOUS_PATH = TEST_DATA_DIR / 'schedule13d_icahn_enterprises_0001539497-26-001890.xml' -ICAHN_13DA_CURRENT_PATH = TEST_DATA_DIR / 'schedule13d_icahn_enterprises_0001539497-26-002605.xml' - - -def _schedule13d_from_xml(path, form, filing_date): +def _schedule13d_from_xml(xml_content, form='SCHEDULE 13D/A', filing_date=date(2024, 12, 31)): filing = Mock() filing.form = form filing.filing_date = filing_date - filing.xml = Mock(return_value=path.read_text()) + filing.xml = Mock(return_value=xml_content) return Schedule13D.from_filing(filing) -def _joint_person(name, shares, percent, exclude=False): - return ReportingPerson( - cik='', name=name, citizenship='DE', - sole_voting_power=0, shared_voting_power=shares, - sole_dispositive_power=0, shared_dispositive_power=shares, - aggregate_amount=shares, percent_of_class=percent, - type_of_reporting_person='PN', member_of_group='a', - is_aggregate_exclude_shares=exclude, - ) +def _header_only_schedule13d(): + """A pre-2025 filing with no XML: identities from the SGML header, no numbers.""" + filer = Mock() + filer.company_information.name = 'BML Investment Partners, L.P.' + filer.company_information.cik = '0001373604' + subject = Mock() + subject.company_information.name = 'Aadi Bioscience, Inc.' + subject.company_information.cik = '0001422142' + filing = Mock() + filing.form = 'SC 13D' + filing.filing_date = date(2021, 3, 1) + filing.xml = Mock(return_value=None) + filing.header = Mock(filers=[filer], subject_companies=[subject]) + return Schedule13D.from_filing(filing) -def _schedule13d_with_persons(persons, filing_date): - filing = Mock() - filing.form = 'SCHEDULE 13D/A' - filing.filing_date = filing_date - return Schedule13D( - filing=filing, - issuer_info=IssuerInfo(cik='0000000001', name='Issuer', cusip='000000000'), - security_info=SecurityInfo(title='Common Stock', cusip='000000000'), - reporting_persons=persons, - items=None, - signatures=[], - date_of_event='', - ) +def _assert_change_unknown(comparison): + assert comparison.shares_change is None + assert comparison.percent_change is None + assert comparison.is_accumulating is False + assert comparison.is_liquidating is False + assert comparison.is_unchanged is False @pytest.mark.fast -def test_ownership_comparison_uses_group_total_not_sum_of_rows(): - """A joint filing's rows overlap, so the change is total_shares minus total_shares. - - Icahn Enterprises 13D/A 0001539497-26-001890 (2026-06-29) has 9 reporting - persons; 0001539497-26-002605 (2026-09-25) has 10 after adding MODAL LLC. - Carl C. Icahn's row is the group's position: 618,393,343 units (87.28%), - then 658,924,537 (87.69%). Adding up the rows reported 1,987,304,001 units - (280.49% of the class), then 2,190,342,396 (291.49%). - """ - previous = _schedule13d_from_xml(ICAHN_13DA_PREVIOUS_PATH, 'SCHEDULE 13D/A', date(2026, 6, 29)) - current = _schedule13d_from_xml(ICAHN_13DA_CURRENT_PATH, 'SCHEDULE 13D/A', date(2026, 9, 25)) - assert len(previous.reporting_persons) == 9 - assert len(current.reporting_persons) == 10 - assert previous.total_shares == 618_393_343 - assert current.total_shares == 658_924_537 +def test_ownership_comparison_against_header_only_filing_is_unknown(): + """A header-only filing has no share counts, so there is no change to report. - comparison = OwnershipComparison(current=current, previous=previous) + Its reporting persons carry placeholder zeros. Adding those up reported the + structured filing's whole position as bought (or sold, the other way round). + """ + header_only = _header_only_schedule13d() + structured = _schedule13d_from_xml(SCHEDULE_13D_XML_PATH.read_text()) + assert header_only.has_structured_data is False + assert header_only.reporting_persons[0].aggregate_amount == 0 - assert comparison.shares_change == 40_531_194 # not 203,038,395 - assert comparison.percent_change == pytest.approx(0.41) # not 11.00 - assert comparison.is_accumulating is True + _assert_change_unknown(OwnershipComparison(current=structured, previous=header_only)) + _assert_change_unknown(OwnershipComparison(current=header_only, previous=structured)) - summary = comparison.get_summary() - assert summary['previous_shares'] == 618_393_343 - assert summary['current_shares'] == 658_924_537 - assert summary['previous_percent'] == pytest.approx(87.28) - assert summary['current_percent'] == pytest.approx(87.69) - assert summary['shares_change'] == 40_531_194 - assert summary['percent_change'] == pytest.approx(0.41) + summary = OwnershipComparison(current=structured, previous=header_only).get_summary() + assert summary['previous_shares'] is None + assert summary['previous_percent'] is None + assert summary['current_shares'] > 0 + assert summary['shares_change'] is None + assert summary['percent_change'] is None @pytest.mark.fast -def test_ownership_comparison_direction_survives_a_person_leaving_the_group(): - """Dropping one joint filer must not turn a purchase into a sale. - - Three joint filers each report the group's 1,000,000 shares (5.0%); in the - amendment one entity has left and the remaining two report 1,200,000 (6.0%). - The group bought 200,000 shares. Adding up the rows gave -600,000 and a - liquidation. - """ - previous = _schedule13d_with_persons([ - _joint_person('Fund LP', 1_000_000, 5.0), - _joint_person('Feeder LP', 1_000_000, 5.0), - _joint_person('Manager LLC', 1_000_000, 5.0), - ], date(2026, 1, 5)) - current = _schedule13d_with_persons([ - _joint_person('Fund LP', 1_200_000, 6.0), - _joint_person('Manager LLC', 1_200_000, 6.0), - ], date(2026, 3, 5)) - - comparison = OwnershipComparison(current=current, previous=previous) +def test_ownership_comparison_without_reporting_person_rows_is_unknown(): + """Structured XML with an empty has no share counts either.""" + xml_content = SCHEDULE_13D_XML_PATH.read_text() + no_rows_xml = re.sub(r'.*?', + '', xml_content, flags=re.S) + no_rows = _schedule13d_from_xml(no_rows_xml) + structured = _schedule13d_from_xml(xml_content) + assert no_rows.has_structured_data is True + assert no_rows.reporting_persons == [] - assert comparison.shares_change == 200_000 - assert comparison.percent_change == pytest.approx(1.0) - assert comparison.is_accumulating is True - assert comparison.is_liquidating is False + _assert_change_unknown(OwnershipComparison(current=no_rows, previous=structured)) + _assert_change_unknown(OwnershipComparison(current=structured, previous=no_rows)) @pytest.mark.fast -def test_ownership_comparison_ignores_rows_excluded_from_the_aggregate(): - """Rows flagged isAggregateExcludeShares stay out of the change, as in total_shares.""" - previous = _schedule13d_with_persons([ - _joint_person('Fund LP', 1_000_000, 5.0), - _joint_person('Affiliate disclaiming', 400_000, 2.0, exclude=True), - ], date(2026, 1, 5)) - current = _schedule13d_with_persons([ - _joint_person('Fund LP', 1_000_000, 5.0), - ], date(2026, 3, 5)) - - comparison = OwnershipComparison(current=current, previous=previous) +def test_ownership_comparison_reported_zero_is_still_a_number(): + """A row that reports 0 shares is data: selling out is a change, and 0 to 0 is unchanged.""" + xml_content = SCHEDULE_13D_XML_PATH.read_text() + zero_xml = re.sub(r'\d+', + '0', xml_content) + zero_xml = re.sub(r'[\d.]+', + '0', zero_xml) + sold_out = _schedule13d_from_xml(zero_xml) + structured = _schedule13d_from_xml(xml_content) + assert [p.aggregate_amount for p in sold_out.reporting_persons] == [0, 0] + + comparison = OwnershipComparison(current=sold_out, previous=structured) + summary = comparison.get_summary() + assert summary['current_shares'] == 0 + assert summary['current_percent'] == 0 + assert comparison.shares_change == -summary['previous_shares'] < 0 + assert comparison.percent_change < 0 + assert comparison.is_liquidating is True - assert previous.total_shares == 1_000_000 - assert comparison.shares_change == 0 - assert comparison.is_unchanged is True + unchanged = OwnershipComparison(current=sold_out, previous=_schedule13d_from_xml(zero_xml)) + assert unchanged.shares_change == 0 + assert unchanged.percent_change == 0 + assert unchanged.is_unchanged is True @pytest.mark.fast -def test_ownership_comparison_single_person_filings_unchanged_behaviour(): - """Control: with one reporting person per filing the sum and the total agree.""" - previous = _schedule13d_with_persons([_joint_person('Solo LP', 2_000_000, 8.0)], date(2026, 1, 5)) - current = _schedule13d_with_persons([_joint_person('Solo LP', 1_500_000, 6.0)], date(2026, 3, 5)) +def test_ownership_comparison_single_person_change(): + """Control: one reporting person selling 500,000 shares.""" + xml_content = SCHEDULE_13D_XML_PATH.read_text() + one_person = re.sub(r'\s*0001373603.*?', + '', xml_content, flags=re.S) + after_sale = one_person.replace('2100000', + '1600000') + after_sale = after_sale.replace('8.5', + '6.5') + previous = _schedule13d_from_xml(one_person) + current = _schedule13d_from_xml(after_sale) + assert len(previous.reporting_persons) == 1 comparison = OwnershipComparison(current=current, previous=previous) - assert comparison.shares_change == -500_000 assert comparison.percent_change == pytest.approx(-2.0) assert comparison.is_liquidating is True assert comparison.is_accumulating is False - assert comparison.is_unchanged is False - - -@pytest.mark.fast -def test_ownership_comparison_against_header_only_filing_is_unknown(): - """A pre-2025 header-only filing has no ownership numerics, so there is no change to report. - - Its reporting persons carry placeholder zeros and its total_shares is None. - Comparing a structured amendment against it used to report the amendment's - whole position as newly accumulated. - """ - previous = Schedule13D( - filing=Mock(form='SCHEDULE 13D', filing_date=date(2021, 3, 1)), - issuer_info=IssuerInfo(cik='', name='Icahn Enterprises L.P.', cusip=''), - security_info=SecurityInfo(title='', cusip=''), - reporting_persons=[_joint_person('ICAHN CARL C', 0, 0.0)], - items=None, - signatures=[], - date_of_event='', - has_structured_data=False, - ) - current = _schedule13d_from_xml(ICAHN_13DA_CURRENT_PATH, 'SCHEDULE 13D/A', date(2026, 9, 25)) - assert previous.total_shares is None - - for comparison in (OwnershipComparison(current=current, previous=previous), - OwnershipComparison(current=previous, previous=current)): - assert comparison.shares_change is None - assert comparison.percent_change is None - assert comparison.is_accumulating is False - assert comparison.is_liquidating is False - assert comparison.is_unchanged is False - - summary = OwnershipComparison(current=current, previous=previous).get_summary() - assert summary['previous_shares'] is None - assert summary['previous_percent'] is None - assert summary['current_shares'] == 658_924_537 - assert summary['shares_change'] is None @pytest.mark.fast diff --git a/tests/data/beneficial_ownership/schedule13d_icahn_enterprises_0001539497-26-001890.xml b/tests/data/beneficial_ownership/schedule13d_icahn_enterprises_0001539497-26-001890.xml deleted file mode 100644 index a0a135f2e..000000000 --- a/tests/data/beneficial_ownership/schedule13d_icahn_enterprises_0001539497-26-001890.xml +++ /dev/null @@ -1,310 +0,0 @@ - - X0202 - - SCHEDULE 13D/A - 0001539497-26-001145 - - - - - 0000921669 - XXXXXXXX - - - LIVE - - - - - - - - 85 - Depositary Units Representing Limited Partner Interests - 06/25/2026 - false - - 0000813762 - - 451100101 - - Icahn Enterprises L.P. -
- 16690 COLLINS AVE, - PH-1 - SUNNY ISLES BEACH - FL - 33160 -
-
- - - Jesse Lynn, Esq. - (305) 422-4100 - - 16690 Collins Avenue, Suite PH-1 - Sunny Isles Beach - FL - 33160 - - - -
- - - 0001327741 - N - CCI Onshore LLC - a - OO - N - DE - 132123972.00 - 0.00 - 132123972.00 - 0.00 - 132123972.00 - N - 18.65 - OO - - - 0001315356 - N - Gascon Partners - a - OO - N - NY - 85004265.00 - 0.00 - 85004265.00 - 0.00 - 85004265.00 - N - 12.00 - PN - - - 0000945612 - N - High Coast Limited Partnership - a - OO - N - DE - 309873208.00 - 132123972.00 - 309873208.00 - 132123972.00 - 441997180.00 - N - 62.38 - PN - - - 0000928475 - N - Highcrest Investors LLC - a - OO - N - DE - 68307958.00 - 0.00 - 68307958.00 - 0.00 - 68307958.00 - N - 9.64 - OO - - - 0001257325 - N - Thornwood Associates Limited Partnership - a - OO - N - DE - 23083940.00 - 0.00 - 23083940.00 - 0.00 - 23083940.00 - N - 3.26 - PN - - - 0000860851 - N - Barberry Corp. - a - OO - N - DE - 0.00 - 23083940.00 - 0.00 - 23083940.00 - 23083940.00 - N - 3.26 - CO - - - 0001008814 - N - Starfire Holding Corporation - a - OO - N - DE - 0.00 - 68307958.00 - 0.00 - 68307958.00 - 68307958.00 - N - 9.64 - CO - - - 0001044968 - N - Little Meadow Corp. - a - OO - N - DE - 0.00 - 527001445.00 - 0.00 - 527001445.00 - 527001445.00 - N - 74.38 - CO - - - 0000921669 - N - Carl C. Icahn - a - OO - N - X1 - 0.00 - 618393343.00 - 0.00 - 618393343.00 - 618393343.00 - N - 87.28 - IN - - - - - Depositary Units Representing Limited Partner Interests - Icahn Enterprises L.P. - - 16690 COLLINS AVE, - PH-1 - SUNNY ISLES BEACH - FL - 33160 - - The Schedule 13D filed with the U.S. Securities and Exchange Commission ("SEC") on September 24, 1990, as previously amended (the "Initial 13D"), is hereby further amended to furnish the additional information set forth in this Amendment No. 85 to the Initial 13D. All capitalized terms contained herein but not otherwise defined shall have the meanings ascribed to such terms in the Initial 13D. - - - The Reporting Persons may be deemed to beneficially own, in the aggregate, 618,393,343 Depositary Units, representing approximately 87.28% of the Issuer's outstanding Depositary Units (based upon (i) the 672,050,553 Depositary Units stated to be outstanding as of May 6, 2026, by the Issuer in its Form 10-Q filed with the Securities and Exchange Commission on May 6, 2026, plus (ii) the 36,456,030 Depositary Units issued to the Reporting Persons by the Issuer on June 25, 2026, in connection with a regular quarterly distribution of Depositary Units by the Issuer). - CCI Onshore has sole voting power and sole dispositive power with respect to 132,123,972 Depositary Units. Pursuant to Rule 13d-3(a) under the Exchange Act, each of High Coast, Little Meadow and Mr. Icahn (by virtue of their relationships to CCI Onshore) may be deemed to indirectly beneficially own the Depositary Units which CCI Onshore owns. Each of High Coast, Little Meadow and Mr. Icahn disclaims beneficial ownership of the Depositary Units for all other purposes. - -Gascon has sole voting power and sole dispositive power with respect to 85,004,265 Depositary Units. Pursuant to Rule 13d-3(a) under the Exchange Act, each of Little Meadow and Mr. Icahn (by virtue of their relationships to Gascon) may be deemed to indirectly beneficially own the Depositary Units which Gascon owns. Each of Little Meadow and Mr. Icahn disclaims beneficial ownership of the Depositary Units for all other purposes. - -High Coast has sole voting power and sole dispositive power with respect to 309,873,208 Depositary Units. Pursuant to Rule 13d-3(a) under the Exchange Act, each of Little Meadow and Mr. Icahn (by virtue of their relationships to High Coast) may be deemed to indirectly beneficially own the Depositary Units which High Coast owns. Each of Little Meadow and Mr. Icahn disclaims beneficial ownership of the Depositary Units for all other purposes. - -Highcrest has sole voting power and sole dispositive power with respect to 68,307,958 Depositary Units. Pursuant to Rule 13d-3(a) under the Exchange Act, each of Starfire and Mr. Icahn (by virtue of their relationships to Highcrest) may be deemed to indirectly beneficially own the Depositary Units which Highcrest owns. Each of Starfire and Mr. Icahn disclaims beneficial ownership of the Depositary Units for all other purposes. - -Thornwood has sole voting power and sole dispositive power with respect to 23,083,940 Depositary Units. Pursuant to Rule 13d-3(a) under the Exchange Act, each of Barberry and Mr. Icahn (by virtue of their relationships to Thornwood) may be deemed to indirectly beneficially own the Depositary Units which Thornwood owns. Each of Barberry and Mr. Icahn disclaims beneficial ownership of the Depositary Units for all other purposes. - Except for the acquisition of Depositary Units from the Issuer in connection with a quarterly dividend, none of the Reporting Persons effected any transactions in the Issuer's securities during the past sixty (60) days. On June 25, 2026, in connection with the quarterly dividend, CCI Onshore received 7,789,081 Depositary Units, Gascon received 5,011,241 Depositary Units, High Coast received 18,267,900 Depositary Units, Highcrest received 4,026,945 Depositary Units, and Thornwood received 1,360,863 Depositary Units. - - - - - CCI Onshore LLC - - /s/ Rowella Asuncion-Gumabong - Rowella Asuncion-Gumabong / Vice President - 06/29/2026 - - - - Gascon Partners - - /s/ Rowella Asuncion-Gumabong - Rowella Asuncion-Gumabong / Vice President - 06/29/2026 - - - - High Coast Limited Partnership - - /s/ Rowella Asuncion-Gumabong - Rowella Asuncion-Gumabong / Vice President - 06/29/2026 - - - - Highcrest Investors LLC - - /s/ Rowella Asuncion-Gumabong - Rowella Asuncion-Gumabong / Vice President - 06/29/2026 - - - - Thornwood Associates Limited Partnership - - /s/ Rowella Asuncion-Gumabong - Rowella Asuncion-Gumabong / Vice President - 06/29/2026 - - - - Barberry Corp. - - /s/ Rowella Asuncion-Gumabong - Rowella Asuncion-Gumabong / Vice President - 06/29/2026 - - - - Starfire Holding Corporation - - /s/ Rowella Asuncion-Gumabong - Rowella Asuncion-Gumabong / Vice President - 06/29/2026 - - - - Little Meadow Corp. - - /s/ Rowella Asuncion-Gumabong - Rowella Asuncion-Gumabong / Vice President - 06/29/2026 - - - - Carl C. Icahn - - /s/ Carl C. Icahn - CARL C. ICAHN - 06/29/2026 - - - -
-
diff --git a/tests/data/beneficial_ownership/schedule13d_icahn_enterprises_0001539497-26-002605.xml b/tests/data/beneficial_ownership/schedule13d_icahn_enterprises_0001539497-26-002605.xml deleted file mode 100644 index 336a943e3..000000000 --- a/tests/data/beneficial_ownership/schedule13d_icahn_enterprises_0001539497-26-002605.xml +++ /dev/null @@ -1,336 +0,0 @@ - - X0202 - - SCHEDULE 13D/A - 0001539497-26-001890 - - - - - 0000921669 - XXXXXXXX - - - LIVE - - - - - - - - 86 - Depositary Units Representing Limited Partner Interests - 09/23/2026 - false - - 0000813762 - - 451100101 - - Icahn Enterprises L.P. -
- 16690 COLLINS AVE, - PH-1 - SUNNY ISLES BEACH - FL - 33160 -
-
- - - Jesse Lynn, Esq. - (305) 422-4100 - - 16690 Collins Avenue, Suite PH-1 - Sunny Isles Beach - FL - 33160 - - - -
- - - 0001327741 - N - CCI Onshore LLC - a - OO - N - DE - 140783739.00 - 0.00 - 140783739.00 - 0.00 - 140783739.00 - N - 18.74 - OO - - - 0001315356 - N - Gascon Partners - a - OO - N - NY - 90575678.00 - 0.00 - 90575678.00 - 0.00 - 90575678.00 - N - 12.05 - PN - - - 0000945612 - N - High Coast Limited Partnership - a - OO - N - DE - 330183150.00 - 140783739.00 - 330183150.00 - 140783739.00 - 470966889.00 - N - 62.67 - PN - - - 0000928475 - N - Highcrest Investors LLC - a - OO - N - DE - 72785046.00 - 0.00 - 72785046.00 - 0.00 - 72785046.00 - N - 9.69 - OO - - - 0001257325 - N - Thornwood Associates Limited Partnership - a - OO - N - DE - 24596924.00 - 0.00 - 24596924.00 - 0.00 - 24596924.00 - N - 3.27 - PN - - - 0000860851 - N - Barberry Corp. - a - OO - N - DE - 0.00 - 24596924.00 - 0.00 - 24596924.00 - 24596924.00 - N - 3.27 - CO - - - 0001008814 - N - Starfire Holding Corporation - a - OO - N - DE - 0.00 - 72785046.00 - 0.00 - 72785046.00 - 72785046.00 - N - 9.69 - CO - - - 0001349835 - N - MODAL LLC - a - OO - N - DE - 0.00 - 72785046.00 - 0.00 - 72785046.00 - 72785046.00 - N - 9.69 - CO - OO - - - 0001044968 - N - Little Meadow Corp. - a - OO - N - DE - 0.00 - 561542567.00 - 0.00 - 561542567.00 - 561542567.00 - N - 74.73 - CO - - - 0000921669 - N - Carl C. Icahn - a - OO - N - X1 - 0.00 - 658924537.00 - 0.00 - 658924537.00 - 658924537.00 - N - 87.69 - IN - - - - - Depositary Units Representing Limited Partner Interests - Icahn Enterprises L.P. - - 16690 COLLINS AVE, - PH-1 - SUNNY ISLES BEACH - FL - 33160 - - The Schedule 13D filed with the U.S. Securities and Exchange Commission ("SEC") on September 24, 1990, as previously amended (the "Initial 13D"), is hereby further amended to furnish the additional information set forth in this Amendment No. 86 to the Initial 13D. All capitalized terms contained herein but not otherwise defined shall have the meanings ascribed to such terms in the Initial 13D. - - - The Reporting Persons may be deemed to beneficially own, in the aggregate, 658,924,537 Depositary Units, representing approximately 87.69% of the Issuer's outstanding Depositary Units (based upon (i) the 710,915,093 Depositary Units stated to be outstanding as of August 4, 2026, by the Issuer in its Form 10-Q filed with the Securities and Exchange Commission on August 5, 2026, plus (ii) the 40,531,194 Depositary Units issued to the Reporting Persons by the Issuer on September 23, 2026, in connection with a regular quarterly distribution of Depositary Units by the Issuer). - CCI Onshore has sole voting power and sole dispositive power with respect to 140,783,739 Depositary Units. Pursuant to Rule 13d-3(a) under the Exchange Act, each of High Coast, Little Meadow and Mr. Icahn (by virtue of their relationships to CCI Onshore) may be deemed to indirectly beneficially own the Depositary Units which CCI Onshore owns. Each of High Coast, Little Meadow and Mr. Icahn disclaims beneficial ownership of the Depositary Units for all other purposes. - -Gascon has sole voting power and sole dispositive power with respect to 90,575,678 Depositary Units. Pursuant to Rule 13d-3(a) under the Exchange Act, each of Little Meadow and Mr. Icahn (by virtue of their relationships to Gascon) may be deemed to indirectly beneficially own the Depositary Units which Gascon owns. Each of Little Meadow and Mr. Icahn disclaims beneficial ownership of the Depositary Units for all other purposes. - -High Coast has sole voting power and sole dispositive power with respect to 330,183,150 Depositary Units. Pursuant to Rule 13d-3(a) under the Exchange Act, each of Little Meadow and Mr. Icahn (by virtue of their relationships to High Coast) may be deemed to indirectly beneficially own the Depositary Units which High Coast owns. Each of Little Meadow and Mr. Icahn disclaims beneficial ownership of the Depositary Units for all other purposes. - -Highcrest has sole voting power and sole dispositive power with respect to 72,785,046 Depositary Units. Pursuant to Rule 13d-3(a) under the Exchange Act, each of Starfire, Modal LLC ("Modal"), which owns approximately 99.6% of Starfire and whose sole member is Mr. Icahn, and Mr. Icahn (by virtue of their relationships to Highcrest) may be deemed to indirectly beneficially own the Depositary Units which Highcrest owns. Each of Starfire, Modal and Mr. Icahn disclaims beneficial ownership of the Depositary Units for all other purposes. - -Thornwood has sole voting power and sole dispositive power with respect to 24,596,924 Depositary Units. Pursuant to Rule 13d-3(a) under the Exchange Act, each of Barberry and Mr. Icahn (by virtue of their relationships to Thornwood) may be deemed to indirectly beneficially own the Depositary Units which Thornwood owns. Each of Barberry and Mr. Icahn disclaims beneficial ownership of the Depositary Units for all other purposes. - Except for the acquisition of Depositary Units from the Issuer in connection with a quarterly dividend, none of the Reporting Persons effected any transactions in the Issuer's securities during the past sixty (60) days. On September 23, 2026, in connection with the quarterly dividend, CCI Onshore received 8,659,767 Depositary Units, Gascon received 5,571,413 Depositary Units, High Coast received 20,309,942 Depositary Units, Highcrest received 4,477,088 Depositary Units, and Thornwood received 1,512,984 Depositary Units. - - - - - CCI Onshore LLC - - /s/ Rowella Asuncion-Gumabong - Rowella Asuncion-Gumabong / Vice President - 09/25/2026 - - - - Gascon Partners - - /s/ Rowella Asuncion-Gumabong - Rowella Asuncion-Gumabong / Vice President - 09/25/2026 - - - - High Coast Limited Partnership - - /s/ Rowella Asuncion-Gumabong - Rowella Asuncion-Gumabong / Vice President - 09/25/2026 - - - - Highcrest Investors LLC - - /s/ Rowella Asuncion-Gumabong - Rowella Asuncion-Gumabong / Vice President - 09/25/2026 - - - - Thornwood Associates Limited Partnership - - /s/ Rowella Asuncion-Gumabong - Rowella Asuncion-Gumabong / Vice President - 09/25/2026 - - - - Barberry Corp. - - /s/ Rowella Asuncion-Gumabong - Rowella Asuncion-Gumabong / Vice President - 09/25/2026 - - - - Starfire Holding Corporation - - /s/ Rowella Asuncion-Gumabong - Rowella Asuncion-Gumabong / Vice President - 09/25/2026 - - - - MODAL LLC - - /s/ Jesse Lynn - Jesse Lynn / Vice President - 09/25/2026 - - - - Little Meadow Corp. - - /s/ Rowella Asuncion-Gumabong - Rowella Asuncion-Gumabong / Vice President - 09/25/2026 - - - - Carl C. Icahn - - /s/ Carl C. Icahn - CARL C. ICAHN - 09/25/2026 - - - -
-
From 99fb2a7a3cf1b53b29f8571d0a1d5f2de1c92ceb Mon Sep 17 00:00:00 2001 From: monody0007 <52037177+monody0007@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:04:41 -0700 Subject: [PATCH 3/4] fix(schedule13): no sale or purchase from ownership figures a filing leaves out OwnershipComparison summed aggregate_amount and percent_of_class over the reporting persons, and the parsers read a figure the filing does not report as 0. The schema makes every cover-page ownership figure optional on 13D/A and 13G/A, a pre-2025 filing read from its SGML header has identities only, and a filing can have no reporting-person rows. Against the Aadi Bioscience 13D in the test data (2,100,000 + 2,435,000 shares), an amendment omitting its figures compared as a 4,535,000-share sale with is_liquidating True; against the Jushi Holdings 13G it was a 20,000,000-share sale. The 6.0 release plan ships 5.x from main and keeps value and type changes for the 6.0 window, staging an additive half first. This change is that half: - ReportingPerson.unreported_fields names the figures a filing leaves out, and ReportingPerson.reported(figure) returns None for them. The fields keep their int/float types and the placeholder 0. - OwnershipComparison.reported_shares_change and reported_percent_change are None when either filing leaves the figure out for any reporting person. is_accumulating, is_liquidating and is_unchanged use them, so an unknown change sets no direction flag. - shares_change, percent_change and get_summary() keep returning numbers and emit a FutureWarning when they read an unreported figure as 0. - to_context() and the rich display show unreported figures as not reported or unavailable instead of 0. The 6.0 flip (the fields, totals and shares_change/percent_change become None) is listed under Still to come in docs/upgrade/6.0.md. A reported 0 stays 0 throughout. The PR's earlier regression tests in tests/beneficial_ownership move to tests/issues/regression/test_issue_1379_schedule13_unreported_figures.py. --- changelog.d/1379.added.md | 1 + changelog.d/1379.fixed.md | 1 + ...chedule13-comparison-missing-data.fixed.md | 1 - docs/guides/schedule13dg-data-object-guide.md | 18 +- docs/upgrade/6.0.md | 1 + edgar/beneficial_ownership/amendments.py | 118 ++++-- edgar/beneficial_ownership/models.py | 49 ++- edgar/beneficial_ownership/rendering.py | 41 +- edgar/beneficial_ownership/schedule13.py | 116 ++++-- .../test_beneficial_ownership.py | 118 ------ ...ssue_1379_schedule13_unreported_figures.py | 379 ++++++++++++++++++ 11 files changed, 638 insertions(+), 205 deletions(-) create mode 100644 changelog.d/1379.added.md create mode 100644 changelog.d/1379.fixed.md delete mode 100644 changelog.d/schedule13-comparison-missing-data.fixed.md create mode 100644 tests/issues/regression/test_issue_1379_schedule13_unreported_figures.py diff --git a/changelog.d/1379.added.md b/changelog.d/1379.added.md new file mode 100644 index 000000000..6b2021648 --- /dev/null +++ b/changelog.d/1379.added.md @@ -0,0 +1 @@ +**`ReportingPerson.reported()` and `OwnershipComparison.reported_shares_change` tell an unreported ownership figure from a reported 0.** The 5.x fields still hold a placeholder 0 for a figure the filing leaves out and name it in `ReportingPerson.unreported_fields`; `reported()`, `reported_shares_change` and `reported_percent_change` return None for it, as the fields will in 6.0. (GH #1379) diff --git a/changelog.d/1379.fixed.md b/changelog.d/1379.fixed.md new file mode 100644 index 000000000..81fe26408 --- /dev/null +++ b/changelog.d/1379.fixed.md @@ -0,0 +1 @@ +**`OwnershipComparison.is_liquidating` reported a sale against a Schedule 13D/G filing that gives no share figures.** A 13D/A or 13G/A may omit them and a pre-2025 header-only filing has none, but they were read as 0: an amendment omitting them on the Aadi Bioscience 13D in the test data sold all 4,535,000 shares. The direction flags are now False when the change is unknown, and `shares_change` and `percent_change` warn that they return None in 6.0. (GH #1379) diff --git a/changelog.d/schedule13-comparison-missing-data.fixed.md b/changelog.d/schedule13-comparison-missing-data.fixed.md deleted file mode 100644 index 639dd7f1c..000000000 --- a/changelog.d/schedule13-comparison-missing-data.fixed.md +++ /dev/null @@ -1 +0,0 @@ -**`OwnershipComparison` read a filing without share data as holding zero shares.** Against a pre-2025 header-only filing, the 4,535,000 shares on the Aadi Bioscience 13D in the test data were reported as bought (or sold). `shares_change` and `percent_change` are now None when either filing is header-only or has no reporting-person rows. A reported 0 is still 0. diff --git a/docs/guides/schedule13dg-data-object-guide.md b/docs/guides/schedule13dg-data-object-guide.md index b7a8327cb..8384b9826 100644 --- a/docs/guides/schedule13dg-data-object-guide.md +++ b/docs/guides/schedule13dg-data-object-guide.md @@ -250,6 +250,10 @@ Each reporting person (individual or entity) in the filing. | `member_of_group` | `Optional[str]` | `"a"` = joint filer, `"b"` = separate filer | | `total_voting_power` | `int` | Computed: sole + shared voting power | | `total_dispositive_power` | `int` | Computed: sole + shared dispositive power | +| `unreported_fields` | `frozenset[str]` | Names of the six ownership figures above that the filing does not report | +| `reported(figure)` | `Optional[int \| float]` | An ownership figure by field name, or `None` when the filing does not report it | + +An amendment (13D/A, 13G/A) may leave any of the six ownership figures out, and a pre-2025 filing read from its SGML header has identities only. Such a figure holds a placeholder `0` in its field and is named in `unreported_fields`; `reported()` returns `None` for it, and `0` for a reported zero. In edgartools 6.0 the fields themselves become `None` for an unreported figure, so code that uses `reported()` today keeps working unchanged. ### IssuerInfo @@ -306,16 +310,20 @@ amendment = Schedule13D.from_filing(amended_filing) comparison = OwnershipComparison(current=amendment, previous=original) -if comparison.shares_change is None: - print("Change unknown: a filing has no reporting-person data") +change = comparison.reported_shares_change +if change is None: + print("Shares changed: unknown, a filing does not report its share count") else: - print(f"Shares changed: {comparison.shares_change:+,}") - print(f"Percent changed: {comparison.percent_change:+.1f} points") + print(f"Shares changed: {change:+,}") +if comparison.reported_percent_change is not None: + print(f"Percent changed: {comparison.reported_percent_change:+.1f} points") print(f"Accumulating: {comparison.is_accumulating}") print(f"Liquidating: {comparison.is_liquidating}") ``` -`shares_change` and `percent_change` are `None` when either filing has no reporting-person data: a pre-2025 header-only filing (`has_structured_data` is `False`), or a filing with no reporting-person rows. `is_accumulating`, `is_liquidating` and `is_unchanged` are then all `False`. A reported zero stays `0`. +`reported_shares_change` is `None` when either filing does not report its share counts: an amendment that leaves them out (the schema makes them optional on 13D/A and 13G/A), a pre-2025 header-only filing (`has_structured_data` is `False`), or a filing with no reporting-person rows. `is_accumulating`, `is_liquidating` and `is_unchanged` are then all `False`. `reported_percent_change` is `None` in the same way for percentages, and the two are checked separately, since a filing can report one and not the other. A reported zero stays `0`. + +`shares_change` and `percent_change` still return a number in 5.x, reading an unreported figure as `0`, and emit a `FutureWarning` when they do. In edgartools 6.0 they return `None` instead, as `reported_shares_change` and `reported_percent_change` do today. The comparison still adds up the rows of every reporting person in each filing. It does not use `total_shares`, and it does not work out whether joint filers report the same shares, so for a joint filing whose rows overlap the change can be overstated. Check the rows in `reporting_persons` before relying on the change for a joint filing. diff --git a/docs/upgrade/6.0.md b/docs/upgrade/6.0.md index b96ab58a3..d4d3b65e9 100644 --- a/docs/upgrade/6.0.md +++ b/docs/upgrade/6.0.md @@ -676,6 +676,7 @@ against these; each will get a section above when it lands. | Public API **enforcement** | `__all__` is already declared (see below) — 6.0 makes the names outside it private, and some currently-importable names will move. | | Source tree reorganisation | Module locations change; import paths follow. The first moves have landed and have their own section above — `edgar.effect` and `edgar.form144`. Further moves will each get a section as they ship. | | `FactQuery.to_dataframe()` sentinels | `preferred_sign` and `fiscal_year` move to nullable `Int64`. | +| Schedule 13D/G figures a filing does not report | `ReportingPerson`'s six ownership figures, `total_voting_power`, `total_dispositive_power`, `Schedule13D`/`Schedule13G` `total_shares` and `total_percent`, and `OwnershipComparison.shares_change`, `percent_change` and the share and percent values of `get_summary()` become `None` where the filing does not report the figure, instead of reading it as `0`. 5.x already has the `None`-returning form: `ReportingPerson.reported(figure)`, `ReportingPerson.unreported_fields`, and `OwnershipComparison.reported_shares_change` / `reported_percent_change`; `shares_change` and `percent_change` emit a `FutureWarning` when they read an unreported figure as `0`. A reported `0` stays `0`. | --- diff --git a/edgar/beneficial_ownership/amendments.py b/edgar/beneficial_ownership/amendments.py index f7cb38bd2..e72062d7a 100644 --- a/edgar/beneficial_ownership/amendments.py +++ b/edgar/beneficial_ownership/amendments.py @@ -4,6 +4,7 @@ This module provides utilities for linking amendments to original filings and comparing ownership changes between filings. """ +import warnings from dataclasses import dataclass from typing import TYPE_CHECKING, Optional @@ -58,23 +59,34 @@ def from_filing(cls, filing): ) -def _reported_shares(schedule: 'Schedule13D | Schedule13G') -> Optional[int]: - """Sum of the reporting persons' shares, or None when the filing reports none. +def _reported_sum(schedule: 'Schedule13D | Schedule13G', figure: str): + """Sum of one figure over the reporting persons, or None when the filing does not report it. - A pre-2025 header-only filing (``has_structured_data`` is False) carries its - reporting persons as identities with placeholder zeros, and a filing parsed - with no reporting-person rows has no numbers at all. Neither holds zero shares. + A figure is unreported when a reporting person leaves it out (an amendment may, + and a pre-2025 filing read from its SGML header has identities only), or when + the filing has no reporting-person rows at all. None of those means zero shares. """ if not schedule.has_structured_data or not schedule.reporting_persons: return None - return sum(p.aggregate_amount for p in schedule.reporting_persons) + values = [p.reported(figure) for p in schedule.reporting_persons] + if None in values: + return None + return sum(values) -def _reported_percent(schedule: 'Schedule13D | Schedule13G') -> Optional[float]: - """Sum of the reporting persons' percentages, or None as for ``_reported_shares``.""" - if not schedule.has_structured_data or not schedule.reporting_persons: - return None - return sum(p.percent_of_class for p in schedule.reporting_persons) +def _placeholder_sum(schedule: 'Schedule13D | Schedule13G', figure: str): + """Sum of one figure over the reporting persons, reading an unreported figure as 0 (the 5.x values).""" + return sum(getattr(p, figure) for p in schedule.reporting_persons) + + +def _warn_unreported(name: str, replacement: str) -> None: + # The text is fixed per property so Python's default filter shows it once per call site. + warnings.warn( + f"OwnershipComparison.{name} is computed from figures a filing does not report, read as 0. " + f"It will be None in edgartools 6.0. Use OwnershipComparison.{replacement}, which is None today.", + FutureWarning, + stacklevel=3, + ) @dataclass @@ -85,83 +97,123 @@ class OwnershipComparison: Useful for tracking ownership changes between the original filing and an amendment, or between two amendments. + A change is known only when both filings report the figure for every + reporting person. An amendment may leave the figures out, a pre-2025 filing + read from its SGML header has none, and a filing may have no reporting-person + rows. ``reported_shares_change`` and ``reported_percent_change`` are None then, + and ``is_accumulating``, ``is_liquidating`` and ``is_unchanged`` are all False. + Example: original = Schedule13D.from_filing(original_filing) amendment = Schedule13D.from_filing(amended_filing) comparison = OwnershipComparison(current=amendment, previous=original) - if comparison.shares_change is not None: - print(f"Shares changed by: {comparison.shares_change:,}") + if comparison.reported_shares_change is not None: + print(f"Shares changed by: {comparison.reported_shares_change:,}") print(f"Is accumulating: {comparison.is_accumulating}") """ current: 'Schedule13D | Schedule13G' previous: 'Schedule13D | Schedule13G' @property - def shares_change(self) -> Optional[int]: + def reported_shares_change(self) -> Optional[int]: """ - Change in total shares owned. + Change in total shares owned, or None when either filing does not report its share counts. Returns: Net change in share count (positive = increased, negative = decreased), - or None when either filing has no reporting-person share data - (a pre-2025 header-only filing, or no reporting-person rows) + or None for a figure left out of an amendment, a pre-2025 header-only + filing, or a filing with no reporting-person rows. A reported 0 is a number. """ - curr_shares = _reported_shares(self.current) - prev_shares = _reported_shares(self.previous) + curr_shares = _reported_sum(self.current, 'aggregate_amount') + prev_shares = _reported_sum(self.previous, 'aggregate_amount') if curr_shares is None or prev_shares is None: return None return curr_shares - prev_shares @property - def percent_change(self) -> Optional[float]: + def reported_percent_change(self) -> Optional[float]: """ - Change in ownership percentage. + Change in ownership percentage, or None when either filing does not report its percentages. Returns: Net change in ownership percentage (e.g., 1.5 means increased by 1.5%), - or None when either filing has no reporting-person data + or None as for ``reported_shares_change`` """ - curr_pct = _reported_percent(self.current) - prev_pct = _reported_percent(self.previous) + curr_pct = _reported_sum(self.current, 'percent_of_class') + prev_pct = _reported_sum(self.previous, 'percent_of_class') if curr_pct is None or prev_pct is None: return None return curr_pct - prev_pct + @property + def shares_change(self) -> int: + """ + Change in total shares owned. + + Returns: + Net change in share count (positive = increased, negative = decreased). + A figure a filing does not report counts as 0 here and emits a + ``FutureWarning``: in edgartools 6.0 this returns None instead, as + ``reported_shares_change`` does today. + """ + if self.reported_shares_change is None: + _warn_unreported('shares_change', 'reported_shares_change') + return _placeholder_sum(self.current, 'aggregate_amount') - _placeholder_sum(self.previous, 'aggregate_amount') + + @property + def percent_change(self) -> float: + """ + Change in ownership percentage. + + Returns: + Net change in ownership percentage (e.g., 1.5 means increased by 1.5%). + A figure a filing does not report counts as 0 here and emits a + ``FutureWarning``: in edgartools 6.0 this returns None instead, as + ``reported_percent_change`` does today. + """ + if self.reported_percent_change is None: + _warn_unreported('percent_change', 'reported_percent_change') + return _placeholder_sum(self.current, 'percent_of_class') - _placeholder_sum(self.previous, 'percent_of_class') + @property def is_accumulating(self) -> bool: """Check if shares increased (False when the change is unknown)""" - change = self.shares_change + change = self.reported_shares_change return change is not None and change > 0 @property def is_liquidating(self) -> bool: """Check if shares decreased (False when the change is unknown)""" - change = self.shares_change + change = self.reported_shares_change return change is not None and change < 0 @property def is_unchanged(self) -> bool: """Check if shareholding is unchanged (False when the change is unknown)""" - return self.shares_change == 0 + return self.reported_shares_change == 0 def get_summary(self) -> dict: """ Get summary of changes. Returns: - Dictionary with change metrics. Share and percent values are None - for a filing without reporting-person data. + Dictionary with change metrics. The share and percent values read a + figure a filing does not report as 0, as ``shares_change`` does, and + emit the same ``FutureWarning``; ``reported_shares_change`` and + ``reported_percent_change`` are None for them. """ return { 'previous_filing_date': self.previous.filing_date, 'current_filing_date': self.current.filing_date, - 'previous_shares': _reported_shares(self.previous), - 'current_shares': _reported_shares(self.current), + 'previous_shares': _placeholder_sum(self.previous, 'aggregate_amount'), + 'current_shares': _placeholder_sum(self.current, 'aggregate_amount'), 'shares_change': self.shares_change, - 'previous_percent': _reported_percent(self.previous), - 'current_percent': _reported_percent(self.current), + 'previous_percent': _placeholder_sum(self.previous, 'percent_of_class'), + 'current_percent': _placeholder_sum(self.current, 'percent_of_class'), 'percent_change': self.percent_change, + 'reported_shares_change': self.reported_shares_change, + 'reported_percent_change': self.reported_percent_change, 'is_accumulating': self.is_accumulating, 'is_liquidating': self.is_liquidating, 'is_unchanged': self.is_unchanged diff --git a/edgar/beneficial_ownership/models.py b/edgar/beneficial_ownership/models.py index a37cb45d1..f3e669bd6 100644 --- a/edgar/beneficial_ownership/models.py +++ b/edgar/beneficial_ownership/models.py @@ -5,9 +5,10 @@ from Schedule 13D/G XML filings. """ from dataclasses import dataclass -from typing import Optional +from typing import FrozenSet, Optional, Union from edgar._party import Address +from edgar.exceptions import ValidationError __all__ = [ 'ReportingPerson', @@ -18,6 +19,30 @@ 'Signature' ] +# The ownership figures on a reporting person's cover page. An amendment may +# leave any of them out, and a pre-2025 filing read from its SGML header has none. +_OWNERSHIP_FIGURES = ( + 'sole_voting_power', + 'shared_voting_power', + 'sole_dispositive_power', + 'shared_dispositive_power', + 'aggregate_amount', + 'percent_of_class', +) + + +def _reported_total(persons, figure: str) -> Optional[Union[int, float]]: + """ + A filing's total for an ownership figure, or None when a person it counts leaves the figure out. + + The same max() over the persons not flagged ``is_aggregate_exclude_shares`` that + ``total_shares`` and ``total_percent`` take, which read an unreported figure as 0. + """ + values = [p.reported(figure) for p in persons if not p.is_aggregate_exclude_shares] + if None in values: + return None + return max(values, default=0) + @dataclass(frozen=True) class ReportingPerson: @@ -26,6 +51,11 @@ class ReportingPerson: Represents a single reporting person from the SC 13D or SC 13G filing. Joint filers will have multiple ReportingPerson instances. + + An ownership figure the filing does not report holds a placeholder 0 and is + named in ``unreported_fields``; ``reported()`` returns None for it instead. + A reported 0 stays 0 in both. edgartools 6.0 will make the fields themselves + None for an unreported figure. """ cik: str name: str @@ -42,6 +72,23 @@ class ReportingPerson: member_of_group: Optional[str] = None # "a" = group member (joint filer), "b" = separate filer is_aggregate_exclude_shares: bool = False # True if shares excluded from aggregate count no_cik: bool = False # True if reporting person has no CIK assigned + unreported_fields: FrozenSet[str] = frozenset() # ownership figures the filing leaves out (placeholder 0) + + def reported(self, figure: str) -> Optional[Union[int, float]]: + """ + An ownership figure as the filing reports it, or None when the filing leaves it out. + + Args: + figure: One of ``sole_voting_power``, ``shared_voting_power``, + ``sole_dispositive_power``, ``shared_dispositive_power``, + ``aggregate_amount`` or ``percent_of_class``. + """ + if figure not in _OWNERSHIP_FIGURES: + raise ValidationError(f"Not an ownership figure: {figure!r}. Use one of {', '.join(_OWNERSHIP_FIGURES)}", + parameter='figure', invalid_value=figure) + if figure in self.unreported_fields: + return None + return getattr(self, figure) @property def total_voting_power(self) -> int: diff --git a/edgar/beneficial_ownership/rendering.py b/edgar/beneficial_ownership/rendering.py index 400e8cab9..c2e8af815 100644 --- a/edgar/beneficial_ownership/rendering.py +++ b/edgar/beneficial_ownership/rendering.py @@ -12,6 +12,8 @@ from rich.table import Table from rich.text import Text +from edgar.beneficial_ownership.models import ReportingPerson, _reported_total + if TYPE_CHECKING: from edgar.beneficial_ownership.schedule13 import Schedule13D, Schedule13G @@ -30,6 +32,19 @@ def _fmt_percent(value) -> str: return f"{value:.2f}%" if value is not None else _UNAVAILABLE +def _schedule_total(schedule, figure: str): + """A schedule's total for a figure, or None when the filing does not report it.""" + if not schedule.has_structured_data: + return None + return _reported_total(schedule.reporting_persons, figure) + + +def _total_power(person: ReportingPerson, sole: str, shared: str): + """Sole + shared power, or None when the filing leaves either out.""" + values = (person.reported(sole), person.reported(shared)) + return None if None in values else sum(values) + + def _degraded_notice() -> Panel: """Banner shown when only header identities are available (pre-2025 HTML filing).""" return Panel( @@ -72,8 +87,8 @@ def render_schedule13d(schedule: 'Schedule13D') -> Panel: header.add_row("CUSIP:", schedule.security_info.cusip) # Aggregate ownership - header.add_row("Total Shares:", _fmt_shares(schedule.total_shares)) - header.add_row("Total Percent:", _fmt_percent(schedule.total_percent)) + header.add_row("Total Shares:", _fmt_shares(_schedule_total(schedule, 'aggregate_amount'))) + header.add_row("Total Percent:", _fmt_percent(_schedule_total(schedule, 'percent_of_class'))) elements = [header, Text()] if not schedule.has_structured_data: @@ -94,15 +109,14 @@ def render_schedule13d(schedule: 'Schedule13D') -> Panel: persons_table.add_column("Voting Power", justify="right") persons_table.add_column("Dispositive Power", justify="right") - structured = schedule.has_structured_data for person in schedule.reporting_persons: persons_table.add_row( person.name, person.cik or 'N/A', - f"{person.aggregate_amount:,}" if structured else _UNAVAILABLE, - f"{person.percent_of_class:.2f}%" if structured else _UNAVAILABLE, - f"{person.total_voting_power:,}" if structured else _UNAVAILABLE, - f"{person.total_dispositive_power:,}" if structured else _UNAVAILABLE + _fmt_shares(person.reported('aggregate_amount')), + _fmt_percent(person.reported('percent_of_class')), + _fmt_shares(_total_power(person, 'sole_voting_power', 'shared_voting_power')), + _fmt_shares(_total_power(person, 'sole_dispositive_power', 'shared_dispositive_power')) ) elements.append(persons_table) @@ -190,8 +204,8 @@ def render_schedule13g(schedule: 'Schedule13G') -> Panel: header.add_row("Rule:", schedule.rule_designation) # Aggregate ownership - header.add_row("Total Shares:", _fmt_shares(schedule.total_shares)) - header.add_row("Total Percent:", _fmt_percent(schedule.total_percent)) + header.add_row("Total Shares:", _fmt_shares(_schedule_total(schedule, 'aggregate_amount'))) + header.add_row("Total Percent:", _fmt_percent(_schedule_total(schedule, 'percent_of_class'))) header.add_row("Type:", "Passive Institutional Investor") elements = [header, Text()] @@ -213,15 +227,14 @@ def render_schedule13g(schedule: 'Schedule13G') -> Panel: persons_table.add_column("Voting Power", justify="right") persons_table.add_column("Dispositive Power", justify="right") - structured = schedule.has_structured_data for person in schedule.reporting_persons: persons_table.add_row( person.name, person.type_of_reporting_person or 'N/A', - f"{person.aggregate_amount:,}" if structured else _UNAVAILABLE, - f"{person.percent_of_class:.2f}%" if structured else _UNAVAILABLE, - f"{person.total_voting_power:,}" if structured else _UNAVAILABLE, - f"{person.total_dispositive_power:,}" if structured else _UNAVAILABLE + _fmt_shares(person.reported('aggregate_amount')), + _fmt_percent(person.reported('percent_of_class')), + _fmt_shares(_total_power(person, 'sole_voting_power', 'shared_voting_power')), + _fmt_shares(_total_power(person, 'sole_dispositive_power', 'shared_dispositive_power')) ) elements.append(persons_table) diff --git a/edgar/beneficial_ownership/schedule13.py b/edgar/beneficial_ownership/schedule13.py index 32d62922d..a88508e59 100644 --- a/edgar/beneficial_ownership/schedule13.py +++ b/edgar/beneficial_ownership/schedule13.py @@ -12,7 +12,16 @@ from edgar._filings import Filing from edgar._party import Address -from edgar.beneficial_ownership.models import IssuerInfo, ReportingPerson, Schedule13DItems, Schedule13GItems, SecurityInfo, Signature +from edgar.beneficial_ownership.models import ( + _OWNERSHIP_FIGURES, + IssuerInfo, + ReportingPerson, + Schedule13DItems, + Schedule13GItems, + SecurityInfo, + Signature, + _reported_total, +) from edgar.core import get_bool from edgar.xmltools import child_text, find_all_elements, find_element, local_name from edgar.xmltools import parse_xml as parse_xml_document @@ -66,6 +75,43 @@ def safe_float(value: Optional[str], default: float = 0.0) -> float: return default +def _ownership_figures(sole_voting: Optional[str], shared_voting: Optional[str], + sole_dispositive: Optional[str], shared_dispositive: Optional[str], + aggregate: Optional[str], percent: Optional[str]) -> dict: + """ + ReportingPerson keyword arguments for the six ownership figures, from their XML text. + + A figure whose element is missing (an amendment may leave any of them out) or + is not a number keeps the placeholder 0 it has always had, and is named in + ``unreported_fields`` so that it can be told apart from a reported 0. + """ + texts = dict(zip(_OWNERSHIP_FIGURES, (sole_voting, shared_voting, sole_dispositive, + shared_dispositive, aggregate, percent), strict=True)) + figures = {} + unreported = set() + for figure, text in texts.items(): + parse = safe_float if figure == 'percent_of_class' else safe_int + value = parse(text, default=None) + if value is None: + unreported.add(figure) + value = parse(None) + figures[figure] = value + figures['unreported_fields'] = frozenset(unreported) + return figures + + +def _describe_shares(value: Optional[int]) -> str: + return f"{value:,} shares" if value is not None else "shares not reported" + + +def _describe_percent(value: Optional[float]) -> str: + return f"{value:.1f}%" if value is not None else "percent not reported" + + +def _describe_count(value: Optional[int]) -> str: + return f"{value:,}" if value is not None else "not reported" + + def extract_amendment_number(form_name: str) -> Optional[int]: """ Extract amendment number from filing form name. @@ -156,6 +202,7 @@ def _partial_from_header(filing: 'Filing') -> tuple: aggregate_amount=0, percent_of_class=0.0, type_of_reporting_person='', + unreported_fields=frozenset(_OWNERSHIP_FIGURES), )) return issuer_info, reporting_persons @@ -292,12 +339,14 @@ def parse_xml(xml: str) -> dict: name=child_text(person_el, 'reportingPersonName') or '', fund_type=child_text(person_el, 'fundType'), citizenship=child_text(person_el, 'citizenshipOrOrganization') or '', - sole_voting_power=safe_int(child_text(person_el, 'soleVotingPower')), - shared_voting_power=safe_int(child_text(person_el, 'sharedVotingPower')), - sole_dispositive_power=safe_int(child_text(person_el, 'soleDispositivePower')), - shared_dispositive_power=safe_int(child_text(person_el, 'sharedDispositivePower')), - aggregate_amount=safe_int(child_text(person_el, 'aggregateAmountOwned')), - percent_of_class=safe_float(child_text(person_el, 'percentOfClass')), + **_ownership_figures( + child_text(person_el, 'soleVotingPower'), + child_text(person_el, 'sharedVotingPower'), + child_text(person_el, 'soleDispositivePower'), + child_text(person_el, 'sharedDispositivePower'), + child_text(person_el, 'aggregateAmountOwned'), + child_text(person_el, 'percentOfClass'), + ), type_of_reporting_person=child_text(person_el, 'typeOfReportingPerson') or '', comment=child_text(person_el, 'commentContent'), member_of_group=child_text(person_el, 'memberOfGroup'), @@ -554,7 +603,9 @@ def to_context(self, detail: str = 'standard') -> str: if self.security_info.title: lines.append(f"Security: {self.security_info.title}") if self.has_structured_data: - lines.append(f"Ownership: {self.total_percent:.1f}% ({self.total_shares:,} shares)") + percent = _reported_total(self.reporting_persons, 'percent_of_class') + shares = _reported_total(self.reporting_persons, 'aggregate_amount') + lines.append(f"Ownership: {_describe_percent(percent)} ({_describe_shares(shares)})") else: lines.append("Ownership: unavailable (pre-2025 HTML filing)") @@ -588,7 +639,8 @@ def to_context(self, detail: str = 'standard') -> str: lines.append("REPORTING PERSONS:") for p in self.reporting_persons[:5]: if self.has_structured_data: - p_line = f" {p.name}: {p.percent_of_class:.1f}% ({p.aggregate_amount:,} shares)" + p_line = (f" {p.name}: {_describe_percent(p.reported('percent_of_class'))}" + f" ({_describe_shares(p.reported('aggregate_amount'))})") else: p_line = f" {p.name}" if p.cik: @@ -629,10 +681,10 @@ def to_context(self, detail: str = 'standard') -> str: p = self.reporting_persons[0] lines.append("") lines.append(f"VOTING/DISPOSITIVE POWER ({p.name}):") - lines.append(f" Sole Voting: {p.sole_voting_power:,}") - lines.append(f" Shared Voting: {p.shared_voting_power:,}") - lines.append(f" Sole Dispositive: {p.sole_dispositive_power:,}") - lines.append(f" Shared Dispositive: {p.shared_dispositive_power:,}") + lines.append(f" Sole Voting: {_describe_count(p.reported('sole_voting_power'))}") + lines.append(f" Shared Voting: {_describe_count(p.reported('shared_voting_power'))}") + lines.append(f" Sole Dispositive: {_describe_count(p.reported('sole_dispositive_power'))}") + lines.append(f" Shared Dispositive: {_describe_count(p.reported('shared_dispositive_power'))}") return "\n".join(lines) @@ -777,15 +829,15 @@ def parse_xml(xml: str) -> dict: for person_el in find_all_elements(form_data, 'coverPageHeaderReportingPersonDetails'): # Get shares info shares_el = find_element(person_el, 'reportingPersonBeneficiallyOwnedNumberOfShares') - sole_voting = 0 - shared_voting = 0 - sole_disp = 0 - shared_disp = 0 + sole_voting = None + shared_voting = None + sole_disp = None + shared_disp = None if shares_el is not None: - sole_voting = safe_int(child_text(shares_el, 'soleVotingPower')) - shared_voting = safe_int(child_text(shares_el, 'sharedVotingPower')) - sole_disp = safe_int(child_text(shares_el, 'soleDispositivePower')) - shared_disp = safe_int(child_text(shares_el, 'sharedDispositivePower')) + sole_voting = child_text(shares_el, 'soleVotingPower') + shared_voting = child_text(shares_el, 'sharedVotingPower') + sole_disp = child_text(shares_el, 'soleDispositivePower') + shared_disp = child_text(shares_el, 'sharedDispositivePower') aggregate = child_text(person_el, 'reportingPersonBeneficiallyOwnedAggregateNumberOfShares') percent = child_text(person_el, 'classPercent') @@ -794,12 +846,7 @@ def parse_xml(xml: str) -> dict: cik='', # Not provided in 13G cover page name=child_text(person_el, 'reportingPersonName') or '', citizenship=child_text(person_el, 'citizenshipOrOrganization') or '', - sole_voting_power=sole_voting, - shared_voting_power=shared_voting, - sole_dispositive_power=sole_disp, - shared_dispositive_power=shared_disp, - aggregate_amount=safe_int(aggregate), - percent_of_class=safe_float(percent), + **_ownership_figures(sole_voting, shared_voting, sole_disp, shared_disp, aggregate, percent), type_of_reporting_person=child_text(person_el, 'typeOfReportingPerson') or '', fund_type=None, comment=None, @@ -1068,7 +1115,9 @@ def to_context(self, detail: str = 'standard') -> str: if self.security_info.title: lines.append(f"Security: {self.security_info.title}") if self.has_structured_data: - lines.append(f"Ownership: {self.total_percent:.1f}% ({self.total_shares:,} shares)") + percent = _reported_total(self.reporting_persons, 'percent_of_class') + shares = _reported_total(self.reporting_persons, 'aggregate_amount') + lines.append(f"Ownership: {_describe_percent(percent)} ({_describe_shares(shares)})") else: lines.append("Ownership: unavailable (pre-2025 HTML filing)") @@ -1103,7 +1152,8 @@ def to_context(self, detail: str = 'standard') -> str: lines.append("REPORTING PERSONS:") for p in self.reporting_persons[:5]: if self.has_structured_data: - p_line = f" {p.name}: {p.percent_of_class:.1f}% ({p.aggregate_amount:,} shares)" + p_line = (f" {p.name}: {_describe_percent(p.reported('percent_of_class'))}" + f" ({_describe_shares(p.reported('aggregate_amount'))})") else: p_line = f" {p.name}" if p.cik: @@ -1132,10 +1182,10 @@ def to_context(self, detail: str = 'standard') -> str: p = self.reporting_persons[0] lines.append("") lines.append(f"VOTING/DISPOSITIVE POWER ({p.name}):") - lines.append(f" Sole Voting: {p.sole_voting_power:,}") - lines.append(f" Shared Voting: {p.shared_voting_power:,}") - lines.append(f" Sole Dispositive: {p.sole_dispositive_power:,}") - lines.append(f" Shared Dispositive: {p.shared_dispositive_power:,}") + lines.append(f" Sole Voting: {_describe_count(p.reported('sole_voting_power'))}") + lines.append(f" Shared Voting: {_describe_count(p.reported('shared_voting_power'))}") + lines.append(f" Sole Dispositive: {_describe_count(p.reported('sole_dispositive_power'))}") + lines.append(f" Shared Dispositive: {_describe_count(p.reported('shared_dispositive_power'))}") return "\n".join(lines) diff --git a/tests/beneficial_ownership/test_beneficial_ownership.py b/tests/beneficial_ownership/test_beneficial_ownership.py index 2c6878ad4..2849357ca 100644 --- a/tests/beneficial_ownership/test_beneficial_ownership.py +++ b/tests/beneficial_ownership/test_beneficial_ownership.py @@ -4,7 +4,6 @@ Tests XML parsing, dataclass creation, amendment tracking, and Rich rendering for both Schedule 13D (active ownership) and Schedule 13G (passive ownership). """ -import re from datetime import date from unittest.mock import Mock @@ -378,123 +377,6 @@ def test_ownership_comparison(): assert summary['is_unchanged'] is True -def _schedule13d_from_xml(xml_content, form='SCHEDULE 13D/A', filing_date=date(2024, 12, 31)): - filing = Mock() - filing.form = form - filing.filing_date = filing_date - filing.xml = Mock(return_value=xml_content) - return Schedule13D.from_filing(filing) - - -def _header_only_schedule13d(): - """A pre-2025 filing with no XML: identities from the SGML header, no numbers.""" - filer = Mock() - filer.company_information.name = 'BML Investment Partners, L.P.' - filer.company_information.cik = '0001373604' - subject = Mock() - subject.company_information.name = 'Aadi Bioscience, Inc.' - subject.company_information.cik = '0001422142' - filing = Mock() - filing.form = 'SC 13D' - filing.filing_date = date(2021, 3, 1) - filing.xml = Mock(return_value=None) - filing.header = Mock(filers=[filer], subject_companies=[subject]) - return Schedule13D.from_filing(filing) - - -def _assert_change_unknown(comparison): - assert comparison.shares_change is None - assert comparison.percent_change is None - assert comparison.is_accumulating is False - assert comparison.is_liquidating is False - assert comparison.is_unchanged is False - - -@pytest.mark.fast -def test_ownership_comparison_against_header_only_filing_is_unknown(): - """A header-only filing has no share counts, so there is no change to report. - - Its reporting persons carry placeholder zeros. Adding those up reported the - structured filing's whole position as bought (or sold, the other way round). - """ - header_only = _header_only_schedule13d() - structured = _schedule13d_from_xml(SCHEDULE_13D_XML_PATH.read_text()) - assert header_only.has_structured_data is False - assert header_only.reporting_persons[0].aggregate_amount == 0 - - _assert_change_unknown(OwnershipComparison(current=structured, previous=header_only)) - _assert_change_unknown(OwnershipComparison(current=header_only, previous=structured)) - - summary = OwnershipComparison(current=structured, previous=header_only).get_summary() - assert summary['previous_shares'] is None - assert summary['previous_percent'] is None - assert summary['current_shares'] > 0 - assert summary['shares_change'] is None - assert summary['percent_change'] is None - - -@pytest.mark.fast -def test_ownership_comparison_without_reporting_person_rows_is_unknown(): - """Structured XML with an empty has no share counts either.""" - xml_content = SCHEDULE_13D_XML_PATH.read_text() - no_rows_xml = re.sub(r'.*?', - '', xml_content, flags=re.S) - no_rows = _schedule13d_from_xml(no_rows_xml) - structured = _schedule13d_from_xml(xml_content) - assert no_rows.has_structured_data is True - assert no_rows.reporting_persons == [] - - _assert_change_unknown(OwnershipComparison(current=no_rows, previous=structured)) - _assert_change_unknown(OwnershipComparison(current=structured, previous=no_rows)) - - -@pytest.mark.fast -def test_ownership_comparison_reported_zero_is_still_a_number(): - """A row that reports 0 shares is data: selling out is a change, and 0 to 0 is unchanged.""" - xml_content = SCHEDULE_13D_XML_PATH.read_text() - zero_xml = re.sub(r'\d+', - '0', xml_content) - zero_xml = re.sub(r'[\d.]+', - '0', zero_xml) - sold_out = _schedule13d_from_xml(zero_xml) - structured = _schedule13d_from_xml(xml_content) - assert [p.aggregate_amount for p in sold_out.reporting_persons] == [0, 0] - - comparison = OwnershipComparison(current=sold_out, previous=structured) - summary = comparison.get_summary() - assert summary['current_shares'] == 0 - assert summary['current_percent'] == 0 - assert comparison.shares_change == -summary['previous_shares'] < 0 - assert comparison.percent_change < 0 - assert comparison.is_liquidating is True - - unchanged = OwnershipComparison(current=sold_out, previous=_schedule13d_from_xml(zero_xml)) - assert unchanged.shares_change == 0 - assert unchanged.percent_change == 0 - assert unchanged.is_unchanged is True - - -@pytest.mark.fast -def test_ownership_comparison_single_person_change(): - """Control: one reporting person selling 500,000 shares.""" - xml_content = SCHEDULE_13D_XML_PATH.read_text() - one_person = re.sub(r'\s*0001373603.*?', - '', xml_content, flags=re.S) - after_sale = one_person.replace('2100000', - '1600000') - after_sale = after_sale.replace('8.5', - '6.5') - previous = _schedule13d_from_xml(one_person) - current = _schedule13d_from_xml(after_sale) - assert len(previous.reporting_persons) == 1 - - comparison = OwnershipComparison(current=current, previous=previous) - assert comparison.shares_change == -500_000 - assert comparison.percent_change == pytest.approx(-2.0) - assert comparison.is_liquidating is True - assert comparison.is_accumulating is False - - @pytest.mark.fast def test_reporting_person_properties(): """Test ReportingPerson computed properties""" diff --git a/tests/issues/regression/test_issue_1379_schedule13_unreported_figures.py b/tests/issues/regression/test_issue_1379_schedule13_unreported_figures.py new file mode 100644 index 000000000..e2763e354 --- /dev/null +++ b/tests/issues/regression/test_issue_1379_schedule13_unreported_figures.py @@ -0,0 +1,379 @@ +"""Regression tests for OwnershipComparison against filings that do not report their figures. + +GitHub PR: https://github.com/dgunning/edgartools/pull/1379 + +OwnershipComparison added up ``aggregate_amount`` and ``percent_of_class`` over +``reporting_persons`` and read a figure the filing does not report as 0. Compared +with a filing that does report shares, that turned the whole position into a sale +or a purchase. Three ways a filing can leave the figures out: + +- a pre-2025 filing read from its SGML header (``has_structured_data`` is False), + whose reporting persons are identities only; +- structured XML with an empty ````; +- an amendment that omits them. The schema makes every figure on a reporting + person's cover page optional on 13D/A and 13G/A + (docs/sec/schedule13/schema-reference.md: "m" on 13D/13G, "o" on the + amendments), while the reporting-person row itself stays mandatory. + +Against the Aadi Bioscience 13D in the test data (2,100,000 + 2,435,000 shares, +8.5% + 9.9%), an amendment omitting its figures came back as shares_change +-4,535,000, percent_change -18.4 and is_liquidating True. Against the Jushi +Holdings 13G (two 10,000,000-share rows) it was -20,000,000. + +The parsers now record which figures a filing leaves out +(``ReportingPerson.unreported_fields``, ``ReportingPerson.reported()``) and the +comparison treats them as unknown: ``reported_shares_change`` and +``reported_percent_change`` are None and no direction flag is set. The 5.x +fields keep their types, so an unreported figure still holds the placeholder 0 +and ``shares_change``/``percent_change`` still return numbers, with a +FutureWarning that they become None in 6.0 (the 6.0 release plan stages value +changes additively in 5.x). A reported 0 stays 0: the controls below check that +selling out, a single-person sale and a 13G/A that does report its figures +still compare as numbers, without a warning. +""" +import re +import warnings +from datetime import date +from pathlib import Path +from unittest.mock import Mock + +import pytest + +from edgar.beneficial_ownership import Schedule13D, Schedule13G +from edgar.beneficial_ownership.amendments import OwnershipComparison +from edgar.exceptions import ValidationError + +OWNERSHIP_FIGURES = {'sole_voting_power', 'shared_voting_power', 'sole_dispositive_power', + 'shared_dispositive_power', 'aggregate_amount', 'percent_of_class'} +TEST_DATA_DIR = Path(__file__).parent.parent.parent / "data" / "beneficial_ownership" +SCHEDULE_13D_XML_PATH = TEST_DATA_DIR / "schedule13d.xml" +SCHEDULE_13G_XML_PATH = TEST_DATA_DIR / "schedule13g.xml" + + +def _schedule13d_from_xml(xml_content, form='SCHEDULE 13D/A', filing_date=date(2024, 12, 31)): + filing = Mock() + filing.form = form + filing.filing_date = filing_date + filing.xml = Mock(return_value=xml_content) + return Schedule13D.from_filing(filing) + + +def _schedule13g_from_xml(xml_content, form='SCHEDULE 13G/A', filing_date=date(2025, 12, 31)): + filing = Mock() + filing.form = form + filing.filing_date = filing_date + filing.xml = Mock(return_value=xml_content) + return Schedule13G.from_filing(filing) + + +def _header_only_schedule13d(): + """A pre-2025 filing with no XML: identities from the SGML header, no numbers.""" + filer = Mock() + filer.company_information.name = 'BML Investment Partners, L.P.' + filer.company_information.cik = '0001373604' + subject = Mock() + subject.company_information.name = 'Aadi Bioscience, Inc.' + subject.company_information.cik = '0001422142' + filing = Mock() + filing.form = 'SC 13D' + filing.filing_date = date(2021, 3, 1) + filing.xml = Mock(return_value=None) + filing.header = Mock(filers=[filer], subject_companies=[subject]) + return Schedule13D.from_filing(filing) + + +def _assert_change_unknown(comparison, placeholder_shares_change): + """No purchase, sale or "unchanged" is read into a comparison with unreported figures.""" + assert comparison.is_accumulating is False + assert comparison.is_liquidating is False + assert comparison.is_unchanged is False + assert comparison.reported_shares_change is None + assert comparison.reported_percent_change is None + # 5.x keeps the numeric properties, counting an unreported figure as 0, and says so. + with pytest.warns(FutureWarning, match=r'shares_change .* None in edgartools 6\.0'): + assert comparison.shares_change == placeholder_shares_change + with pytest.warns(FutureWarning, match=r'percent_change .* None in edgartools 6\.0'): + assert isinstance(comparison.percent_change, float) + + +@pytest.mark.fast +def test_ownership_comparison_against_header_only_filing_is_unknown(): + """A header-only filing has no share counts, so there is no change to report. + + Its reporting persons carry placeholder zeros. Adding those up reported the + structured filing's whole position as bought (or sold, the other way round). + """ + header_only = _header_only_schedule13d() + structured = _schedule13d_from_xml(SCHEDULE_13D_XML_PATH.read_text()) + assert header_only.has_structured_data is False + + _assert_change_unknown(OwnershipComparison(current=structured, previous=header_only), 4_535_000) + _assert_change_unknown(OwnershipComparison(current=header_only, previous=structured), -4_535_000) + + person = header_only.reporting_persons[0] + assert person.unreported_fields == OWNERSHIP_FIGURES + assert person.reported('aggregate_amount') is None + + with pytest.warns(FutureWarning): + summary = OwnershipComparison(current=structured, previous=header_only).get_summary() + assert summary['reported_shares_change'] is None + assert summary['reported_percent_change'] is None + assert summary['is_accumulating'] is False + + +@pytest.mark.fast +def test_ownership_comparison_without_reporting_person_rows_is_unknown(): + """Structured XML with an empty has no share counts either.""" + xml_content = SCHEDULE_13D_XML_PATH.read_text() + no_rows_xml = re.sub(r'.*?', + '', xml_content, flags=re.S) + no_rows = _schedule13d_from_xml(no_rows_xml) + structured = _schedule13d_from_xml(xml_content) + assert no_rows.has_structured_data is True + assert no_rows.reporting_persons == [] + + _assert_change_unknown(OwnershipComparison(current=no_rows, previous=structured), -4_535_000) + _assert_change_unknown(OwnershipComparison(current=structured, previous=no_rows), 4_535_000) + + +@pytest.mark.fast +def test_unreported_figures_keep_their_5x_types(): + """Control: code written against 5.x keeps getting numbers until 6.0. + + Formatting a figure of a header-only filing, or a change against it, printed + 0 and a number before this fix and must not raise TypeError now. Only the new + accessors answer None. + """ + header_only = _header_only_schedule13d() + structured = _schedule13d_from_xml(SCHEDULE_13D_XML_PATH.read_text()) + person = header_only.reporting_persons[0] + assert format(person.aggregate_amount, ',') == '0' + assert f"{person.percent_of_class:.2f}%" == '0.00%' + assert person.total_voting_power == 0 + assert person.total_dispositive_power == 0 + + with warnings.catch_warnings(): + warnings.simplefilter('ignore', FutureWarning) + comparison = OwnershipComparison(current=structured, previous=header_only) + assert f"{comparison.shares_change:+,}" == '+4,535,000' + assert f"{comparison.percent_change:+.1f}" == '+18.4' + summary = comparison.get_summary() + assert summary['previous_shares'] == 0 + assert summary['current_shares'] == 4_535_000 + + +@pytest.mark.fast +def test_ownership_comparison_reported_zero_is_still_a_number(): + """Control: a row that reports 0 shares is data: selling out is a change, and 0 to 0 is unchanged.""" + xml_content = SCHEDULE_13D_XML_PATH.read_text() + zero_xml = re.sub(r'\d+', + '0', xml_content) + zero_xml = re.sub(r'[\d.]+', + '0', zero_xml) + sold_out = _schedule13d_from_xml(zero_xml) + structured = _schedule13d_from_xml(xml_content) + assert [p.aggregate_amount for p in sold_out.reporting_persons] == [0, 0] + + with warnings.catch_warnings(): + warnings.simplefilter('error', FutureWarning) + comparison = OwnershipComparison(current=sold_out, previous=structured) + summary = comparison.get_summary() + assert summary['current_shares'] == 0 + assert summary['current_percent'] == 0 + assert comparison.shares_change == -summary['previous_shares'] < 0 + assert comparison.percent_change < 0 + assert comparison.is_liquidating is True + + unchanged = OwnershipComparison(current=sold_out, previous=_schedule13d_from_xml(zero_xml)) + assert unchanged.shares_change == 0 + assert unchanged.percent_change == 0 + assert unchanged.is_unchanged is True + + +@pytest.mark.fast +def test_ownership_comparison_single_person_change(): + """Control: one reporting person selling 500,000 shares.""" + xml_content = SCHEDULE_13D_XML_PATH.read_text() + one_person = re.sub(r'\s*0001373603.*?', + '', xml_content, flags=re.S) + after_sale = one_person.replace('2100000', + '1600000') + after_sale = after_sale.replace('8.5', + '6.5') + previous = _schedule13d_from_xml(one_person) + current = _schedule13d_from_xml(after_sale) + assert len(previous.reporting_persons) == 1 + + with warnings.catch_warnings(): + warnings.simplefilter('error', FutureWarning) + comparison = OwnershipComparison(current=current, previous=previous) + assert comparison.shares_change == -500_000 + assert comparison.percent_change == pytest.approx(-2.0) + assert comparison.is_liquidating is True + assert comparison.is_accumulating is False + + +# On an amendment the schema makes every ownership figure on a reporting person's +# cover page optional (docs/sec/schedule13/schema-reference.md: "m" on 13D/13G, +# "o" on 13D/A and 13G/A). The reporting-person row itself stays mandatory. +_13D_FIGURES = ('soleVotingPower', 'sharedVotingPower', 'soleDispositivePower', + 'sharedDispositivePower', 'aggregateAmountOwned', 'percentOfClass') +_13G_FIGURES = ('soleVotingPower', 'sharedVotingPower', 'soleDispositivePower', + 'sharedDispositivePower', 'reportingPersonBeneficiallyOwnedAggregateNumberOfShares', + 'classPercent') + + +def _amendment_without_figures(xml_content, form, figures): + """Turn an original filing's XML into an amendment that reports no ownership figures. + + Adds the elements an amendment requires (previousAccessionNumber, amendmentNo) + and drops the reporting-person figures an amendment may omit. The 13G Item 4 + classPercent is not a reporting-person figure and is left alone. + """ + xml_content = xml_content.replace(f'{form}', + f'{form}/A' + '0000000000-24-000001', 1) + xml_content = xml_content.replace('', '1', 1) + persons_tag = 'reportingPersons' if form == 'SCHEDULE 13D' else 'coverPageHeaderReportingPersonDetails' + persons = re.compile(rf'<{persons_tag}>.*?', re.S) + figure = re.compile(r'<(%s)>[^<]*' % '|'.join(figures)) + + def drop_figures(match): + return figure.sub('', match.group(0)) + + amended = persons.sub(drop_figures, xml_content) + # Both reporting persons remain; the source reports every figure for both, the amendment none. + def tags_in_persons(xml, tag): + return sum(block.count(f'<{tag}>') for block in persons.findall(xml)) + + assert tags_in_persons(xml_content, 'reportingPersonName') == tags_in_persons(amended, 'reportingPersonName') == 2 + for tag in figures: + assert tags_in_persons(xml_content, tag) == 2 + assert tags_in_persons(amended, tag) == 0 + return amended + + +@pytest.mark.fast +@pytest.mark.parametrize('schedule_class,xml_path,form,figures', [ + (Schedule13D, SCHEDULE_13D_XML_PATH, 'SCHEDULE 13D', _13D_FIGURES), + (Schedule13G, SCHEDULE_13G_XML_PATH, 'SCHEDULE 13G', _13G_FIGURES), +]) +def test_amendment_omitting_ownership_figures_marks_them_unreported(schedule_class, xml_path, form, figures): + """A figure the amendment leaves out was not reported: reported() is None, not 0.""" + amended_xml = _amendment_without_figures(xml_path.read_text(), form, figures) + filing = Mock(form=f'{form}/A', filing_date=date(2025, 12, 31), xml=Mock(return_value=amended_xml)) + amendment = schedule_class.from_filing(filing) + + assert amendment.has_structured_data is True + assert amendment.is_amendment is True + assert len(amendment.reporting_persons) == 2 + for person in amendment.reporting_persons: + assert person.name + assert person.unreported_fields == OWNERSHIP_FIGURES + for field_name in OWNERSHIP_FIGURES: + assert person.reported(field_name) is None + assert getattr(person, field_name) == 0 # the 5.x placeholder, None in 6.0 + + # The display paths show the gap instead of a zero. + context = amendment.to_context(detail='full') + assert 'percent not reported (shares not reported)' in context + assert '(0 shares)' not in context and 'Sole Voting: 0' not in context + from rich.console import Console + console = Console(record=True, width=200) + console.print(amendment) + rendered = console.export_text() + assert 'unavailable' in rendered + assert re.search(r'Total Shares:\s+unavailable', rendered) + + +@pytest.mark.fast +def test_reported_figures_are_not_marked(): + """Control: the original filings report every figure, including a reported 0.""" + schedule_13d = _schedule13d_from_xml(SCHEDULE_13D_XML_PATH.read_text(), form='SCHEDULE 13D') + schedule_13g = _schedule13g_from_xml(SCHEDULE_13G_XML_PATH.read_text(), form='SCHEDULE 13G') + for person in schedule_13d.reporting_persons + schedule_13g.reporting_persons: + assert person.unreported_fields == frozenset() + bml = schedule_13d.reporting_persons[0] + assert bml.reported('sole_voting_power') == 0 + assert bml.reported('aggregate_amount') == 2_100_000 + assert bml.reported('percent_of_class') == 8.5 + with pytest.raises(ValidationError): + bml.reported('cik') + + +@pytest.mark.fast +def test_one_person_omitting_shares_leaves_percent_known(): + """Share and percent availability are independent, person by person.""" + xml_content = SCHEDULE_13D_XML_PATH.read_text() + original = _schedule13d_from_xml(xml_content, form='SCHEDULE 13D') + partial_xml = xml_content.replace('2100000', '', 1) + partial = _schedule13d_from_xml(partial_xml, filing_date=date(2025, 3, 31)) + bml, leonard = partial.reporting_persons + assert bml.unreported_fields == {'aggregate_amount'} + assert bml.reported('aggregate_amount') is None + assert leonard.reported('aggregate_amount') == 2_435_000 + + comparison = OwnershipComparison(current=partial, previous=original) + assert comparison.reported_shares_change is None + assert comparison.reported_percent_change == 0.0 + assert comparison.is_liquidating is False + assert 'Ownership: 9.9% (shares not reported)' in partial.to_context() + + +@pytest.mark.fast +def test_ownership_comparison_13d_amendment_omitting_figures_is_unknown(): + """A 13D/A that leaves out the share figures is not a sale of the whole position. + + Summing the parsed rows read the omitted figures as 0, so against the Aadi + Bioscience 13D (4,535,000 shares over two rows) the amendment came out as a + 4,535,000-share sale, -18.4 points, and is_liquidating True. + """ + original_xml = SCHEDULE_13D_XML_PATH.read_text() + original = _schedule13d_from_xml(original_xml, form='SCHEDULE 13D', filing_date=date(2024, 12, 31)) + amendment = _schedule13d_from_xml(_amendment_without_figures(original_xml, 'SCHEDULE 13D', _13D_FIGURES), + filing_date=date(2025, 3, 31)) + assert amendment.has_structured_data is True + assert len(amendment.reporting_persons) == 2 + + _assert_change_unknown(OwnershipComparison(current=amendment, previous=original), -4_535_000) + _assert_change_unknown(OwnershipComparison(current=original, previous=amendment), 4_535_000) + + with pytest.warns(FutureWarning): + summary = OwnershipComparison(current=amendment, previous=original).get_summary() + assert summary['is_liquidating'] is False + assert summary['reported_shares_change'] is None + assert summary['reported_percent_change'] is None + + +@pytest.mark.fast +def test_ownership_comparison_13g_amendment_omitting_figures_is_unknown(): + """The same for a 13G/A against the Jushi Holdings 13G (two 10,000,000-share rows).""" + original_xml = SCHEDULE_13G_XML_PATH.read_text() + original = _schedule13g_from_xml(original_xml, form='SCHEDULE 13G', filing_date=date(2025, 11, 26)) + amendment = _schedule13g_from_xml(_amendment_without_figures(original_xml, 'SCHEDULE 13G', _13G_FIGURES)) + assert amendment.has_structured_data is True + assert len(amendment.reporting_persons) == 2 + + _assert_change_unknown(OwnershipComparison(current=amendment, previous=original), -20_000_000) + _assert_change_unknown(OwnershipComparison(current=original, previous=amendment), 20_000_000) + + +@pytest.mark.fast +def test_ownership_comparison_13g_amendment_reporting_figures(): + """Control: a 13G/A that does report its figures still compares as numbers.""" + original_xml = SCHEDULE_13G_XML_PATH.read_text() + original = _schedule13g_from_xml(original_xml, form='SCHEDULE 13G', filing_date=date(2025, 11, 26)) + amended_xml = original_xml.replace( + '10000000.00', + '8000000.00') + amended_xml = amended_xml.replace('5.1', '4.1') + amendment = _schedule13g_from_xml(amended_xml) + assert [p.aggregate_amount for p in amendment.reporting_persons] == [8_000_000, 8_000_000] + + with warnings.catch_warnings(): + warnings.simplefilter('error', FutureWarning) + comparison = OwnershipComparison(current=amendment, previous=original) + assert comparison.shares_change == -4_000_000 + assert comparison.percent_change == pytest.approx(-2.0) + assert comparison.is_liquidating is True + assert amendment.total_shares == 8_000_000 From 980cb1f8d62f7ea21332164ed153226f856b0cba Mon Sep 17 00:00:00 2001 From: monody0007 <52037177+monody0007@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:40:42 -0700 Subject: [PATCH 4/4] test(schedule13): pin the persons-table cells for unreported figures The regression tests checked only the "Total Shares: unavailable" line of the rich display. Putting the per-person Shares, Percent, Voting Power and Dispositive Power cells back to the raw fields (placeholder 0) left every test passing. The new test drops one person's shares and shared voting power from the 13D and 13G test data and asserts each cell of both rows: the two figures left out read "unavailable", and the reported ones stay numbers. _ownership_figures() calls safe_int() and safe_float() with default=None to tell a missing figure from a reported 0, but both were annotated to take and return only int/float. They now have overloads: the default call still returns int/float, and default=None returns Optional. No runtime change. --- edgar/beneficial_ownership/schedule13.py | 22 +++++++++-- ...ssue_1379_schedule13_unreported_figures.py | 38 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/edgar/beneficial_ownership/schedule13.py b/edgar/beneficial_ownership/schedule13.py index a88508e59..111e12985 100644 --- a/edgar/beneficial_ownership/schedule13.py +++ b/edgar/beneficial_ownership/schedule13.py @@ -6,7 +6,7 @@ """ import re from datetime import date -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING, List, Optional, overload if TYPE_CHECKING: from edgar._filings import Filing @@ -29,7 +29,15 @@ __all__ = ['Schedule13D', 'Schedule13G'] -def safe_int(value: Optional[str], default: int = 0) -> int: +@overload +def safe_int(value: Optional[str], default: int = 0) -> int: ... + + +@overload +def safe_int(value: Optional[str], default: None) -> Optional[int]: ... + + +def safe_int(value: Optional[str], default: Optional[int] = 0) -> Optional[int]: """ Safely convert a string value to an integer. @@ -52,7 +60,15 @@ def safe_int(value: Optional[str], default: int = 0) -> int: return default -def safe_float(value: Optional[str], default: float = 0.0) -> float: +@overload +def safe_float(value: Optional[str], default: float = 0.0) -> float: ... + + +@overload +def safe_float(value: Optional[str], default: None) -> Optional[float]: ... + + +def safe_float(value: Optional[str], default: Optional[float] = 0.0) -> Optional[float]: """ Safely convert a string value to a float. diff --git a/tests/issues/regression/test_issue_1379_schedule13_unreported_figures.py b/tests/issues/regression/test_issue_1379_schedule13_unreported_figures.py index e2763e354..a399a169d 100644 --- a/tests/issues/regression/test_issue_1379_schedule13_unreported_figures.py +++ b/tests/issues/regression/test_issue_1379_schedule13_unreported_figures.py @@ -286,6 +286,44 @@ def test_amendment_omitting_ownership_figures_marks_them_unreported(schedule_cla assert re.search(r'Total Shares:\s+unavailable', rendered) +@pytest.mark.fast +@pytest.mark.parametrize('schedule_class,xml_path,form,shares_tag,expected_rows', [ + (Schedule13D, SCHEDULE_13D_XML_PATH, 'SCHEDULE 13D/A', 'aggregateAmountOwned', { + 'BML Investment Partners, L.P.': ['unavailable', '8.50%', 'unavailable', '2,100,000'], + 'Leonard Braden Michael': ['2,435,000', '9.90%', '2,435,000', '2,435,000'], + }), + (Schedule13G, SCHEDULE_13G_XML_PATH, 'SCHEDULE 13G/A', 'reportingPersonBeneficiallyOwnedAggregateNumberOfShares', { + 'Marex Securities Products Inc.': ['unavailable', '5.10%', 'unavailable', '10,000,000'], + 'Marex Group plc': ['10,000,000', '5.10%', '10,000,000', '10,000,000'], + }), +]) +def test_rich_persons_table_shows_unreported_figures_as_unavailable(schedule_class, xml_path, form, + shares_tag, expected_rows): + """Each cell of the persons table stands for one person's figure, reported or not. + + The first person leaves out its shares and its shared voting power. Its Shares + and Voting Power cells say "unavailable" rather than the placeholder 0 (or the + sole voting power alone), while its reported percent and dispositive power, and + every figure of the second person, still print as numbers. + """ + xml_content = xml_path.read_text() + for tag in (shares_tag, 'sharedVotingPower'): + xml_content = re.sub(rf'<{tag}>[^<]*', '', xml_content, count=1) + filing = Mock(form=form, filing_date=date(2025, 12, 31), xml=Mock(return_value=xml_content)) + schedule = schedule_class.from_filing(filing) + first = schedule.reporting_persons[0] + assert first.unreported_fields == {'aggregate_amount', 'shared_voting_power'} + + from rich.console import Console + console = Console(record=True, width=200) + console.print(schedule) + lines = console.export_text().splitlines() + for name, cells in expected_rows.items(): + row = next(line for line in lines if re.match(rf'│\s+{re.escape(name)}\s', line)) + # Shares, Percent, Voting Power and Dispositive Power are the last four cells. + assert row.strip('│ ').split()[-4:] == cells + + @pytest.mark.fast def test_reported_figures_are_not_marked(): """Control: the original filings report every figure, including a reported 0."""