diff --git a/changelog.d/u6tkw-schedule13-unreported.changed.md b/changelog.d/u6tkw-schedule13-unreported.changed.md new file mode 100644 index 000000000..497539cee --- /dev/null +++ b/changelog.d/u6tkw-schedule13-unreported.changed.md @@ -0,0 +1 @@ +**Schedule 13D/G figures a filing does not report are `None`, not `0`.** `ReportingPerson`'s ownership figures and power totals, `total_shares`/`total_percent`, and `OwnershipComparison.shares_change`, `percent_change` and `get_summary()` no longer read a missing figure as 0, so a 13D/A omitting the Aadi Bioscience share counts is no longer a 2,435,000-share sale; a reported 0 stays 0, and the 5.x FutureWarning is gone. (bead edgartools-u6tkw) diff --git a/docs/guides/schedule13dg-data-object-guide.md b/docs/guides/schedule13dg-data-object-guide.md index d052cdc6c..03d7dfa3c 100644 --- a/docs/guides/schedule13dg-data-object-guide.md +++ b/docs/guides/schedule13dg-data-object-guide.md @@ -72,8 +72,8 @@ When you call `filing.obj()` on a Schedule 13D filing, edgartools parses the XML | `amendment_number` | `Optional[int]` | Amendment sequence number | | `is_amendment` | `bool` | Whether this is an amendment (`/A`) | | `filing_date` | `date` | Date filed with the SEC | -| `total_shares` | `int` | Aggregate beneficial ownership (handles joint/separate filers) | -| `total_percent` | `float` | Aggregate ownership percentage | +| `total_shares` | `Optional[int]` | Aggregate beneficial ownership (handles joint/separate filers) | +| `total_percent` | `Optional[float]` | Aggregate ownership percentage | ### Items (Narrative Disclosures) @@ -151,8 +151,8 @@ When you call `filing.obj()` on a Schedule 13G filing, edgartools parses the XML | `rule_designation` | `Optional[str]` | SEC rule under which filing is made (e.g., "Rule 13d-1(c)") | | `is_amendment` | `bool` | Whether this is an amendment | | `filing_date` | `date` | Date filed with the SEC | -| `total_shares` | `int` | Aggregate beneficial ownership | -| `total_percent` | `float` | Aggregate ownership percentage | +| `total_shares` | `Optional[int]` | Aggregate beneficial ownership | +| `total_percent` | `Optional[float]` | Aggregate ownership percentage | | `is_passive_investor` | `bool` | Always `True` for Schedule 13G | ### Example: Reading a Schedule 13G @@ -240,20 +240,20 @@ Each reporting person (individual or entity) in the filing. | `cik` | `str` | SEC Central Index Key | | `name` | `str` | Person or entity name | | `citizenship` | `str` | Citizenship or place of organization | -| `sole_voting_power` | `int` | Shares with sole voting authority | -| `shared_voting_power` | `int` | Shares with shared voting authority | -| `sole_dispositive_power` | `int` | Shares with sole dispositive authority | -| `shared_dispositive_power` | `int` | Shares with shared dispositive authority | -| `aggregate_amount` | `int` | Total shares beneficially owned | -| `percent_of_class` | `float` | Percentage of outstanding shares | +| `sole_voting_power` | `Optional[int]` | Shares with sole voting authority | +| `shared_voting_power` | `Optional[int]` | Shares with shared voting authority | +| `sole_dispositive_power` | `Optional[int]` | Shares with sole dispositive authority | +| `shared_dispositive_power` | `Optional[int]` | Shares with shared dispositive authority | +| `aggregate_amount` | `Optional[int]` | Total shares beneficially owned | +| `percent_of_class` | `Optional[float]` | Percentage of outstanding shares | | `type_of_reporting_person` | `str` | Entity type code (e.g., "IN" = individual, "IA" = investment adviser) | | `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 | +| `total_voting_power` | `Optional[int]` | Computed: sole + shared voting power | +| `total_dispositive_power` | `Optional[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. +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 is `None` and is named in `unreported_fields`, and `total_voting_power` or `total_dispositive_power` is `None` when either half is. A reported zero stays `0`. `reported(figure)` returns the same value as the field, looked up by name. ### IssuerInfo @@ -320,24 +320,22 @@ amendment = Schedule13D.from_filing(amended_filing) comparison = OwnershipComparison(current=amendment, previous=original) -change = comparison.reported_shares_change +change = comparison.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") +if comparison.percent_change is not None: + print(f"Percent changed: {comparison.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` 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`. `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. +In `get_summary()` a share or percent value is `None` when its filing does not report it. `reported_shares_change` and `reported_percent_change` (and the summary keys of the same names) come from 5.x, where `shares_change` and `percent_change` read an unreported figure as `0`; in 6.0 they equal `shares_change` and `percent_change`. --- diff --git a/docs/upgrade/6.0.md b/docs/upgrade/6.0.md index a1cdab0c5..399edda89 100644 --- a/docs/upgrade/6.0.md +++ b/docs/upgrade/6.0.md @@ -1355,14 +1355,51 @@ rows and periods, where 5.x could return another statement's. --- -## Still to come +## Schedule 13D/G: a figure the filing does not report is `None`, not `0` + +**What changed — a quiet one.** An amendment (13D/A, 13G/A) may leave any +ownership figure off a reporting person's cover page, a pre-2025 filing read +from its SGML header has none, and a filing can have no reporting-person rows +at all. 5.x read every such figure as `0`. In 6.0 these are `None` where the +filing does not report the figure, and a reported `0` stays `0`: + +- `ReportingPerson.sole_voting_power`, `shared_voting_power`, + `sole_dispositive_power`, `shared_dispositive_power`, `aggregate_amount`, + `percent_of_class` +- `ReportingPerson.total_voting_power` and `total_dispositive_power` (`None` + when either half is) +- `Schedule13D` / `Schedule13G` `total_shares` and `total_percent` (also `None` + for a filing with no reporting-person rows; still `0` when every person is + flagged `is_aggregate_exclude_shares`) +- `OwnershipComparison.shares_change`, `percent_change`, and the share and + percent values of `get_summary()` + +The `FutureWarning` that `shares_change`, `percent_change` and `get_summary()` +emitted in 5.x is gone. An amendment that omits its share counts no longer +reads as a sale of the whole position: against BML Investment Partners' 13D on +Aadi Bioscience (`0001373604-24-000118`, 2,435,000 shares), 5.x reported such +an amendment as a 2,435,000-share decrease. -Planned for 6.0 and **not yet merged**. Do not write code against it until it -has a section above. +```text +# 5.x +amendment.total_shares 0 +comparison.shares_change -2435000 (FutureWarning) +# 6.0 +amendment.total_shares None +comparison.shares_change None +``` -| Change | What to expect | -|---|---| -| 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`. | +**Rewrite rule.** Test for `None` before formatting or arithmetic: `f"{x:,}"` +and `x + y` raise `TypeError` on `None`. Code already written against the 5.x +`None`-returning names keeps working unchanged: `ReportingPerson.reported(figure)`, +`ReportingPerson.unreported_fields`, `OwnershipComparison.reported_shares_change` +and `reported_percent_change` (and the summary keys of the same names) now equal +the main properties. If you want the old numbers back, `x or 0` gives them, but a +`0` you substitute is not one the filing reported. + +--- + +## Not in 6.0 Module moves stop at the ones described above for 6.0. Later moves, such as the merge of `edgar.markdown` and `edgar._markdown`, come in 6.x with a diff --git a/edgar/beneficial_ownership/amendments.py b/edgar/beneficial_ownership/amendments.py index ae4cf65bc..367705836 100644 --- a/edgar/beneficial_ownership/amendments.py +++ b/edgar/beneficial_ownership/amendments.py @@ -4,7 +4,6 @@ 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 @@ -77,35 +76,6 @@ def _reported_total(schedule: 'Schedule13D | Schedule13G', figure: str): return _total_over_persons(schedule.reporting_persons, figure) -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: - # The text is fixed per property so Python's default filter shows it once per call site. - # stacklevel=3 skips this helper and the property, so the caller's line is reported. - 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, - ) - - -def _warn_unreported_summary() -> None: - # get_summary() warns for itself: reading self.shares_change would attribute the - # warning to this module, and the default filter would then show it once per process. - warnings.warn( - "OwnershipComparison.get_summary() computes 'shares_change' and 'percent_change' from figures " - "a filing does not report, read as 0. They will be None in edgartools 6.0. " - "Use the 'reported_shares_change' and 'reported_percent_change' keys, which are None today.", - FutureWarning, - stacklevel=3, - ) - - @dataclass class OwnershipComparison: """ @@ -117,25 +87,26 @@ class OwnershipComparison: 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. + 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. ``shares_change`` and ``percent_change`` are + None then, and ``is_accumulating``, ``is_liquidating`` and ``is_unchanged`` are + all False. A reported 0 is a number. Example: original = Schedule13D.from_filing(original_filing) amendment = Schedule13D.from_filing(amended_filing) comparison = OwnershipComparison(current=amendment, previous=original) - if comparison.reported_shares_change is not None: - print(f"Shares changed by: {comparison.reported_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' previous: 'Schedule13D | Schedule13G' @property - def reported_shares_change(self) -> Optional[int]: + def shares_change(self) -> Optional[int]: """ Change in total shares owned, or None when either filing does not report its share counts. @@ -151,13 +122,13 @@ def reported_shares_change(self) -> Optional[int]: return curr_shares - prev_shares @property - def reported_percent_change(self) -> Optional[float]: + def 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`` + or None as for ``shares_change`` """ curr_pct = _reported_total(self.current, 'percent_of_class') prev_pct = _reported_total(self.previous, 'percent_of_class') @@ -166,81 +137,59 @@ def reported_percent_change(self) -> Optional[float]: 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_total(self.current, 'aggregate_amount') - _placeholder_total(self.previous, 'aggregate_amount') + def reported_shares_change(self) -> Optional[int]: + """The same value as ``shares_change``; kept from 5.x, where ``shares_change`` read unreported figures as 0.""" + return self.shares_change @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_total(self.current, 'percent_of_class') - _placeholder_total(self.previous, 'percent_of_class') + def reported_percent_change(self) -> Optional[float]: + """The same value as ``percent_change``; kept from 5.x, where ``percent_change`` read unreported figures as 0.""" + return self.percent_change @property def is_accumulating(self) -> bool: """Check if shares increased (False when the change is unknown)""" - change = self.reported_shares_change + change = self.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.reported_shares_change + change = self.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.reported_shares_change == 0 + return self.shares_change == 0 def get_summary(self) -> dict: """ Get summary of changes. Returns: - 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. + Dictionary with change metrics. A share or percent value is None when + its filing does not report the figure, and a change is None when either + side is. ``reported_shares_change`` and ``reported_percent_change`` are + kept from 5.x and equal ``shares_change`` and ``percent_change``. """ - reported_shares_change = self.reported_shares_change - 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_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') + previous_shares = _reported_total(self.previous, 'aggregate_amount') + current_shares = _reported_total(self.current, 'aggregate_amount') + previous_percent = _reported_total(self.previous, 'percent_of_class') + current_percent = _reported_total(self.current, 'percent_of_class') + shares_change = self.shares_change + percent_change = self.percent_change return { 'previous_filing_date': self.previous.filing_date, 'current_filing_date': self.current.filing_date, 'previous_shares': previous_shares, 'current_shares': current_shares, - 'shares_change': current_shares - previous_shares, + 'shares_change': shares_change, 'previous_percent': previous_percent, 'current_percent': current_percent, - 'percent_change': current_percent - previous_percent, - 'reported_shares_change': reported_shares_change, - 'reported_percent_change': reported_percent_change, + 'percent_change': percent_change, + 'reported_shares_change': shares_change, + 'reported_percent_change': 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 7c48a12d2..228631d59 100644 --- a/edgar/beneficial_ownership/models.py +++ b/edgar/beneficial_ownership/models.py @@ -35,10 +35,10 @@ 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; - ``_aggregation_basis`` says whether that max is the group's total. A filing with - no reporting-person rows reports no figure either. + The max() over the persons not flagged ``is_aggregate_exclude_shares`` that + ``total_shares`` and ``total_percent`` return; ``_aggregation_basis`` says whether + that max is the group's total. A filing with no reporting-person rows reports no + figure either. When every person is excluded the total is 0. """ if not persons: return None @@ -96,32 +96,44 @@ 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. + An ownership figure the filing does not report is None and is named in + ``unreported_fields``; a reported 0 stays 0. An amendment may leave any figure + out, and a pre-2025 filing read from its SGML header reports none of them. + A figure passed as None is added to ``unreported_fields``, and one named there + is set to None, so the two always agree. """ cik: str name: str citizenship: str - sole_voting_power: int - shared_voting_power: int - sole_dispositive_power: int - shared_dispositive_power: int - aggregate_amount: int - percent_of_class: float + sole_voting_power: Optional[int] + shared_voting_power: Optional[int] + sole_dispositive_power: Optional[int] + shared_dispositive_power: Optional[int] + aggregate_amount: Optional[int] + percent_of_class: Optional[float] type_of_reporting_person: str fund_type: Optional[str] = None comment: Optional[str] = None 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) + unreported_fields: FrozenSet[str] = frozenset() # ownership figures the filing leaves out (None) + + def __post_init__(self): + unreported = {figure for figure in self.unreported_fields if figure in _OWNERSHIP_FIGURES} + unreported.update(figure for figure in _OWNERSHIP_FIGURES if getattr(self, figure) is None) + for figure in unreported: + object.__setattr__(self, figure, None) + object.__setattr__(self, 'unreported_fields', frozenset(unreported)) 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. + The same value as the attribute itself (``person.reported('aggregate_amount')`` + is ``person.aggregate_amount``); kept from 5.x, where the attribute held a + placeholder 0, and for looking a figure up by name. + Args: figure: One of ``sole_voting_power``, ``shared_voting_power``, ``sole_dispositive_power``, ``shared_dispositive_power``, @@ -135,13 +147,17 @@ def reported(self, figure: str) -> Optional[Union[int, float]]: return getattr(self, figure) @property - def total_voting_power(self) -> int: - """Total voting power (sole + shared)""" + def total_voting_power(self) -> Optional[int]: + """Total voting power (sole + shared), or None when the filing leaves either out""" + if self.sole_voting_power is None or self.shared_voting_power is None: + return None return self.sole_voting_power + self.shared_voting_power @property - def total_dispositive_power(self) -> int: - """Total dispositive power (sole + shared)""" + def total_dispositive_power(self) -> Optional[int]: + """Total dispositive power (sole + shared), or None when the filing leaves either out""" + if self.sole_dispositive_power is None or self.shared_dispositive_power is None: + return None return self.sole_dispositive_power + self.shared_dispositive_power diff --git a/edgar/beneficial_ownership/schedule13.py b/edgar/beneficial_ownership/schedule13.py index c85530091..b98d43b85 100644 --- a/edgar/beneficial_ownership/schedule13.py +++ b/edgar/beneficial_ownership/schedule13.py @@ -99,8 +99,7 @@ def _ownership_figures(sole_voting: Optional[str], shared_voting: Optional[str], 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. + is not a number is None, and is named in ``unreported_fields``. A reported 0 stays 0. """ texts = dict(zip(_OWNERSHIP_FIGURES, (sole_voting, shared_voting, sole_dispositive, shared_dispositive, aggregate, percent), strict=True)) @@ -111,7 +110,6 @@ def _ownership_figures(sole_voting: Optional[str], shared_voting: Optional[str], 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 @@ -171,7 +169,7 @@ def _partial_from_header(filing: 'Filing') -> tuple: identities are recoverable from the header — the subject company (issuer) and the filer(s)/reporting person(s). Beneficial-ownership numerics (shares, percentage, voting/dispositive power) are NOT in the header and are - left as ``0`` / empty; callers must gate on ``has_structured_data``. + None, named in ``unreported_fields``; ``has_structured_data`` is False. Returns: Tuple of (IssuerInfo, List[ReportingPerson]). Falls back to filing-level @@ -212,12 +210,12 @@ def _partial_from_header(filing: 'Filing') -> tuple: cik=ci.cik or '', name=ci.name, citizenship='', - sole_voting_power=0, - shared_voting_power=0, - sole_dispositive_power=0, - shared_dispositive_power=0, - aggregate_amount=0, - percent_of_class=0.0, + sole_voting_power=None, + shared_voting_power=None, + sole_dispositive_power=None, + shared_dispositive_power=None, + aggregate_amount=None, + percent_of_class=None, type_of_reporting_person='', unreported_fields=frozenset(_OWNERSHIP_FIGURES), )) @@ -554,24 +552,17 @@ def total_shares(self) -> Optional[int]: 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. + Excludes shares flagged with is_aggregate_exclude_shares == True; 0 when + every person is excluded. - Returns ``None`` when ``has_structured_data`` is False (pre-2025 HTML-only - filing) — the share count is genuinely unavailable, not zero. + Returns ``None`` when the filing does not report the share count: a + pre-2025 HTML-only filing (``has_structured_data`` is False), a filing with + no reporting-person rows, or an amendment where a counted person leaves + its share count out. A reported 0 stays 0. """ if not self.has_structured_data: return None - - if not self.reporting_persons: - return 0 - - included_persons = [p for p in self.reporting_persons - if not p.is_aggregate_exclude_shares] - - if not included_persons: - return 0 - - return max(p.aggregate_amount for p in included_persons) + return _reported_total(self.reporting_persons, 'aggregate_amount') @property def total_percent(self) -> Optional[float]: @@ -584,24 +575,15 @@ def total_percent(self) -> Optional[float]: 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. + Excludes shares flagged with is_aggregate_exclude_shares == True; 0 when + every person is excluded. - Returns ``None`` when ``has_structured_data`` is False (pre-2025 HTML-only - filing) — the percentage is genuinely unavailable, not zero. + Returns ``None`` when the filing does not report the percentage, as for + ``total_shares``. A reported 0 stays 0. """ if not self.has_structured_data: return None - - if not self.reporting_persons: - return 0.0 - - included_persons = [p for p in self.reporting_persons - if not p.is_aggregate_exclude_shares] - - if not included_persons: - return 0.0 - - return max(p.percent_of_class for p in included_persons) + return _reported_total(self.reporting_persons, 'percent_of_class') @property def aggregation_basis(self) -> Optional[str]: @@ -1088,24 +1070,17 @@ def total_shares(self) -> Optional[int]: 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. + Excludes shares flagged with is_aggregate_exclude_shares == True; 0 when + every person is excluded. - Returns ``None`` when ``has_structured_data`` is False (pre-2025 HTML-only - filing) — the share count is genuinely unavailable, not zero. + Returns ``None`` when the filing does not report the share count: a + pre-2025 HTML-only filing (``has_structured_data`` is False), a filing with + no reporting-person rows, or an amendment where a counted person leaves + its share count out. A reported 0 stays 0. """ if not self.has_structured_data: return None - - if not self.reporting_persons: - return 0 - - included_persons = [p for p in self.reporting_persons - if not p.is_aggregate_exclude_shares] - - if not included_persons: - return 0 - - return max(p.aggregate_amount for p in included_persons) + return _reported_total(self.reporting_persons, 'aggregate_amount') @property def total_percent(self) -> Optional[float]: @@ -1118,24 +1093,15 @@ def total_percent(self) -> Optional[float]: 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. + Excludes shares flagged with is_aggregate_exclude_shares == True; 0 when + every person is excluded. - Returns ``None`` when ``has_structured_data`` is False (pre-2025 HTML-only - filing) — the percentage is genuinely unavailable, not zero. + Returns ``None`` when the filing does not report the percentage, as for + ``total_shares``. A reported 0 stays 0. """ if not self.has_structured_data: return None - - if not self.reporting_persons: - return 0.0 - - included_persons = [p for p in self.reporting_persons - if not p.is_aggregate_exclude_shares] - - if not included_persons: - return 0.0 - - return max(p.percent_of_class for p in included_persons) + return _reported_total(self.reporting_persons, 'percent_of_class') @property def is_passive_investor(self) -> bool: 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 0df58d672..dc677ea8b 100644 --- a/tests/issues/regression/test_issue_1379_schedule13_unreported_figures.py +++ b/tests/issues/regression/test_issue_1379_schedule13_unreported_figures.py @@ -23,13 +23,13 @@ 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. +``reported_percent_change`` are None and no direction flag is set. In 5.x the +fields kept a placeholder 0 and ``shares_change``/``percent_change`` returned +numbers with a FutureWarning; edgartools 6.0 made them None (edgartools-u6tkw), +so the assertions on those placeholders now assert None and no warning. 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 @@ -82,18 +82,18 @@ def _header_only_schedule13d(): return Schedule13D.from_filing(filing) -def _assert_change_unknown(comparison, placeholder_shares_change): +def _assert_change_unknown(comparison): """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) + with warnings.catch_warnings(): + warnings.simplefilter('error', FutureWarning) + 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 + # 6.0: the main properties are None too (5.x read the figure as 0 and warned). + assert comparison.shares_change is None + assert comparison.percent_change is None @pytest.mark.fast @@ -107,17 +107,17 @@ 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), 2_435_000) - _assert_change_unknown(OwnershipComparison(current=header_only, previous=structured), -2_435_000) + _assert_change_unknown(OwnershipComparison(current=structured, previous=header_only)) + _assert_change_unknown(OwnershipComparison(current=header_only, previous=structured)) 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() + 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['shares_change'] is None assert summary['is_accumulating'] is False @@ -132,34 +132,37 @@ 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), -2_435_000) - _assert_change_unknown(OwnershipComparison(current=structured, previous=no_rows), 2_435_000) + _assert_change_unknown(OwnershipComparison(current=no_rows, previous=structured)) + _assert_change_unknown(OwnershipComparison(current=structured, previous=no_rows)) + assert no_rows.total_shares is None + assert no_rows.total_percent is None @pytest.mark.fast -def test_unreported_figures_keep_their_5x_types(): - """Control: code written against 5.x keeps getting numbers until 6.0. +def test_unreported_figures_are_none(): + """6.0: an unreported figure is None in the field itself, not the 5.x placeholder 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. + This test asserted the 5.x placeholders (0, and a change of +2,435,000 against + a header-only filing) until edgartools-u6tkw made them 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 + for field_name in OWNERSHIP_FIGURES: + assert getattr(person, field_name) is None + assert person.total_voting_power is None + assert person.total_dispositive_power is None with warnings.catch_warnings(): - warnings.simplefilter('ignore', FutureWarning) + warnings.simplefilter('error', FutureWarning) comparison = OwnershipComparison(current=structured, previous=header_only) - assert f"{comparison.shares_change:+,}" == '+2,435,000' - assert f"{comparison.percent_change:+.1f}" == '+9.9' + assert comparison.shares_change is None + assert comparison.percent_change is None summary = comparison.get_summary() - assert summary['previous_shares'] == 0 + assert summary['previous_shares'] is None + assert summary['previous_percent'] is None assert summary['current_shares'] == 2_435_000 + assert summary['current_percent'] == 9.9 @pytest.mark.fast @@ -272,7 +275,7 @@ def test_amendment_omitting_ownership_figures_marks_them_unreported(schedule_cla 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 + assert getattr(person, field_name) is None # the 5.x placeholder 0 became None in 6.0 # The display paths show the gap instead of a zero. context = amendment.to_context(detail='full') @@ -373,11 +376,10 @@ 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), -2_435_000) - _assert_change_unknown(OwnershipComparison(current=original, previous=amendment), 2_435_000) + _assert_change_unknown(OwnershipComparison(current=amendment, previous=original)) + _assert_change_unknown(OwnershipComparison(current=original, previous=amendment)) - with pytest.warns(FutureWarning): - summary = OwnershipComparison(current=amendment, previous=original).get_summary() + 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 @@ -393,8 +395,8 @@ def test_ownership_comparison_13g_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), -10_000_000) - _assert_change_unknown(OwnershipComparison(current=original, previous=amendment), 10_000_000) + _assert_change_unknown(OwnershipComparison(current=amendment, previous=original)) + _assert_change_unknown(OwnershipComparison(current=original, previous=amendment)) @pytest.mark.fast @@ -421,12 +423,11 @@ def test_ownership_comparison_13g_amendment_reporting_figures(): @pytest.mark.fast -def test_get_summary_warns_once_at_the_callers_line(): - """get_summary() names the caller's line, not amendments.py. +def test_get_summary_emits_no_warning(): + """6.0: get_summary() reports an unreported figure as None and warns no more. - It used to read self.shares_change and self.percent_change, so it emitted two - warnings attributed to amendments.py, and Python's default filter then showed - them once per process no matter where get_summary() was called from. + In 5.x it emitted one FutureWarning at the caller's line and returned the + placeholder values (shares_change 2,435,000, previous_shares 0). """ comparison = OwnershipComparison(current=_schedule13d_from_xml(SCHEDULE_13D_XML_PATH.read_text()), previous=_header_only_schedule13d()) @@ -434,14 +435,10 @@ def test_get_summary_warns_once_at_the_callers_line(): warnings.simplefilter('always') summary = comparison.get_summary() - future = [w for w in caught if issubclass(w.category, FutureWarning)] - assert len(future) == 1 - assert future[0].filename == __file__ - assert "'reported_shares_change'" in str(future[0].message) - # The 5.x values are unchanged. - assert summary['shares_change'] == 2_435_000 + assert [w for w in caught if issubclass(w.category, FutureWarning)] == [] + assert summary['shares_change'] is None assert summary['current_shares'] == 2_435_000 - assert summary['previous_shares'] == 0 + assert summary['previous_shares'] is None assert summary['reported_shares_change'] is None diff --git a/tests/issues/regression/test_u6tkw_schedule13_unreported_is_none.py b/tests/issues/regression/test_u6tkw_schedule13_unreported_is_none.py new file mode 100644 index 000000000..50ae3c084 --- /dev/null +++ b/tests/issues/regression/test_u6tkw_schedule13_unreported_is_none.py @@ -0,0 +1,188 @@ +"""Schedule 13D/G figures a filing does not report are None, not 0 (edgartools-u6tkw, 6.0). + +Bead: edgartools-u6tkw. Follows GitHub PR https://github.com/dgunning/edgartools/pull/1379, +which added ``ReportingPerson.reported()``/``unreported_fields`` and +``OwnershipComparison.reported_shares_change``/``reported_percent_change`` in 5.x +and warned that the main properties would become None in 6.0. + +In 5.x an unreported figure held a placeholder 0 in ``ReportingPerson``'s six +ownership fields, ``total_voting_power`` and ``total_dispositive_power`` added the +placeholders up, ``total_shares``/``total_percent`` returned 0 for a filing with no +reporting-person rows, and ``OwnershipComparison.shares_change``/``percent_change`` +and ``get_summary()`` computed with the placeholders behind a FutureWarning. In 6.0 +they are None, and a reported 0 stays 0. + +Ground truth: BML Investment Partners' SCHEDULE 13D on Aadi Bioscience, Inc., +accession 0001373604-24-000118, filed 2024-12-31 (event date 12/31/2024). The +fixture tests/data/beneficial_ownership/schedule13d.xml is that filing's primary +XML (identical to SEC's apart from whitespace and the masked filer CCC). Its cover +pages report: + +- BML Investment Partners, L.P.: sole voting 0, shared voting 2,100,000, + sole dispositive 0, shared dispositive 2,100,000, aggregate 2,100,000, 8.5% +- Leonard Braden Michael: sole voting 335,000, shared voting 2,100,000, + sole dispositive 335,000, shared dispositive 2,100,000, aggregate 2,435,000, 9.9% +""" +import re +import warnings +from datetime import date +from pathlib import Path +from unittest.mock import Mock + +import pytest + +from edgar.beneficial_ownership import ReportingPerson, Schedule13D +from edgar.beneficial_ownership.amendments import OwnershipComparison +from edgar.exceptions import ValidationError + +FIXTURE = Path(__file__).resolve().parents[2] / "data" / "beneficial_ownership" / "schedule13d.xml" +ACCESSION = "0001373604-24-000118" + + +def _schedule(xml_content, form='SCHEDULE 13D', filing_date=date(2024, 12, 31)): + filing = Mock(form=form, filing_date=filing_date, accession_no=ACCESSION, xml=Mock(return_value=xml_content)) + return Schedule13D.from_filing(filing) + + +def _header_only(): + filer = Mock() + filer.company_information.name = 'BML Investment Partners, L.P.' + filer.company_information.cik = '0001373604' + filing = Mock(form='SC 13D', filing_date=date(2021, 3, 1), xml=Mock(return_value=None), + header=Mock(filers=[filer], subject_companies=[])) + filing.cik = 1422142 + filing.company = 'Aadi Bioscience, Inc.' + return Schedule13D.from_filing(filing) + + +@pytest.fixture +def no_future_warnings(): + with warnings.catch_warnings(): + warnings.simplefilter('error', FutureWarning) + yield + + +@pytest.mark.fast +def test_reported_figures_keep_their_filed_values(no_future_warnings): + """Every figure 0001373604-24-000118 reports, including BML's reported 0 sole voting power.""" + schedule = _schedule(FIXTURE.read_text()) + bml, leonard = schedule.reporting_persons + assert bml.name == 'BML Investment Partners, L.P.' + assert bml.unreported_fields == frozenset() + assert bml.sole_voting_power == 0 # a reported 0 stays 0 + assert bml.sole_dispositive_power == 0 + assert bml.shared_voting_power == 2_100_000 + assert bml.aggregate_amount == 2_100_000 + assert bml.percent_of_class == 8.5 + assert bml.total_voting_power == 2_100_000 + assert bml.total_dispositive_power == 2_100_000 + + assert leonard.name == 'Leonard Braden Michael' + assert leonard.sole_voting_power == 335_000 + assert leonard.aggregate_amount == 2_435_000 + assert leonard.percent_of_class == 9.9 + assert leonard.total_voting_power == 2_435_000 + assert leonard.total_dispositive_power == 2_435_000 + + assert schedule.total_shares == 2_435_000 + assert schedule.total_percent == 9.9 + + +@pytest.mark.fast +def test_figure_left_out_is_none(no_future_warnings): + """An amendment leaving out BML's share count and shared voting power: those are None.""" + xml = FIXTURE.read_text() + xml = xml.replace('2100000', '', 1) + xml = xml.replace('2100000', '', 1) + amendment = _schedule(xml, form='SCHEDULE 13D/A', filing_date=date(2025, 2, 4)) + bml, leonard = amendment.reporting_persons + + assert bml.unreported_fields == {'aggregate_amount', 'shared_voting_power'} + assert bml.aggregate_amount is None + assert bml.shared_voting_power is None + assert bml.total_voting_power is None # 5.x: 0 + 0 = 0 + assert bml.sole_voting_power == 0 # still reported as 0 + assert bml.total_dispositive_power == 2_100_000 # both halves reported + assert bml.percent_of_class == 8.5 + assert bml.reported('aggregate_amount') is None # the 5.x accessor agrees + assert bml.reported('sole_voting_power') == 0 + assert leonard.aggregate_amount == 2_435_000 + + # A counted person leaves its shares out, so the filing's total is unknown; + # every person reports a percent, so that total is known. + assert amendment.total_shares is None + assert amendment.total_percent == 9.9 + + comparison = OwnershipComparison(current=amendment, previous=_schedule(FIXTURE.read_text())) + assert comparison.shares_change is None + assert comparison.reported_shares_change is None + assert comparison.percent_change == 0.0 + assert comparison.reported_percent_change == 0.0 + assert comparison.is_accumulating is False + assert comparison.is_liquidating is False + assert comparison.is_unchanged is False + + summary = comparison.get_summary() + assert summary['current_shares'] is None + assert summary['previous_shares'] == 2_435_000 + assert summary['shares_change'] is None + assert summary['current_percent'] == 9.9 + assert summary['percent_change'] == 0.0 + + +@pytest.mark.fast +def test_header_only_and_no_rows_report_nothing(no_future_warnings): + """A pre-2025 header-only filing and an empty have no figures.""" + header_only = _header_only() + assert header_only.has_structured_data is False + person = header_only.reporting_persons[0] + assert person.aggregate_amount is None + assert person.percent_of_class is None + assert person.total_voting_power is None + assert header_only.total_shares is None + assert header_only.total_percent is None + + no_rows = _schedule(re.sub(r'.*?', '', + FIXTURE.read_text(), flags=re.S)) + assert no_rows.reporting_persons == [] + assert no_rows.total_shares is None # 5.x: 0 + assert no_rows.total_percent is None # 5.x: 0.0 + + comparison = OwnershipComparison(current=_schedule(FIXTURE.read_text()), previous=header_only) + assert comparison.shares_change is None # 5.x: +2,435,000 with a FutureWarning + assert comparison.percent_change is None + assert comparison.get_summary()['previous_shares'] is None + + +@pytest.mark.fast +def test_reported_zero_compares_as_a_number(no_future_warnings): + """Selling out to a reported 0 is a 2,435,000-share decrease, not an unknown.""" + xml = FIXTURE.read_text() + sold_out_xml = xml.replace('2100000', + '0') + sold_out_xml = sold_out_xml.replace('2435000', + '0') + sold_out = _schedule(sold_out_xml, form='SCHEDULE 13D/A', filing_date=date(2025, 6, 1)) + assert sold_out.total_shares == 0 + comparison = OwnershipComparison(current=sold_out, previous=_schedule(xml)) + assert comparison.shares_change == -2_435_000 + assert comparison.is_liquidating is True + + +@pytest.mark.fast +def test_constructor_keeps_none_and_unreported_fields_in_step(): + """A figure passed as None is named in unreported_fields, and one named there is None.""" + common = dict(cik='0001373604', name='BML Investment Partners, L.P.', citizenship='DE', + sole_voting_power=0, shared_voting_power=2_100_000, sole_dispositive_power=0, + shared_dispositive_power=2_100_000, type_of_reporting_person='PN') + from_none = ReportingPerson(aggregate_amount=None, percent_of_class=8.5, **common) + assert from_none.unreported_fields == {'aggregate_amount'} + + # 5.x-style construction: a placeholder 0 named in unreported_fields + from_placeholder = ReportingPerson(aggregate_amount=0, percent_of_class=8.5, + unreported_fields=frozenset({'aggregate_amount'}), **common) + assert from_placeholder.aggregate_amount is None + assert from_placeholder == from_none + + with pytest.raises(ValidationError): + from_none.reported('cik')