diff --git a/changelog.d/1379.added.md b/changelog.d/1379.added.md new file mode 100644 index 000000000..6b2021648 --- /dev/null +++ b/changelog.d/1379.added.md @@ -0,0 +1 @@ +**`ReportingPerson.reported()` and `OwnershipComparison.reported_shares_change` tell an unreported ownership figure from a reported 0.** The 5.x fields still hold a placeholder 0 for a figure the filing leaves out and name it in `ReportingPerson.unreported_fields`; `reported()`, `reported_shares_change` and `reported_percent_change` return None for it, as the fields will in 6.0. (GH #1379) diff --git a/changelog.d/1379.fixed.md b/changelog.d/1379.fixed.md new file mode 100644 index 000000000..81fe26408 --- /dev/null +++ b/changelog.d/1379.fixed.md @@ -0,0 +1 @@ +**`OwnershipComparison.is_liquidating` reported a sale against a Schedule 13D/G filing that gives no share figures.** A 13D/A or 13G/A may omit them and a pre-2025 header-only filing has none, but they were read as 0: an amendment omitting them on the Aadi Bioscience 13D in the test data sold all 4,535,000 shares. The direction flags are now False when the change is unknown, and `shares_change` and `percent_change` warn that they return None in 6.0. (GH #1379) diff --git a/docs/guides/schedule13dg-data-object-guide.md b/docs/guides/schedule13dg-data-object-guide.md index d5f32e1f9..8384b9826 100644 --- a/docs/guides/schedule13dg-data-object-guide.md +++ b/docs/guides/schedule13dg-data-object-guide.md @@ -250,6 +250,10 @@ Each reporting person (individual or entity) in the filing. | `member_of_group` | `Optional[str]` | `"a"` = joint filer, `"b"` = separate filer | | `total_voting_power` | `int` | Computed: sole + shared voting power | | `total_dispositive_power` | `int` | Computed: sole + shared dispositive power | +| `unreported_fields` | `frozenset[str]` | Names of the six ownership figures above that the filing does not report | +| `reported(figure)` | `Optional[int \| float]` | An ownership figure by field name, or `None` when the filing does not report it | + +An amendment (13D/A, 13G/A) may leave any of the six ownership figures out, and a pre-2025 filing read from its SGML header has identities only. Such a figure holds a placeholder `0` in its field and is named in `unreported_fields`; `reported()` returns `None` for it, and `0` for a reported zero. In edgartools 6.0 the fields themselves become `None` for an unreported figure, so code that uses `reported()` today keeps working unchanged. ### IssuerInfo @@ -306,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 diff --git a/docs/upgrade/6.0.md b/docs/upgrade/6.0.md index b96ab58a3..d4d3b65e9 100644 --- a/docs/upgrade/6.0.md +++ b/docs/upgrade/6.0.md @@ -676,6 +676,7 @@ against these; each will get a section above when it lands. | Public API **enforcement** | `__all__` is already declared (see below) — 6.0 makes the names outside it private, and some currently-importable names will move. | | Source tree reorganisation | Module locations change; import paths follow. The first moves have landed and have their own section above — `edgar.effect` and `edgar.form144`. Further moves will each get a section as they ship. | | `FactQuery.to_dataframe()` sentinels | `preferred_sign` and `fiscal_year` move to nullable `Int64`. | +| Schedule 13D/G figures a filing does not report | `ReportingPerson`'s six ownership figures, `total_voting_power`, `total_dispositive_power`, `Schedule13D`/`Schedule13G` `total_shares` and `total_percent`, and `OwnershipComparison.shares_change`, `percent_change` and the share and percent values of `get_summary()` become `None` where the filing does not report the figure, instead of reading it as `0`. 5.x already has the `None`-returning form: `ReportingPerson.reported(figure)`, `ReportingPerson.unreported_fields`, and `OwnershipComparison.reported_shares_change` / `reported_percent_change`; `shares_change` and `percent_change` emit a `FutureWarning` when they read an unreported figure as `0`. A reported `0` stays `0`. | --- diff --git a/edgar/beneficial_ownership/amendments.py b/edgar/beneficial_ownership/amendments.py index c5a179676..e72062d7a 100644 --- a/edgar/beneficial_ownership/amendments.py +++ b/edgar/beneficial_ownership/amendments.py @@ -4,6 +4,7 @@ This module provides utilities for linking amendments to original filings and comparing ownership changes between filings. """ +import warnings from dataclasses import dataclass from typing import TYPE_CHECKING, Optional @@ -58,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: """ @@ -66,28 +97,69 @@ 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: @@ -95,43 +167,53 @@ 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 diff --git a/edgar/beneficial_ownership/models.py b/edgar/beneficial_ownership/models.py index a37cb45d1..f3e669bd6 100644 --- a/edgar/beneficial_ownership/models.py +++ b/edgar/beneficial_ownership/models.py @@ -5,9 +5,10 @@ from Schedule 13D/G XML filings. """ from dataclasses import dataclass -from typing import Optional +from typing import FrozenSet, Optional, Union from edgar._party import Address +from edgar.exceptions import ValidationError __all__ = [ 'ReportingPerson', @@ -18,6 +19,30 @@ 'Signature' ] +# The ownership figures on a reporting person's cover page. An amendment may +# leave any of them out, and a pre-2025 filing read from its SGML header has none. +_OWNERSHIP_FIGURES = ( + 'sole_voting_power', + 'shared_voting_power', + 'sole_dispositive_power', + 'shared_dispositive_power', + 'aggregate_amount', + 'percent_of_class', +) + + +def _reported_total(persons, figure: str) -> Optional[Union[int, float]]: + """ + A filing's total for an ownership figure, or None when a person it counts leaves the figure out. + + The same max() over the persons not flagged ``is_aggregate_exclude_shares`` that + ``total_shares`` and ``total_percent`` take, which read an unreported figure as 0. + """ + values = [p.reported(figure) for p in persons if not p.is_aggregate_exclude_shares] + if None in values: + return None + return max(values, default=0) + @dataclass(frozen=True) class ReportingPerson: @@ -26,6 +51,11 @@ class ReportingPerson: Represents a single reporting person from the SC 13D or SC 13G filing. Joint filers will have multiple ReportingPerson instances. + + An ownership figure the filing does not report holds a placeholder 0 and is + named in ``unreported_fields``; ``reported()`` returns None for it instead. + A reported 0 stays 0 in both. edgartools 6.0 will make the fields themselves + None for an unreported figure. """ cik: str name: str @@ -42,6 +72,23 @@ class ReportingPerson: member_of_group: Optional[str] = None # "a" = group member (joint filer), "b" = separate filer is_aggregate_exclude_shares: bool = False # True if shares excluded from aggregate count no_cik: bool = False # True if reporting person has no CIK assigned + unreported_fields: FrozenSet[str] = frozenset() # ownership figures the filing leaves out (placeholder 0) + + def reported(self, figure: str) -> Optional[Union[int, float]]: + """ + An ownership figure as the filing reports it, or None when the filing leaves it out. + + Args: + figure: One of ``sole_voting_power``, ``shared_voting_power``, + ``sole_dispositive_power``, ``shared_dispositive_power``, + ``aggregate_amount`` or ``percent_of_class``. + """ + if figure not in _OWNERSHIP_FIGURES: + raise ValidationError(f"Not an ownership figure: {figure!r}. Use one of {', '.join(_OWNERSHIP_FIGURES)}", + parameter='figure', invalid_value=figure) + if figure in self.unreported_fields: + return None + return getattr(self, figure) @property def total_voting_power(self) -> int: diff --git a/edgar/beneficial_ownership/rendering.py b/edgar/beneficial_ownership/rendering.py index 400e8cab9..c2e8af815 100644 --- a/edgar/beneficial_ownership/rendering.py +++ b/edgar/beneficial_ownership/rendering.py @@ -12,6 +12,8 @@ from rich.table import Table from rich.text import Text +from edgar.beneficial_ownership.models import ReportingPerson, _reported_total + if TYPE_CHECKING: from edgar.beneficial_ownership.schedule13 import Schedule13D, Schedule13G @@ -30,6 +32,19 @@ def _fmt_percent(value) -> str: return f"{value:.2f}%" if value is not None else _UNAVAILABLE +def _schedule_total(schedule, figure: str): + """A schedule's total for a figure, or None when the filing does not report it.""" + if not schedule.has_structured_data: + return None + return _reported_total(schedule.reporting_persons, figure) + + +def _total_power(person: ReportingPerson, sole: str, shared: str): + """Sole + shared power, or None when the filing leaves either out.""" + values = (person.reported(sole), person.reported(shared)) + return None if None in values else sum(values) + + def _degraded_notice() -> Panel: """Banner shown when only header identities are available (pre-2025 HTML filing).""" return Panel( @@ -72,8 +87,8 @@ def render_schedule13d(schedule: 'Schedule13D') -> Panel: header.add_row("CUSIP:", schedule.security_info.cusip) # Aggregate ownership - header.add_row("Total Shares:", _fmt_shares(schedule.total_shares)) - header.add_row("Total Percent:", _fmt_percent(schedule.total_percent)) + header.add_row("Total Shares:", _fmt_shares(_schedule_total(schedule, 'aggregate_amount'))) + header.add_row("Total Percent:", _fmt_percent(_schedule_total(schedule, 'percent_of_class'))) elements = [header, Text()] if not schedule.has_structured_data: @@ -94,15 +109,14 @@ def render_schedule13d(schedule: 'Schedule13D') -> Panel: persons_table.add_column("Voting Power", justify="right") persons_table.add_column("Dispositive Power", justify="right") - structured = schedule.has_structured_data for person in schedule.reporting_persons: persons_table.add_row( person.name, person.cik or 'N/A', - f"{person.aggregate_amount:,}" if structured else _UNAVAILABLE, - f"{person.percent_of_class:.2f}%" if structured else _UNAVAILABLE, - f"{person.total_voting_power:,}" if structured else _UNAVAILABLE, - f"{person.total_dispositive_power:,}" if structured else _UNAVAILABLE + _fmt_shares(person.reported('aggregate_amount')), + _fmt_percent(person.reported('percent_of_class')), + _fmt_shares(_total_power(person, 'sole_voting_power', 'shared_voting_power')), + _fmt_shares(_total_power(person, 'sole_dispositive_power', 'shared_dispositive_power')) ) elements.append(persons_table) @@ -190,8 +204,8 @@ def render_schedule13g(schedule: 'Schedule13G') -> Panel: header.add_row("Rule:", schedule.rule_designation) # Aggregate ownership - header.add_row("Total Shares:", _fmt_shares(schedule.total_shares)) - header.add_row("Total Percent:", _fmt_percent(schedule.total_percent)) + header.add_row("Total Shares:", _fmt_shares(_schedule_total(schedule, 'aggregate_amount'))) + header.add_row("Total Percent:", _fmt_percent(_schedule_total(schedule, 'percent_of_class'))) header.add_row("Type:", "Passive Institutional Investor") elements = [header, Text()] @@ -213,15 +227,14 @@ def render_schedule13g(schedule: 'Schedule13G') -> Panel: persons_table.add_column("Voting Power", justify="right") persons_table.add_column("Dispositive Power", justify="right") - structured = schedule.has_structured_data for person in schedule.reporting_persons: persons_table.add_row( person.name, person.type_of_reporting_person or 'N/A', - f"{person.aggregate_amount:,}" if structured else _UNAVAILABLE, - f"{person.percent_of_class:.2f}%" if structured else _UNAVAILABLE, - f"{person.total_voting_power:,}" if structured else _UNAVAILABLE, - f"{person.total_dispositive_power:,}" if structured else _UNAVAILABLE + _fmt_shares(person.reported('aggregate_amount')), + _fmt_percent(person.reported('percent_of_class')), + _fmt_shares(_total_power(person, 'sole_voting_power', 'shared_voting_power')), + _fmt_shares(_total_power(person, 'sole_dispositive_power', 'shared_dispositive_power')) ) elements.append(persons_table) diff --git a/edgar/beneficial_ownership/schedule13.py b/edgar/beneficial_ownership/schedule13.py index 32d62922d..111e12985 100644 --- a/edgar/beneficial_ownership/schedule13.py +++ b/edgar/beneficial_ownership/schedule13.py @@ -6,13 +6,22 @@ """ import re from datetime import date -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING, List, Optional, overload if TYPE_CHECKING: from edgar._filings import Filing from edgar._party import Address -from edgar.beneficial_ownership.models import IssuerInfo, ReportingPerson, Schedule13DItems, Schedule13GItems, SecurityInfo, Signature +from edgar.beneficial_ownership.models import ( + _OWNERSHIP_FIGURES, + IssuerInfo, + ReportingPerson, + Schedule13DItems, + Schedule13GItems, + SecurityInfo, + Signature, + _reported_total, +) from edgar.core import get_bool from edgar.xmltools import child_text, find_all_elements, find_element, local_name from edgar.xmltools import parse_xml as parse_xml_document @@ -20,7 +29,15 @@ __all__ = ['Schedule13D', 'Schedule13G'] -def safe_int(value: Optional[str], default: int = 0) -> int: +@overload +def safe_int(value: Optional[str], default: int = 0) -> int: ... + + +@overload +def safe_int(value: Optional[str], default: None) -> Optional[int]: ... + + +def safe_int(value: Optional[str], default: Optional[int] = 0) -> Optional[int]: """ Safely convert a string value to an integer. @@ -43,7 +60,15 @@ def safe_int(value: Optional[str], default: int = 0) -> int: return default -def safe_float(value: Optional[str], default: float = 0.0) -> float: +@overload +def safe_float(value: Optional[str], default: float = 0.0) -> float: ... + + +@overload +def safe_float(value: Optional[str], default: None) -> Optional[float]: ... + + +def safe_float(value: Optional[str], default: Optional[float] = 0.0) -> Optional[float]: """ Safely convert a string value to a float. @@ -66,6 +91,43 @@ def safe_float(value: Optional[str], default: float = 0.0) -> float: return default +def _ownership_figures(sole_voting: Optional[str], shared_voting: Optional[str], + sole_dispositive: Optional[str], shared_dispositive: Optional[str], + aggregate: Optional[str], percent: Optional[str]) -> dict: + """ + ReportingPerson keyword arguments for the six ownership figures, from their XML text. + + A figure whose element is missing (an amendment may leave any of them out) or + is not a number keeps the placeholder 0 it has always had, and is named in + ``unreported_fields`` so that it can be told apart from a reported 0. + """ + texts = dict(zip(_OWNERSHIP_FIGURES, (sole_voting, shared_voting, sole_dispositive, + shared_dispositive, aggregate, percent), strict=True)) + figures = {} + unreported = set() + for figure, text in texts.items(): + parse = safe_float if figure == 'percent_of_class' else safe_int + value = parse(text, default=None) + if value is None: + unreported.add(figure) + value = parse(None) + figures[figure] = value + figures['unreported_fields'] = frozenset(unreported) + return figures + + +def _describe_shares(value: Optional[int]) -> str: + return f"{value:,} shares" if value is not None else "shares not reported" + + +def _describe_percent(value: Optional[float]) -> str: + return f"{value:.1f}%" if value is not None else "percent not reported" + + +def _describe_count(value: Optional[int]) -> str: + return f"{value:,}" if value is not None else "not reported" + + def extract_amendment_number(form_name: str) -> Optional[int]: """ Extract amendment number from filing form name. @@ -156,6 +218,7 @@ def _partial_from_header(filing: 'Filing') -> tuple: aggregate_amount=0, percent_of_class=0.0, type_of_reporting_person='', + unreported_fields=frozenset(_OWNERSHIP_FIGURES), )) return issuer_info, reporting_persons @@ -292,12 +355,14 @@ def parse_xml(xml: str) -> dict: name=child_text(person_el, 'reportingPersonName') or '', fund_type=child_text(person_el, 'fundType'), citizenship=child_text(person_el, 'citizenshipOrOrganization') or '', - sole_voting_power=safe_int(child_text(person_el, 'soleVotingPower')), - shared_voting_power=safe_int(child_text(person_el, 'sharedVotingPower')), - sole_dispositive_power=safe_int(child_text(person_el, 'soleDispositivePower')), - shared_dispositive_power=safe_int(child_text(person_el, 'sharedDispositivePower')), - aggregate_amount=safe_int(child_text(person_el, 'aggregateAmountOwned')), - percent_of_class=safe_float(child_text(person_el, 'percentOfClass')), + **_ownership_figures( + child_text(person_el, 'soleVotingPower'), + child_text(person_el, 'sharedVotingPower'), + child_text(person_el, 'soleDispositivePower'), + child_text(person_el, 'sharedDispositivePower'), + child_text(person_el, 'aggregateAmountOwned'), + child_text(person_el, 'percentOfClass'), + ), type_of_reporting_person=child_text(person_el, 'typeOfReportingPerson') or '', comment=child_text(person_el, 'commentContent'), member_of_group=child_text(person_el, 'memberOfGroup'), @@ -554,7 +619,9 @@ def to_context(self, detail: str = 'standard') -> str: if self.security_info.title: lines.append(f"Security: {self.security_info.title}") if self.has_structured_data: - lines.append(f"Ownership: {self.total_percent:.1f}% ({self.total_shares:,} shares)") + percent = _reported_total(self.reporting_persons, 'percent_of_class') + shares = _reported_total(self.reporting_persons, 'aggregate_amount') + lines.append(f"Ownership: {_describe_percent(percent)} ({_describe_shares(shares)})") else: lines.append("Ownership: unavailable (pre-2025 HTML filing)") @@ -588,7 +655,8 @@ def to_context(self, detail: str = 'standard') -> str: lines.append("REPORTING PERSONS:") for p in self.reporting_persons[:5]: if self.has_structured_data: - p_line = f" {p.name}: {p.percent_of_class:.1f}% ({p.aggregate_amount:,} shares)" + p_line = (f" {p.name}: {_describe_percent(p.reported('percent_of_class'))}" + f" ({_describe_shares(p.reported('aggregate_amount'))})") else: p_line = f" {p.name}" if p.cik: @@ -629,10 +697,10 @@ def to_context(self, detail: str = 'standard') -> str: p = self.reporting_persons[0] lines.append("") lines.append(f"VOTING/DISPOSITIVE POWER ({p.name}):") - lines.append(f" Sole Voting: {p.sole_voting_power:,}") - lines.append(f" Shared Voting: {p.shared_voting_power:,}") - lines.append(f" Sole Dispositive: {p.sole_dispositive_power:,}") - lines.append(f" Shared Dispositive: {p.shared_dispositive_power:,}") + lines.append(f" Sole Voting: {_describe_count(p.reported('sole_voting_power'))}") + lines.append(f" Shared Voting: {_describe_count(p.reported('shared_voting_power'))}") + lines.append(f" Sole Dispositive: {_describe_count(p.reported('sole_dispositive_power'))}") + lines.append(f" Shared Dispositive: {_describe_count(p.reported('shared_dispositive_power'))}") return "\n".join(lines) @@ -777,15 +845,15 @@ def parse_xml(xml: str) -> dict: for person_el in find_all_elements(form_data, 'coverPageHeaderReportingPersonDetails'): # Get shares info shares_el = find_element(person_el, 'reportingPersonBeneficiallyOwnedNumberOfShares') - sole_voting = 0 - shared_voting = 0 - sole_disp = 0 - shared_disp = 0 + sole_voting = None + shared_voting = None + sole_disp = None + shared_disp = None if shares_el is not None: - sole_voting = safe_int(child_text(shares_el, 'soleVotingPower')) - shared_voting = safe_int(child_text(shares_el, 'sharedVotingPower')) - sole_disp = safe_int(child_text(shares_el, 'soleDispositivePower')) - shared_disp = safe_int(child_text(shares_el, 'sharedDispositivePower')) + sole_voting = child_text(shares_el, 'soleVotingPower') + shared_voting = child_text(shares_el, 'sharedVotingPower') + sole_disp = child_text(shares_el, 'soleDispositivePower') + shared_disp = child_text(shares_el, 'sharedDispositivePower') aggregate = child_text(person_el, 'reportingPersonBeneficiallyOwnedAggregateNumberOfShares') percent = child_text(person_el, 'classPercent') @@ -794,12 +862,7 @@ def parse_xml(xml: str) -> dict: cik='', # Not provided in 13G cover page name=child_text(person_el, 'reportingPersonName') or '', citizenship=child_text(person_el, 'citizenshipOrOrganization') or '', - sole_voting_power=sole_voting, - shared_voting_power=shared_voting, - sole_dispositive_power=sole_disp, - shared_dispositive_power=shared_disp, - aggregate_amount=safe_int(aggregate), - percent_of_class=safe_float(percent), + **_ownership_figures(sole_voting, shared_voting, sole_disp, shared_disp, aggregate, percent), type_of_reporting_person=child_text(person_el, 'typeOfReportingPerson') or '', fund_type=None, comment=None, @@ -1068,7 +1131,9 @@ def to_context(self, detail: str = 'standard') -> str: if self.security_info.title: lines.append(f"Security: {self.security_info.title}") if self.has_structured_data: - lines.append(f"Ownership: {self.total_percent:.1f}% ({self.total_shares:,} shares)") + percent = _reported_total(self.reporting_persons, 'percent_of_class') + shares = _reported_total(self.reporting_persons, 'aggregate_amount') + lines.append(f"Ownership: {_describe_percent(percent)} ({_describe_shares(shares)})") else: lines.append("Ownership: unavailable (pre-2025 HTML filing)") @@ -1103,7 +1168,8 @@ def to_context(self, detail: str = 'standard') -> str: lines.append("REPORTING PERSONS:") for p in self.reporting_persons[:5]: if self.has_structured_data: - p_line = f" {p.name}: {p.percent_of_class:.1f}% ({p.aggregate_amount:,} shares)" + p_line = (f" {p.name}: {_describe_percent(p.reported('percent_of_class'))}" + f" ({_describe_shares(p.reported('aggregate_amount'))})") else: p_line = f" {p.name}" if p.cik: @@ -1132,10 +1198,10 @@ def to_context(self, detail: str = 'standard') -> str: p = self.reporting_persons[0] lines.append("") lines.append(f"VOTING/DISPOSITIVE POWER ({p.name}):") - lines.append(f" Sole Voting: {p.sole_voting_power:,}") - lines.append(f" Shared Voting: {p.shared_voting_power:,}") - lines.append(f" Sole Dispositive: {p.sole_dispositive_power:,}") - lines.append(f" Shared Dispositive: {p.shared_dispositive_power:,}") + lines.append(f" Sole Voting: {_describe_count(p.reported('sole_voting_power'))}") + lines.append(f" Shared Voting: {_describe_count(p.reported('shared_voting_power'))}") + lines.append(f" Sole Dispositive: {_describe_count(p.reported('sole_dispositive_power'))}") + lines.append(f" Shared Dispositive: {_describe_count(p.reported('shared_dispositive_power'))}") return "\n".join(lines) diff --git a/tests/issues/regression/test_issue_1379_schedule13_unreported_figures.py b/tests/issues/regression/test_issue_1379_schedule13_unreported_figures.py new file mode 100644 index 000000000..a399a169d --- /dev/null +++ b/tests/issues/regression/test_issue_1379_schedule13_unreported_figures.py @@ -0,0 +1,417 @@ +"""Regression tests for OwnershipComparison against filings that do not report their figures. + +GitHub PR: https://github.com/dgunning/edgartools/pull/1379 + +OwnershipComparison added up ``aggregate_amount`` and ``percent_of_class`` over +``reporting_persons`` and read a figure the filing does not report as 0. Compared +with a filing that does report shares, that turned the whole position into a sale +or a purchase. Three ways a filing can leave the figures out: + +- a pre-2025 filing read from its SGML header (``has_structured_data`` is False), + whose reporting persons are identities only; +- structured XML with an empty ````; +- an amendment that omits them. The schema makes every figure on a reporting + person's cover page optional on 13D/A and 13G/A + (docs/sec/schedule13/schema-reference.md: "m" on 13D/13G, "o" on the + amendments), while the reporting-person row itself stays mandatory. + +Against the Aadi Bioscience 13D in the test data (2,100,000 + 2,435,000 shares, +8.5% + 9.9%), an amendment omitting its figures came back as shares_change +-4,535,000, percent_change -18.4 and is_liquidating True. Against the Jushi +Holdings 13G (two 10,000,000-share rows) it was -20,000,000. + +The parsers now record which figures a filing leaves out +(``ReportingPerson.unreported_fields``, ``ReportingPerson.reported()``) and the +comparison treats them as unknown: ``reported_shares_change`` and +``reported_percent_change`` are None and no direction flag is set. The 5.x +fields keep their types, so an unreported figure still holds the placeholder 0 +and ``shares_change``/``percent_change`` still return numbers, with a +FutureWarning that they become None in 6.0 (the 6.0 release plan stages value +changes additively in 5.x). A reported 0 stays 0: the controls below check that +selling out, a single-person sale and a 13G/A that does report its figures +still compare as numbers, without a warning. +""" +import re +import warnings +from datetime import date +from pathlib import Path +from unittest.mock import Mock + +import pytest + +from edgar.beneficial_ownership import Schedule13D, Schedule13G +from edgar.beneficial_ownership.amendments import OwnershipComparison +from edgar.exceptions import ValidationError + +OWNERSHIP_FIGURES = {'sole_voting_power', 'shared_voting_power', 'sole_dispositive_power', + 'shared_dispositive_power', 'aggregate_amount', 'percent_of_class'} +TEST_DATA_DIR = Path(__file__).parent.parent.parent / "data" / "beneficial_ownership" +SCHEDULE_13D_XML_PATH = TEST_DATA_DIR / "schedule13d.xml" +SCHEDULE_13G_XML_PATH = TEST_DATA_DIR / "schedule13g.xml" + + +def _schedule13d_from_xml(xml_content, form='SCHEDULE 13D/A', filing_date=date(2024, 12, 31)): + filing = Mock() + filing.form = form + filing.filing_date = filing_date + filing.xml = Mock(return_value=xml_content) + return Schedule13D.from_filing(filing) + + +def _schedule13g_from_xml(xml_content, form='SCHEDULE 13G/A', filing_date=date(2025, 12, 31)): + filing = Mock() + filing.form = form + filing.filing_date = filing_date + filing.xml = Mock(return_value=xml_content) + return Schedule13G.from_filing(filing) + + +def _header_only_schedule13d(): + """A pre-2025 filing with no XML: identities from the SGML header, no numbers.""" + filer = Mock() + filer.company_information.name = 'BML Investment Partners, L.P.' + filer.company_information.cik = '0001373604' + subject = Mock() + subject.company_information.name = 'Aadi Bioscience, Inc.' + subject.company_information.cik = '0001422142' + filing = Mock() + filing.form = 'SC 13D' + filing.filing_date = date(2021, 3, 1) + filing.xml = Mock(return_value=None) + filing.header = Mock(filers=[filer], subject_companies=[subject]) + return Schedule13D.from_filing(filing) + + +def _assert_change_unknown(comparison, placeholder_shares_change): + """No purchase, sale or "unchanged" is read into a comparison with unreported figures.""" + assert comparison.is_accumulating is False + assert comparison.is_liquidating is False + assert comparison.is_unchanged is False + assert comparison.reported_shares_change is None + assert comparison.reported_percent_change is None + # 5.x keeps the numeric properties, counting an unreported figure as 0, and says so. + with pytest.warns(FutureWarning, match=r'shares_change .* None in edgartools 6\.0'): + assert comparison.shares_change == placeholder_shares_change + with pytest.warns(FutureWarning, match=r'percent_change .* None in edgartools 6\.0'): + assert isinstance(comparison.percent_change, float) + + +@pytest.mark.fast +def test_ownership_comparison_against_header_only_filing_is_unknown(): + """A header-only filing has no share counts, so there is no change to report. + + Its reporting persons carry placeholder zeros. Adding those up reported the + structured filing's whole position as bought (or sold, the other way round). + """ + header_only = _header_only_schedule13d() + structured = _schedule13d_from_xml(SCHEDULE_13D_XML_PATH.read_text()) + assert header_only.has_structured_data is False + + _assert_change_unknown(OwnershipComparison(current=structured, previous=header_only), 4_535_000) + _assert_change_unknown(OwnershipComparison(current=header_only, previous=structured), -4_535_000) + + person = header_only.reporting_persons[0] + assert person.unreported_fields == OWNERSHIP_FIGURES + assert person.reported('aggregate_amount') is None + + with pytest.warns(FutureWarning): + summary = OwnershipComparison(current=structured, previous=header_only).get_summary() + assert summary['reported_shares_change'] is None + assert summary['reported_percent_change'] is None + assert summary['is_accumulating'] is False + + +@pytest.mark.fast +def test_ownership_comparison_without_reporting_person_rows_is_unknown(): + """Structured XML with an empty has no share counts either.""" + xml_content = SCHEDULE_13D_XML_PATH.read_text() + no_rows_xml = re.sub(r'.*?', + '', xml_content, flags=re.S) + no_rows = _schedule13d_from_xml(no_rows_xml) + structured = _schedule13d_from_xml(xml_content) + assert no_rows.has_structured_data is True + assert no_rows.reporting_persons == [] + + _assert_change_unknown(OwnershipComparison(current=no_rows, previous=structured), -4_535_000) + _assert_change_unknown(OwnershipComparison(current=structured, previous=no_rows), 4_535_000) + + +@pytest.mark.fast +def test_unreported_figures_keep_their_5x_types(): + """Control: code written against 5.x keeps getting numbers until 6.0. + + Formatting a figure of a header-only filing, or a change against it, printed + 0 and a number before this fix and must not raise TypeError now. Only the new + accessors answer None. + """ + header_only = _header_only_schedule13d() + structured = _schedule13d_from_xml(SCHEDULE_13D_XML_PATH.read_text()) + person = header_only.reporting_persons[0] + assert format(person.aggregate_amount, ',') == '0' + assert f"{person.percent_of_class:.2f}%" == '0.00%' + assert person.total_voting_power == 0 + assert person.total_dispositive_power == 0 + + with warnings.catch_warnings(): + warnings.simplefilter('ignore', FutureWarning) + comparison = OwnershipComparison(current=structured, previous=header_only) + assert f"{comparison.shares_change:+,}" == '+4,535,000' + assert f"{comparison.percent_change:+.1f}" == '+18.4' + summary = comparison.get_summary() + assert summary['previous_shares'] == 0 + assert summary['current_shares'] == 4_535_000 + + +@pytest.mark.fast +def test_ownership_comparison_reported_zero_is_still_a_number(): + """Control: a row that reports 0 shares is data: selling out is a change, and 0 to 0 is unchanged.""" + xml_content = SCHEDULE_13D_XML_PATH.read_text() + zero_xml = re.sub(r'\d+', + '0', xml_content) + zero_xml = re.sub(r'[\d.]+', + '0', zero_xml) + sold_out = _schedule13d_from_xml(zero_xml) + structured = _schedule13d_from_xml(xml_content) + assert [p.aggregate_amount for p in sold_out.reporting_persons] == [0, 0] + + with warnings.catch_warnings(): + warnings.simplefilter('error', FutureWarning) + comparison = OwnershipComparison(current=sold_out, previous=structured) + summary = comparison.get_summary() + assert summary['current_shares'] == 0 + assert summary['current_percent'] == 0 + assert comparison.shares_change == -summary['previous_shares'] < 0 + assert comparison.percent_change < 0 + assert comparison.is_liquidating is True + + unchanged = OwnershipComparison(current=sold_out, previous=_schedule13d_from_xml(zero_xml)) + assert unchanged.shares_change == 0 + assert unchanged.percent_change == 0 + assert unchanged.is_unchanged is True + + +@pytest.mark.fast +def test_ownership_comparison_single_person_change(): + """Control: one reporting person selling 500,000 shares.""" + xml_content = SCHEDULE_13D_XML_PATH.read_text() + one_person = re.sub(r'\s*0001373603.*?', + '', xml_content, flags=re.S) + after_sale = one_person.replace('2100000', + '1600000') + after_sale = after_sale.replace('8.5', + '6.5') + previous = _schedule13d_from_xml(one_person) + current = _schedule13d_from_xml(after_sale) + assert len(previous.reporting_persons) == 1 + + with warnings.catch_warnings(): + warnings.simplefilter('error', FutureWarning) + comparison = OwnershipComparison(current=current, previous=previous) + assert comparison.shares_change == -500_000 + assert comparison.percent_change == pytest.approx(-2.0) + assert comparison.is_liquidating is True + assert comparison.is_accumulating is False + + +# On an amendment the schema makes every ownership figure on a reporting person's +# cover page optional (docs/sec/schedule13/schema-reference.md: "m" on 13D/13G, +# "o" on 13D/A and 13G/A). The reporting-person row itself stays mandatory. +_13D_FIGURES = ('soleVotingPower', 'sharedVotingPower', 'soleDispositivePower', + 'sharedDispositivePower', 'aggregateAmountOwned', 'percentOfClass') +_13G_FIGURES = ('soleVotingPower', 'sharedVotingPower', 'soleDispositivePower', + 'sharedDispositivePower', 'reportingPersonBeneficiallyOwnedAggregateNumberOfShares', + 'classPercent') + + +def _amendment_without_figures(xml_content, form, figures): + """Turn an original filing's XML into an amendment that reports no ownership figures. + + Adds the elements an amendment requires (previousAccessionNumber, amendmentNo) + and drops the reporting-person figures an amendment may omit. The 13G Item 4 + classPercent is not a reporting-person figure and is left alone. + """ + xml_content = xml_content.replace(f'{form}', + f'{form}/A' + '0000000000-24-000001', 1) + xml_content = xml_content.replace('', '1', 1) + persons_tag = 'reportingPersons' if form == 'SCHEDULE 13D' else 'coverPageHeaderReportingPersonDetails' + persons = re.compile(rf'<{persons_tag}>.*?', re.S) + figure = re.compile(r'<(%s)>[^<]*' % '|'.join(figures)) + + def drop_figures(match): + return figure.sub('', match.group(0)) + + amended = persons.sub(drop_figures, xml_content) + # Both reporting persons remain; the source reports every figure for both, the amendment none. + def tags_in_persons(xml, tag): + return sum(block.count(f'<{tag}>') for block in persons.findall(xml)) + + assert tags_in_persons(xml_content, 'reportingPersonName') == tags_in_persons(amended, 'reportingPersonName') == 2 + for tag in figures: + assert tags_in_persons(xml_content, tag) == 2 + assert tags_in_persons(amended, tag) == 0 + return amended + + +@pytest.mark.fast +@pytest.mark.parametrize('schedule_class,xml_path,form,figures', [ + (Schedule13D, SCHEDULE_13D_XML_PATH, 'SCHEDULE 13D', _13D_FIGURES), + (Schedule13G, SCHEDULE_13G_XML_PATH, 'SCHEDULE 13G', _13G_FIGURES), +]) +def test_amendment_omitting_ownership_figures_marks_them_unreported(schedule_class, xml_path, form, figures): + """A figure the amendment leaves out was not reported: reported() is None, not 0.""" + amended_xml = _amendment_without_figures(xml_path.read_text(), form, figures) + filing = Mock(form=f'{form}/A', filing_date=date(2025, 12, 31), xml=Mock(return_value=amended_xml)) + amendment = schedule_class.from_filing(filing) + + assert amendment.has_structured_data is True + assert amendment.is_amendment is True + assert len(amendment.reporting_persons) == 2 + for person in amendment.reporting_persons: + assert person.name + assert person.unreported_fields == OWNERSHIP_FIGURES + for field_name in OWNERSHIP_FIGURES: + assert person.reported(field_name) is None + assert getattr(person, field_name) == 0 # the 5.x placeholder, None in 6.0 + + # The display paths show the gap instead of a zero. + context = amendment.to_context(detail='full') + assert 'percent not reported (shares not reported)' in context + assert '(0 shares)' not in context and 'Sole Voting: 0' not in context + from rich.console import Console + console = Console(record=True, width=200) + console.print(amendment) + rendered = console.export_text() + assert 'unavailable' in rendered + assert re.search(r'Total Shares:\s+unavailable', rendered) + + +@pytest.mark.fast +@pytest.mark.parametrize('schedule_class,xml_path,form,shares_tag,expected_rows', [ + (Schedule13D, SCHEDULE_13D_XML_PATH, 'SCHEDULE 13D/A', 'aggregateAmountOwned', { + 'BML Investment Partners, L.P.': ['unavailable', '8.50%', 'unavailable', '2,100,000'], + 'Leonard Braden Michael': ['2,435,000', '9.90%', '2,435,000', '2,435,000'], + }), + (Schedule13G, SCHEDULE_13G_XML_PATH, 'SCHEDULE 13G/A', 'reportingPersonBeneficiallyOwnedAggregateNumberOfShares', { + 'Marex Securities Products Inc.': ['unavailable', '5.10%', 'unavailable', '10,000,000'], + 'Marex Group plc': ['10,000,000', '5.10%', '10,000,000', '10,000,000'], + }), +]) +def test_rich_persons_table_shows_unreported_figures_as_unavailable(schedule_class, xml_path, form, + shares_tag, expected_rows): + """Each cell of the persons table stands for one person's figure, reported or not. + + The first person leaves out its shares and its shared voting power. Its Shares + and Voting Power cells say "unavailable" rather than the placeholder 0 (or the + sole voting power alone), while its reported percent and dispositive power, and + every figure of the second person, still print as numbers. + """ + xml_content = xml_path.read_text() + for tag in (shares_tag, 'sharedVotingPower'): + xml_content = re.sub(rf'<{tag}>[^<]*', '', xml_content, count=1) + filing = Mock(form=form, filing_date=date(2025, 12, 31), xml=Mock(return_value=xml_content)) + schedule = schedule_class.from_filing(filing) + first = schedule.reporting_persons[0] + assert first.unreported_fields == {'aggregate_amount', 'shared_voting_power'} + + from rich.console import Console + console = Console(record=True, width=200) + console.print(schedule) + lines = console.export_text().splitlines() + for name, cells in expected_rows.items(): + row = next(line for line in lines if re.match(rf'│\s+{re.escape(name)}\s', line)) + # Shares, Percent, Voting Power and Dispositive Power are the last four cells. + assert row.strip('│ ').split()[-4:] == cells + + +@pytest.mark.fast +def test_reported_figures_are_not_marked(): + """Control: the original filings report every figure, including a reported 0.""" + schedule_13d = _schedule13d_from_xml(SCHEDULE_13D_XML_PATH.read_text(), form='SCHEDULE 13D') + schedule_13g = _schedule13g_from_xml(SCHEDULE_13G_XML_PATH.read_text(), form='SCHEDULE 13G') + for person in schedule_13d.reporting_persons + schedule_13g.reporting_persons: + assert person.unreported_fields == frozenset() + bml = schedule_13d.reporting_persons[0] + assert bml.reported('sole_voting_power') == 0 + assert bml.reported('aggregate_amount') == 2_100_000 + assert bml.reported('percent_of_class') == 8.5 + with pytest.raises(ValidationError): + bml.reported('cik') + + +@pytest.mark.fast +def test_one_person_omitting_shares_leaves_percent_known(): + """Share and percent availability are independent, person by person.""" + xml_content = SCHEDULE_13D_XML_PATH.read_text() + original = _schedule13d_from_xml(xml_content, form='SCHEDULE 13D') + partial_xml = xml_content.replace('2100000', '', 1) + partial = _schedule13d_from_xml(partial_xml, filing_date=date(2025, 3, 31)) + bml, leonard = partial.reporting_persons + assert bml.unreported_fields == {'aggregate_amount'} + assert bml.reported('aggregate_amount') is None + assert leonard.reported('aggregate_amount') == 2_435_000 + + comparison = OwnershipComparison(current=partial, previous=original) + assert comparison.reported_shares_change is None + assert comparison.reported_percent_change == 0.0 + assert comparison.is_liquidating is False + assert 'Ownership: 9.9% (shares not reported)' in partial.to_context() + + +@pytest.mark.fast +def test_ownership_comparison_13d_amendment_omitting_figures_is_unknown(): + """A 13D/A that leaves out the share figures is not a sale of the whole position. + + Summing the parsed rows read the omitted figures as 0, so against the Aadi + Bioscience 13D (4,535,000 shares over two rows) the amendment came out as a + 4,535,000-share sale, -18.4 points, and is_liquidating True. + """ + original_xml = SCHEDULE_13D_XML_PATH.read_text() + original = _schedule13d_from_xml(original_xml, form='SCHEDULE 13D', filing_date=date(2024, 12, 31)) + amendment = _schedule13d_from_xml(_amendment_without_figures(original_xml, 'SCHEDULE 13D', _13D_FIGURES), + filing_date=date(2025, 3, 31)) + assert amendment.has_structured_data is True + assert len(amendment.reporting_persons) == 2 + + _assert_change_unknown(OwnershipComparison(current=amendment, previous=original), -4_535_000) + _assert_change_unknown(OwnershipComparison(current=original, previous=amendment), 4_535_000) + + with pytest.warns(FutureWarning): + summary = OwnershipComparison(current=amendment, previous=original).get_summary() + assert summary['is_liquidating'] is False + assert summary['reported_shares_change'] is None + assert summary['reported_percent_change'] is None + + +@pytest.mark.fast +def test_ownership_comparison_13g_amendment_omitting_figures_is_unknown(): + """The same for a 13G/A against the Jushi Holdings 13G (two 10,000,000-share rows).""" + original_xml = SCHEDULE_13G_XML_PATH.read_text() + original = _schedule13g_from_xml(original_xml, form='SCHEDULE 13G', filing_date=date(2025, 11, 26)) + amendment = _schedule13g_from_xml(_amendment_without_figures(original_xml, 'SCHEDULE 13G', _13G_FIGURES)) + assert amendment.has_structured_data is True + assert len(amendment.reporting_persons) == 2 + + _assert_change_unknown(OwnershipComparison(current=amendment, previous=original), -20_000_000) + _assert_change_unknown(OwnershipComparison(current=original, previous=amendment), 20_000_000) + + +@pytest.mark.fast +def test_ownership_comparison_13g_amendment_reporting_figures(): + """Control: a 13G/A that does report its figures still compares as numbers.""" + original_xml = SCHEDULE_13G_XML_PATH.read_text() + original = _schedule13g_from_xml(original_xml, form='SCHEDULE 13G', filing_date=date(2025, 11, 26)) + amended_xml = original_xml.replace( + '10000000.00', + '8000000.00') + amended_xml = amended_xml.replace('5.1', '4.1') + amendment = _schedule13g_from_xml(amended_xml) + assert [p.aggregate_amount for p in amendment.reporting_persons] == [8_000_000, 8_000_000] + + with warnings.catch_warnings(): + warnings.simplefilter('error', FutureWarning) + comparison = OwnershipComparison(current=amendment, previous=original) + assert comparison.shares_change == -4_000_000 + assert comparison.percent_change == pytest.approx(-2.0) + assert comparison.is_liquidating is True + assert amendment.total_shares == 8_000_000