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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/qsk4.fixed.md
Original file line number Diff line number Diff line change
@@ -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)
22 changes: 17 additions & 5 deletions docs/guides/schedule13dg-data-object-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions edgar/ai/skills/ownership/skill.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
52 changes: 29 additions & 23 deletions edgar/beneficial_ownership/amendments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
46 changes: 43 additions & 3 deletions edgar/beneficial_ownership/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
"""
Expand Down
Loading
Loading