From 43277bc5e2c2aa77d513f81006b158fcee616d4a Mon Sep 17 00:00:00 2001 From: Dwight Gunning Date: Thu, 1 Oct 2026 19:00:00 -0400 Subject: [PATCH] fix(schedule13): OwnershipComparison uses total_shares' max; add aggregation_basis (qsk4) OwnershipComparison summed aggregate_amount / percent_of_class over every reporting person, including rows flagged is_aggregate_exclude_shares, while total_shares / total_percent and _reported_total take the max over the unflagged persons: one rule, two implementations, disagreeing twice. In a control chain (fund, GP, manager, individual each reporting the same position) the sum counts one position once per person; the Jushi 13G fixture's two 10,000,000-share rows compared as 20,000,000. The comparison now goes through models._reported_total (reported_*) and an unreported-as-0 max (shares_change, percent_change, get_summary). The reported_* accessors are unreleased (#1379, after 5.59.1); shares_change's numbers change in 5.x as a correctness fix. The "max() is always correct" docstrings were false. Sampled joint filings: most rows restate one position, but GAMCO/Nevro (0000807249-25-000053) sub-advisers hold separate accounts, 1,322,950 by max vs 2,123,900 stated in Item 5(a), and the 33-person ZIM group (0001178913-25-004165) matches neither sum nor max. No structured field says which rows roll up into which, so a new aggregation_basis reports the shape: 'single', 'identical', 'parent_sums_children' (largest row = sum of the distinct smaller ones; Blackstone 0000950170-25-090888 4,782,781 = 2,989,238 + 1,793,543) or 'ambiguous'. On 60 joint filings from 2025Q3: 13D 15 identical / 13 ambiguous / 2 single; 13G 23 identical / 3 parent / 4 ambiguous. to_context() flags ambiguous totals. The guide's "Joint vs. Separate Filers" section described member_of_group / >100% / identical- count detection that was never implemented; rewritten. The Item 5(a) stated total is bead edgartools-vazz. 1379's tests pinned the summed values; updated to the max (Aadi 4,535,000 -> 2,435,000, Jushi 20,000,000 -> 10,000,000). Co-Authored-By: Claude Opus 5.5 --- changelog.d/qsk4.fixed.md | 1 + docs/guides/schedule13dg-data-object-guide.md | 22 +++-- edgar/ai/skills/ownership/skill.yaml | 14 +++ edgar/beneficial_ownership/amendments.py | 52 ++++++----- edgar/beneficial_ownership/models.py | 46 +++++++++- edgar/beneficial_ownership/schedule13.py | 79 ++++++++++++++--- ...ssue_1379_schedule13_unreported_figures.py | 35 ++++---- .../test_qsk4_schedule13_aggregation_basis.py | 87 +++++++++++++++++++ 8 files changed, 277 insertions(+), 59 deletions(-) create mode 100644 changelog.d/qsk4.fixed.md create mode 100644 tests/issues/regression/test_qsk4_schedule13_aggregation_basis.py diff --git a/changelog.d/qsk4.fixed.md b/changelog.d/qsk4.fixed.md new file mode 100644 index 000000000..1f3b44d6a --- /dev/null +++ b/changelog.d/qsk4.fixed.md @@ -0,0 +1 @@ +**Schedule 13 `OwnershipComparison` summed joint filers, while `total_shares` took the largest one.** A fund and its adviser reporting one 10,000,000-share position compared as 20,000,000. The comparison now uses the same max, and the new `aggregation_basis` (`single`, `identical`, `parent_sums_children`, `ambiguous`) says when it may fall short: GAMCO's Nevro 13D is 1,322,950 by max and 2,123,900 by its own Item 5(a). (bead edgartools-qsk4) diff --git a/docs/guides/schedule13dg-data-object-guide.md b/docs/guides/schedule13dg-data-object-guide.md index 8384b9826..d052cdc6c 100644 --- a/docs/guides/schedule13dg-data-object-guide.md +++ b/docs/guides/schedule13dg-data-object-guide.md @@ -288,13 +288,23 @@ The class of securities subject to the filing. ## Joint vs. Separate Filers -When multiple reporting persons appear on a filing, edgartools automatically determines whether they are filing **jointly** (reporting the same shares) or **separately** (each holding distinct shares): +When several reporting persons file together, each reports its own beneficial ownership on its own cover page. Usually they restate one position: a fund, its general partner, its manager and the individual behind them all report the same shares. Sometimes they hold separate positions, as sub-advisers managing different accounts do. Nothing in the filing's structured data says which, so `total_shares` and `total_percent` take the largest figure any counted person reports (persons flagged `is_aggregate_exclude_shares` are left out), and `aggregation_basis` says what the rows show: -- **Joint filers** (`member_of_group = "a"`): `total_shares` returns the shared amount, not a sum -- **Hierarchical ownership** (parent-subsidiary chains): detected when percentages exceed 100%, takes the top-level amount -- **Undeclared joint filers**: when all persons report identical share counts, treated as joint +| `aggregation_basis` | What the rows show | Is `total_shares` the group's holding? | +|---------------------|--------------------|----------------------------------------| +| `'single'` | One reporting person | Yes | +| `'identical'` | Every person reports the same shares | Yes | +| `'parent_sums_children'` | The largest row is the sum of the others | Yes | +| `'ambiguous'` | Anything else | Not necessarily: it can be lower | -This means `total_shares` and `total_percent` always give you the correct aggregate, regardless of filing structure. +```python +schedule = filing.obj() +if schedule.aggregation_basis == 'ambiguous': + for person in schedule.reporting_persons: + print(person.name, person.aggregate_amount) +``` + +GAMCO's 13D on Nevro (accession 0000807249-25-000053) is `'ambiguous'`. Its three sub-advisers report 1,322,950, 88,500 and 712,450 shares in separate accounts, so `total_shares` is 1,322,950, while Item 5(a) of the filing states the group's 2,123,900 shares (5.54%). Read Item 5(a) when an ambiguous total matters. --- @@ -323,6 +333,8 @@ print(f"Liquidating: {comparison.is_liquidating}") `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`. +Each filing's figure is its `total_shares` or `total_percent`, so a change between two `'ambiguous'` filings is a change in the largest reporting person's holding. + `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/edgar/ai/skills/ownership/skill.yaml b/edgar/ai/skills/ownership/skill.yaml index 8b196066f..97eda3200 100644 --- a/edgar/ai/skills/ownership/skill.yaml +++ b/edgar/ai/skills/ownership/skill.yaml @@ -58,6 +58,20 @@ patterns: # Form 5 - Annual summary (small/late transactions) annual = company.get_filings(form="5")[0].obj().get_ownership_summary() + - name: Schedule 13D/13G Stakes + when: 5%+ beneficial ownership, activist or passive stakes, joint filers + code: | + stake = filing.obj() # SCHEDULE 13D / 13G (XML, 2025+) + stake.total_shares, stake.total_percent # largest counted reporting person + stake.aggregation_basis # single | identical | parent_sums_children | ambiguous + [(p.name, p.aggregate_amount) for p in stake.reporting_persons] + note: | + Joint filers usually restate one position (fund, GP, manager), so + total_shares is the max, never a sum. When aggregation_basis is + 'ambiguous' the persons may hold separate positions and the group total + can be higher (GAMCO/Nevro: max 1,322,950, Item 5(a) 2,123,900); read the + rows or Item 5(a). + avoid: - pattern: "form4.common_stock_sales.iterrows()" fix: "form4.get_ownership_summary().net_change" diff --git a/edgar/beneficial_ownership/amendments.py b/edgar/beneficial_ownership/amendments.py index a261addb2..ae4cf65bc 100644 --- a/edgar/beneficial_ownership/amendments.py +++ b/edgar/beneficial_ownership/amendments.py @@ -8,6 +8,8 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Optional +from edgar.beneficial_ownership.models import _reported_total as _total_over_persons + if TYPE_CHECKING: from edgar.beneficial_ownership.schedule13 import Schedule13D, Schedule13G @@ -59,24 +61,26 @@ def from_filing(cls, filing): ) -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. +def _reported_total(schedule: 'Schedule13D | Schedule13G', figure: str): + """A filing's total for one figure, as ``total_shares`` takes it, or None when it is not reported. + + The max over the persons not flagged ``is_aggregate_exclude_shares``. Summing the + persons counted a control chain's one position once per person: a fund, its + general partner and its manager each report the same shares (edgartools-qsk4). - A figure is unreported when a reporting person leaves it out (an amendment may, + A figure is unreported when a counted 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 - values = [p.reported(figure) for p in schedule.reporting_persons] - if None in values: + if not schedule.has_structured_data: return None - return sum(values) + return _total_over_persons(schedule.reporting_persons, figure) -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 _placeholder_total(schedule: 'Schedule13D | Schedule13G', figure: str): + """``_reported_total`` reading an unreported figure as 0 (the 5.x values), as ``total_shares`` does.""" + counted = [getattr(p, figure) for p in schedule.reporting_persons if not p.is_aggregate_exclude_shares] + return max(counted, default=0) def _warn_unreported(name: str, replacement: str) -> None: @@ -110,8 +114,10 @@ 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 + A filing's figure is the largest any counted reporting person reports, the + same as ``total_shares``; check each filing's ``aggregation_basis`` before + reading it as the group's holding. A change is known only when both filings + report the figure for every counted 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. @@ -138,8 +144,8 @@ def reported_shares_change(self) -> Optional[int]: 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_sum(self.current, 'aggregate_amount') - prev_shares = _reported_sum(self.previous, 'aggregate_amount') + curr_shares = _reported_total(self.current, 'aggregate_amount') + prev_shares = _reported_total(self.previous, 'aggregate_amount') if curr_shares is None or prev_shares is None: return None return curr_shares - prev_shares @@ -153,8 +159,8 @@ def reported_percent_change(self) -> Optional[float]: Net change in ownership percentage (e.g., 1.5 means increased by 1.5%), or None as for ``reported_shares_change`` """ - curr_pct = _reported_sum(self.current, 'percent_of_class') - prev_pct = _reported_sum(self.previous, 'percent_of_class') + curr_pct = _reported_total(self.current, 'percent_of_class') + prev_pct = _reported_total(self.previous, 'percent_of_class') if curr_pct is None or prev_pct is None: return None return curr_pct - prev_pct @@ -172,7 +178,7 @@ def shares_change(self) -> int: """ 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') + return _placeholder_total(self.current, 'aggregate_amount') - _placeholder_total(self.previous, 'aggregate_amount') @property def percent_change(self) -> float: @@ -187,7 +193,7 @@ def percent_change(self) -> float: """ 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') + return _placeholder_total(self.current, 'percent_of_class') - _placeholder_total(self.previous, 'percent_of_class') @property def is_accumulating(self) -> bool: @@ -220,10 +226,10 @@ def get_summary(self) -> dict: reported_percent_change = self.reported_percent_change if reported_shares_change is None or reported_percent_change is None: _warn_unreported_summary() - previous_shares = _placeholder_sum(self.previous, 'aggregate_amount') - current_shares = _placeholder_sum(self.current, 'aggregate_amount') - previous_percent = _placeholder_sum(self.previous, 'percent_of_class') - current_percent = _placeholder_sum(self.current, 'percent_of_class') + previous_shares = _placeholder_total(self.previous, 'aggregate_amount') + current_shares = _placeholder_total(self.current, 'aggregate_amount') + previous_percent = _placeholder_total(self.previous, 'percent_of_class') + current_percent = _placeholder_total(self.current, 'percent_of_class') return { 'previous_filing_date': self.previous.filing_date, 'current_filing_date': self.current.filing_date, diff --git a/edgar/beneficial_ownership/models.py b/edgar/beneficial_ownership/models.py index ba38059ba..7c48a12d2 100644 --- a/edgar/beneficial_ownership/models.py +++ b/edgar/beneficial_ownership/models.py @@ -36,9 +36,9 @@ 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. - A filing with no reporting-person rows reports no figure either, matching - ``OwnershipComparison.reported_shares_change``. + ``total_shares`` and ``total_percent`` take, which read an unreported figure as 0; + ``_aggregation_basis`` says whether that max is the group's total. A filing with + no reporting-person rows reports no figure either. """ if not persons: return None @@ -48,6 +48,46 @@ def _reported_total(persons, figure: str) -> Optional[Union[int, float]]: return max(values, default=0) +AGGREGATION_BASES = ('single', 'identical', 'parent_sums_children', 'ambiguous') + + +def _aggregation_basis(persons) -> Optional[str]: + """Whether the largest reporting person's share count is the filing's group total. + + The cover pages give each person's beneficial ownership and nothing says + which rows roll up into which, so max() is right only when the rows show it: + + * ``'single'`` — one person counts. + * ``'identical'`` — every person reports the same shares: a control chain + (fund, general partner, manager, individual) restating one position. + * ``'parent_sums_children'`` — the largest row equals the sum of the distinct + smaller ones, a parent consolidating its funds (Blackstone: 4,782,781 = + 2,989,238 + 1,793,543). + * ``'ambiguous'`` — anything else. Persons may hold separate positions, and + the group total can be well above max(): GAMCO's sub-advisers hold 1,322,950, + 88,500 and 712,450 in separate accounts, 2,123,900 together per Item 5(a). + + Persons flagged ``is_aggregate_exclude_shares`` and rows of 0 do not count. + None when there is no reporting-person row or a counted person leaves the + share count out. + """ + if not persons: + return None + values = [p.reported('aggregate_amount') for p in persons if not p.is_aggregate_exclude_shares] + if None in values: + return None + positive = [v for v in values if v > 0] + if len(values) == 1: + return 'single' + distinct = set(positive) + if len(distinct) <= 1: + return 'identical' + largest = max(distinct) + if sum(distinct - {largest}) == largest: + return 'parent_sums_children' + return 'ambiguous' + + @dataclass(frozen=True) class ReportingPerson: """ diff --git a/edgar/beneficial_ownership/schedule13.py b/edgar/beneficial_ownership/schedule13.py index 111e12985..c85530091 100644 --- a/edgar/beneficial_ownership/schedule13.py +++ b/edgar/beneficial_ownership/schedule13.py @@ -20,6 +20,7 @@ Schedule13GItems, SecurityInfo, Signature, + _aggregation_basis, _reported_total, ) from edgar.core import get_bool @@ -547,9 +548,11 @@ def total_shares(self) -> Optional[int]: """ Total beneficial ownership across all reporting persons. - Within a single 13D filing, reporting persons always report overlapping - beneficial ownership (group formations or control chains). Independent - filers file separate forms. The correct aggregate is always max(). + The largest share count any counted reporting person reports. Usually the + persons restate one position (a fund, its general partner and its manager), + and then this is the group's holding. When they hold separate positions it + is less than the group's: check ``aggregation_basis`` before treating it as + the group total. Excludes shares flagged with is_aggregate_exclude_shares == True. @@ -575,9 +578,11 @@ def total_percent(self) -> Optional[float]: """ Total ownership percentage across all reporting persons. - Within a single 13D filing, reporting persons always report overlapping - beneficial ownership (group formations or control chains). Independent - filers file separate forms. The correct aggregate is always max(). + The largest share count any counted reporting person reports. Usually the + persons restate one position (a fund, its general partner and its manager), + and then this is the group's holding. When they hold separate positions it + is less than the group's: check ``aggregation_basis`` before treating it as + the group total. Excludes shares flagged with is_aggregate_exclude_shares == True. @@ -598,6 +603,25 @@ def total_percent(self) -> Optional[float]: return max(p.percent_of_class for p in included_persons) + @property + def aggregation_basis(self) -> Optional[str]: + """ + Whether ``total_shares`` is the group's total, from how the persons' rows relate. + + * ``'single'`` — one reporting person counts. + * ``'identical'`` — every person reports the same shares (a control chain). + * ``'parent_sums_children'`` — the largest row is the sum of the others. + * ``'ambiguous'`` — the persons may hold separate positions, and the group's + total can exceed ``total_shares``: GAMCO's 13D on Nevro (0000807249-25-000053) + states 2,123,900 shares in Item 5(a), against a largest row of 1,322,950. + + None when ``has_structured_data`` is False, there are no reporting persons, + or a counted person leaves its share count out. + """ + if not self.has_structured_data: + return None + return _aggregation_basis(self.reporting_persons) + def to_context(self, detail: str = 'standard') -> str: """ AI-optimized context string. @@ -622,6 +646,9 @@ def to_context(self, detail: str = 'standard') -> str: 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)})") + if self.aggregation_basis == 'ambiguous': + lines.append(" (largest single reporting person; persons may hold separate positions," + " see aggregation_basis)") else: lines.append("Ownership: unavailable (pre-2025 HTML filing)") @@ -681,6 +708,7 @@ def to_context(self, detail: str = 'standard') -> str: lines.append(" .items Narrative items 1-7") lines.append(" .total_shares Aggregate beneficial ownership") lines.append(" .total_percent Ownership percentage") + lines.append(" .aggregation_basis Whether total_shares is the group total") lines.append(" .signatures Filing signatures") if detail == 'standard': @@ -1054,9 +1082,11 @@ def total_shares(self) -> Optional[int]: """ Total beneficial ownership across all reporting persons. - Within a single 13G filing, reporting persons always report overlapping - beneficial ownership (group formations or control chains). Independent - filers file separate forms. The correct aggregate is always max(). + The largest share count any counted reporting person reports. Usually the + persons restate one position (a fund, its general partner and its manager), + and then this is the group's holding. When they hold separate positions it + is less than the group's: check ``aggregation_basis`` before treating it as + the group total. Excludes shares flagged with is_aggregate_exclude_shares == True. @@ -1082,9 +1112,11 @@ def total_percent(self) -> Optional[float]: """ Total ownership percentage across all reporting persons. - Within a single 13G filing, reporting persons always report overlapping - beneficial ownership (group formations or control chains). Independent - filers file separate forms. The correct aggregate is always max(). + The largest share count any counted reporting person reports. Usually the + persons restate one position (a fund, its general partner and its manager), + and then this is the group's holding. When they hold separate positions it + is less than the group's: check ``aggregation_basis`` before treating it as + the group total. Excludes shares flagged with is_aggregate_exclude_shares == True. @@ -1110,6 +1142,25 @@ def is_passive_investor(self) -> bool: """Check if this is a passive investor (13G are passive by definition)""" return True + @property + def aggregation_basis(self) -> Optional[str]: + """ + Whether ``total_shares`` is the group's total, from how the persons' rows relate. + + * ``'single'`` — one reporting person counts. + * ``'identical'`` — every person reports the same shares (a control chain). + * ``'parent_sums_children'`` — the largest row is the sum of the others. + * ``'ambiguous'`` — the persons may hold separate positions, and the group's + total can exceed ``total_shares``: GAMCO's 13D on Nevro (0000807249-25-000053) + states 2,123,900 shares in Item 5(a), against a largest row of 1,322,950. + + None when ``has_structured_data`` is False, there are no reporting persons, + or a counted person leaves its share count out. + """ + if not self.has_structured_data: + return None + return _aggregation_basis(self.reporting_persons) + def to_context(self, detail: str = 'standard') -> str: """ AI-optimized context string. @@ -1134,6 +1185,9 @@ def to_context(self, detail: str = 'standard') -> str: 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)})") + if self.aggregation_basis == 'ambiguous': + lines.append(" (largest single reporting person; persons may hold separate positions," + " see aggregation_basis)") else: lines.append("Ownership: unavailable (pre-2025 HTML filing)") @@ -1188,6 +1242,7 @@ def to_context(self, detail: str = 'standard') -> str: lines.append(" .items Structured items data") lines.append(" .total_shares Aggregate beneficial ownership") lines.append(" .total_percent Ownership percentage") + lines.append(" .aggregation_basis Whether total_shares is the group total") lines.append(" .is_passive_investor Always True for 13G") if detail == 'standard': 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 2e605089a..0df58d672 100644 --- a/tests/issues/regression/test_issue_1379_schedule13_unreported_figures.py +++ b/tests/issues/regression/test_issue_1379_schedule13_unreported_figures.py @@ -107,8 +107,8 @@ def test_ownership_comparison_against_header_only_filing_is_unknown(): 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) + _assert_change_unknown(OwnershipComparison(current=structured, previous=header_only), 2_435_000) + _assert_change_unknown(OwnershipComparison(current=header_only, previous=structured), -2_435_000) person = header_only.reporting_persons[0] assert person.unreported_fields == OWNERSHIP_FIGURES @@ -132,8 +132,8 @@ def test_ownership_comparison_without_reporting_person_rows_is_unknown(): 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) + _assert_change_unknown(OwnershipComparison(current=no_rows, previous=structured), -2_435_000) + _assert_change_unknown(OwnershipComparison(current=structured, previous=no_rows), 2_435_000) @pytest.mark.fast @@ -155,11 +155,11 @@ def test_unreported_figures_keep_their_5x_types(): 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' + assert f"{comparison.shares_change:+,}" == '+2,435,000' + assert f"{comparison.percent_change:+.1f}" == '+9.9' summary = comparison.get_summary() assert summary['previous_shares'] == 0 - assert summary['current_shares'] == 4_535_000 + assert summary['current_shares'] == 2_435_000 @pytest.mark.fast @@ -373,8 +373,8 @@ def test_ownership_comparison_13d_amendment_omitting_figures_is_unknown(): 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) + _assert_change_unknown(OwnershipComparison(current=amendment, previous=original), -2_435_000) + _assert_change_unknown(OwnershipComparison(current=original, previous=amendment), 2_435_000) with pytest.warns(FutureWarning): summary = OwnershipComparison(current=amendment, previous=original).get_summary() @@ -385,15 +385,16 @@ def test_ownership_comparison_13d_amendment_omitting_figures_is_unknown(): @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).""" + """The same for a 13G/A against the Jushi Holdings 13G (two 10,000,000-share rows, + one position restated, so the filing's figure is 10,000,000, not 20,000,000; qsk4).""" 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) + _assert_change_unknown(OwnershipComparison(current=amendment, previous=original), -10_000_000) + _assert_change_unknown(OwnershipComparison(current=original, previous=amendment), 10_000_000) @pytest.mark.fast @@ -411,10 +412,12 @@ def test_ownership_comparison_13g_amendment_reporting_figures(): 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) + # Both persons report the one position, 10,000,000 -> 8,000,000 (qsk4) + assert comparison.shares_change == -2_000_000 + assert comparison.percent_change == pytest.approx(-1.0) assert comparison.is_liquidating is True assert amendment.total_shares == 8_000_000 + assert amendment.aggregation_basis == 'identical' @pytest.mark.fast @@ -436,8 +439,8 @@ def test_get_summary_warns_once_at_the_callers_line(): assert future[0].filename == __file__ assert "'reported_shares_change'" in str(future[0].message) # The 5.x values are unchanged. - assert summary['shares_change'] == 4_535_000 - assert summary['current_shares'] == 4_535_000 + assert summary['shares_change'] == 2_435_000 + assert summary['current_shares'] == 2_435_000 assert summary['previous_shares'] == 0 assert summary['reported_shares_change'] is None diff --git a/tests/issues/regression/test_qsk4_schedule13_aggregation_basis.py b/tests/issues/regression/test_qsk4_schedule13_aggregation_basis.py new file mode 100644 index 000000000..ba0046b4a --- /dev/null +++ b/tests/issues/regression/test_qsk4_schedule13_aggregation_basis.py @@ -0,0 +1,87 @@ +"""A joint Schedule 13 filing's total: max, never a sum, and say when max may fall short (edgartools-qsk4). + +total_shares took the max over reporting persons while OwnershipComparison summed +them, so a control chain (fund, GP, manager restating one position) counted once +per person: the Jushi Holdings 13G's two 10,000,000-share rows compared as +20,000,000. The comparison now uses the same max. + +max() is not always the group's holding either. In a sample of ~40 joint filings +from 2025 most rows restated one position, but GAMCO's sub-advisers hold separate +accounts. aggregation_basis says which shape the rows have. +""" +from datetime import date +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from edgar.beneficial_ownership import Schedule13D, Schedule13G +from edgar.beneficial_ownership.models import _aggregation_basis + +TEST_DATA_DIR = Path(__file__).resolve().parents[2] / "data" / "beneficial_ownership" + + +def person(amount, excluded=False): + return SimpleNamespace(reported=lambda figure: amount, is_aggregate_exclude_shares=excluded) + + +@pytest.mark.fast +@pytest.mark.parametrize("rows, basis", [ + ([person(2_100_000)], 'single'), + # Jushi Holdings 13G: fund and adviser report the same position + ([person(10_000_000), person(10_000_000)], 'identical'), + ([person(10_000_000), person(10_000_000), person(0)], 'identical'), + # Blackstone 0000950170-25-090888: the holding company consolidates two funds, + # each also reported by its general partner + ([person(4_782_781), person(4_782_781), person(2_989_238), person(2_989_238), person(1_793_543)], + 'parent_sums_children'), + # GAMCO / Nevro 0000807249-25-000053: separate sub-adviser accounts; the parent's + # row is 0 and flagged as excluding shares + ([person(0, excluded=True), person(1_322_950), person(88_500), person(712_450)], 'ambiguous'), + ([], None), + ([person(None), person(5)], None), +]) +def test_aggregation_basis(rows, basis): + assert _aggregation_basis(rows) == basis + + +def _from_xml(cls, name, form): + filing = Mock() + filing.form = form + filing.filing_date = date(2025, 3, 31) + filing.xml = Mock(return_value=(TEST_DATA_DIR / name).read_text()) + return cls.from_filing(filing) + + +@pytest.mark.fast +def test_fixtures(): + """Aadi Bioscience 13D: BML 2,100,000 and Leonard 2,435,000. Leonard controls BML, + so his row likely includes it, but nothing in the rows proves that.""" + aadi = _from_xml(Schedule13D, "schedule13d.xml", "SCHEDULE 13D") + assert aadi.total_shares == 2_435_000 + assert aadi.aggregation_basis == 'ambiguous' + assert 'persons may hold separate positions' in aadi.to_context() + + jushi = _from_xml(Schedule13G, "schedule13g.xml", "SCHEDULE 13G") + assert jushi.total_shares == 10_000_000 + assert jushi.aggregation_basis == 'identical' + assert 'persons may hold separate positions' not in jushi.to_context() + + +@pytest.mark.network +def test_gamco_nevro_is_ambiguous_and_below_its_stated_group_total(): + """Item 5(a): "The aggregate number of Securities to which this Schedule 13D relates + is 2,123,900 shares, representing 5.54%"; the largest person reports 1,322,950.""" + from edgar import find + schedule = find("0000807249-25-000053").obj() + assert schedule.total_shares == 1_322_950 + assert schedule.aggregation_basis == 'ambiguous' + + +@pytest.mark.network +def test_blackstone_parent_sums_its_funds(): + from edgar import find + schedule = find("0000950170-25-090888").obj() + assert schedule.total_shares == 4_782_781 + assert schedule.aggregation_basis == 'parent_sums_children'