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/1379.added.md
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions changelog.d/1379.fixed.md
Original file line number Diff line number Diff line change
@@ -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)
19 changes: 17 additions & 2 deletions docs/guides/schedule13dg-data-object-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -306,12 +310,23 @@ 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}%")
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: {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}")
```

`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.

---

## Key Differences: Visual Comparison
Expand Down
1 change: 1 addition & 0 deletions docs/upgrade/6.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |

---

Expand Down
122 changes: 102 additions & 20 deletions edgar/beneficial_ownership/amendments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -58,6 +59,36 @@ 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.

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
values = [p.reported(figure) for p in schedule.reporting_persons]
if None in values:
return None
return sum(values)


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
class OwnershipComparison:
"""
Expand All @@ -66,72 +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)

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 reported_shares_change(self) -> Optional[int]:
"""
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 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')
if curr_shares is None or prev_shares is None:
return None
return curr_shares - prev_shares

@property
def reported_percent_change(self) -> Optional[float]:
"""
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 as for ``reported_shares_change``
"""
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)
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.
"""
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)
return curr_shares - prev_shares
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%)
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.
"""
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)
return curr_pct - prev_pct
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"""
return self.shares_change > 0
"""Check if shares increased (False when the change is unknown)"""
change = self.reported_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.reported_shares_change
return change is not None and change < 0

@property
def is_unchanged(self) -> bool:
"""Check if shareholding is unchanged"""
return self.shares_change == 0
"""Check if shareholding is unchanged (False when the change is unknown)"""
return self.reported_shares_change == 0

def get_summary(self) -> dict:
"""
Get summary of changes.

Returns:
Dictionary with change metrics
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': 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': _placeholder_sum(self.previous, 'aggregate_amount'),
'current_shares': _placeholder_sum(self.current, 'aggregate_amount'),
'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': _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
Expand Down
49 changes: 48 additions & 1 deletion edgar/beneficial_ownership/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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:
Expand Down
Loading
Loading