Skip to content

fix(schedule13): no sale or purchase from ownership figures a filing leaves out - #1379

Merged
dgunning merged 4 commits into
dgunning:mainfrom
monody0007:oss-pilot/schedule13-missing-data
Sep 30, 2026
Merged

dgunning merged 4 commits into
dgunning:mainfrom
monody0007:oss-pilot/schedule13-missing-data

Conversation

@monody0007

@monody0007 monody0007 commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Problem

OwnershipComparison added up aggregate_amount and percent_of_class over reporting_persons, and the Schedule 13D/G parsers stored a figure the filing does not report as 0. When one of the two filings leaves its figures out, the other filing's whole position shows up as bought or sold. A filing can leave them out in three ways:

  • Amendments. 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). The reporting-person row itself is still mandatory. safe_int() / safe_float() turned an absent element into 0.
  • Pre-2025 header-only filings (has_structured_data is False). Their reporting persons carry placeholder zeros.
  • Structured XML with an empty <reportingPersons>.

Reproduction with the fixtures in tests/data/beneficial_ownership/. Turn the Aadi Bioscience 13D (2,100,000 + 2,435,000 shares) into a 13D/A: add previousAccessionNumber and amendmentNo, and drop the six optional figures from both reporting persons. Compared with the original, main returns shares_change == -4535000, percent_change == -18.4 and is_liquidating == True. The same change to the Jushi Holdings 13G (two 10,000,000-share rows) gives -20000000.

Why this is staged for 5.x

The simplest fix would make these values None. But that changes public numeric types, and engineering/6.0-release-plan.md ("Branching and release model") says main keeps shipping 5.x with fixes that don't break anyone. Incompatible changes wait for the 6.0 window, and each item ships an additive half in 5.x first. The same approach was used for FactQuery.to_dataframe(), where fiscal_year stays int64 until 6.0 and is listed under Still to come in docs/upgrade/6.0.md. So this PR ships the additive half and leaves every existing field and property with the type it has on main.

An earlier revision of this PR made the fields Optional right away. The previous head (fad3358) also made shares_change / percent_change return None for header-only filings. This revision drops both type changes.

Change

  • ReportingPerson.unreported_fields (new, frozenset[str]) names the ownership figures the filing leaves out. The parsers fill it for absent or non-numeric elements. A header-only person gets all six.
  • ReportingPerson.reported(figure) (new) returns the figure, or None if the filing leaves it out. A reported 0 returns 0. An unknown field name raises ValidationError.
  • The six fields keep their int / float types and still hold the placeholder 0. The same goes for total_voting_power, total_dispositive_power, total_shares and total_percent.
  • OwnershipComparison.reported_shares_change / reported_percent_change (new) return None when either filing has no rows, or when any reporting person leaves out that figure. Shares and percent are checked separately.
  • is_accumulating, is_liquidating, is_unchanged now use those new properties, so an unknown change sets none of them. They stay bool. This is the fix for the fabricated sale or purchase.
  • shares_change, percent_change, get_summary() still return the same numbers as on main. When they count an unreported figure as 0, they now emit a FutureWarning saying that 6.0 returns None and naming the reported_* replacement. The warning text is fixed, so Python's default filter shows it once per call site. get_summary() also gains the keys reported_shares_change and reported_percent_change.
  • Display: to_context() prints "not reported" and the Rich tables print "unavailable" for unreported figures, instead of 0.
  • Typing: safe_int() / safe_float() get typing.overload signatures. The parsers call them with default=None to tell a missing figure from a reported 0, which the old int / float annotations did not allow. The default call still returns int / float. There is no runtime change.
  • Unchanged: a reported 0 is still 0 everywhere. Rows are still summed, so aggregation for joint filers whose rows overlap is not fixed here. The guide already says so.

Docs and changelog:

  • changelog.d/1379.fixed.md covers the direction flags and the warning.
  • changelog.d/1379.added.md covers the new accessors.
  • docs/upgrade/6.0.md gets one Still to come row for the 6.0 flip: the fields, totals, shares_change, percent_change and the summary values become None. Following that page's rule, nothing is added above that table, because nothing in this PR changes what existing code receives.
  • docs/guides/schedule13dg-data-object-guide.md documents the new accessors and shows a None-safe comparison example.

The regression tests are in tests/issues/regression/test_issue_1379_schedule13_unreported_figures.py, with the provenance line CONTRIBUTING asks for. The tests this PR had added to tests/beneficial_ownership/test_beneficial_ownership.py moved there, so that file is unchanged from main.

Tests

All tests run offline with -p tests._offline_harness, which blocks sockets, and use the local fixtures. There are no cassettes and no SEC calls. The amendment fixtures are built from the existing XML to match the table in the repo's schema reference. They were not validated against the SEC XSD, which is not in the repository.

pytest -p tests._offline_harness -p no:pytest-retry -v tests/issues/regression/test_issue_1379_schedule13_unreported_figures.py
  • This branch: 14 passed (12 functions; two are parametrised over 13D and 13G).

  • Same test file against edgar/ from main (73491ae): 10 failed, 4 passed. Four failures are assert True is False on the direction flags: the header-only, no-rows, 13D/A and 13G/A comparisons report a purchase or sale. The other six are the new accessors being absent.

  • test_rich_persons_table_shows_unreported_figures_as_unavailable checks every cell of the Rich persons table. One person leaves out its shares and shared voting power, and the other person reports every figure. The earlier tests only checked the Total Shares: line. Two mutations of rendering.py now fail both cases (13D and 13G), where before they passed every test:

    • putting the per-person cells back to the raw fields gives '0' != 'unavailable';
    • counting a missing sole or shared power as 0 in the Voting Power cell gives '0' / '10,000,000' != 'unavailable'.
  • Before the latest commit, the test file had 12 cases. Against the earlier head of this PR (fad3358), those 12 cases gave 9 failed, 3 passed. The 13D/A and 13G/A comparisons still fail is_liquidating is False (assert True is False). test_unreported_figures_keep_their_5x_types fails with TypeError: unsupported format string passed to NoneType.__format__, because f"{comparison.shares_change:+,}" against a header-only filing gets None. The rest are the new accessors being absent.

  • Controls that pass on main, on fad3358 and on this branch:

    • an explicit zero (selling out is still -4,535,000, and 0 → 0 is unchanged);
    • a single-person sale of 500,000;
    • a 13G/A that reports its figures (−4,000,000, −2.0 points).

    The three controls also assert that no FutureWarning is raised when every figure is reported.

  • test_unreported_figures_keep_their_5x_types also passes on main. It checks that format(person.aggregate_amount, ',') and f"{comparison.shares_change:+,}" still work for a header-only filing.

pytest -p tests._offline_harness -p no:pytest-retry -q -m 'not network' tests/beneficial_ownership \
  tests/issues/regression/test_issue_802_schedule13_cusip_nested.py \
  tests/issues/regression/test_issue_804_schedule13_event_date_alias.py \
  tests/issues/regression/test_issue_840_schedule13_partial_object.py \
  tests/issues/regression/test_issue_1379_schedule13_unreported_figures.py

57 passed, 1 deselected. The deselected test is the network test test_aapl_pre_mandate_sc13g_ground_truth, which was not run.

Other checks:

  • TestSchedule13DToContext in tests/display/test_to_context.py needs the SEC, so it was not run.
  • ruff check on the touched files reports the same three findings as on main (S110 twice, F841) and none new.
  • pyright (1.1.414) on edgar/beneficial_ownership/schedule13.py: the two default=None argument errors on safe_int / safe_float are gone, and there are no new errors. pyright is not run in CI.
  • scripts/check_regression_provenance.py: OK, 402 files. scripts/check_regression_skips.py: OK.
  • scripts/release/assemble_changelog.py --check: 6 fragments ready.

Prepared with AI assistance (Claude); the results above come from local test runs.

monody0007 and others added 3 commits September 28, 2026 10:18
…e sum of rows

OwnershipComparison.shares_change, percent_change and get_summary() added
up aggregate_amount and percent_of_class across every reporting person.
The reporting persons in one 13D/G restate overlapping parts of the same
position, which is why Schedule13D/13G.total_shares and total_percent were
moved from sum() to max() in 342ec91. The comparison kept the sum, so a
joint filing's change was inflated by roughly the number of filers, and a
filer joining or leaving the group could flip its direction.

Icahn Enterprises 13D/A 0001539497-26-002605 (2026-09-25, 10 reporting
persons) names 0001539497-26-001890 (2026-06-29, 9 persons) as its
previousAccessionNumber. Carl C. Icahn's row, the top of the control chain,
went from 618,393,343 units (87.28%) to 658,924,537 (87.69%). The
comparison reported +203,038,395 units and +11.00 points, from row sums of
1,987,304,001 (280.49%) and 2,190,342,396 (291.49%).

A pre-2025 header-only filing (has_structured_data False) carries
placeholder zeros, and its total_shares/total_percent are None. Summing
those zeros made a structured amendment compared against it report the
whole position as newly accumulated: against IEP's SC 13D/A
0001539497-24-002049 the change came back as +2,190,342,396 units and
+291.49 points. The change is now None when either side has no structured
data, and is_accumulating/is_liquidating/is_unchanged are all False.

The two IEP primary_doc.xml files are saved verbatim from SEC as fixtures.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…s data

Rescopes the previous commit. That commit switched OwnershipComparison
to total_shares, which takes the largest reporting-person row. An
independent review found a real 13D/A pair (K Wave Media, accessions
0001829126-25-008079 -> 0001829126-26-000596) whose five reporting
persons hold separate blocks: the base sum gives the right -13,445,110
shares and max() gives -1,534,482. No single rule covers both
overlapping and separate joint filers, so this commit restores the
existing sum of rows and changes only the missing-data case.

A pre-2025 header-only filing (has_structured_data False) carries its
reporting persons with placeholder zeros, and structured XML with an
empty <reportingPersons> has no rows. Both were read as holding zero
shares, so comparing either with a real filing reported the other
filing's whole position as bought or sold (4,535,000 shares for the
Aadi Bioscience test fixture). shares_change and percent_change are now
None when either side has no reporting-person data, the direction flags
are all False, and get_summary() reports None for that side. A row that
reports 0 is still a number.

The guide's example guards the Optional value before formatting it, and
states that joint filings are still summed row by row.

The Icahn fixtures and max-based tests from the previous commit are
removed from the tree; they stay in its history.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…leaves out

OwnershipComparison summed aggregate_amount and percent_of_class over the
reporting persons, and the parsers read a figure the filing does not report
as 0. The schema makes every cover-page ownership figure optional on 13D/A
and 13G/A, a pre-2025 filing read from its SGML header has identities only,
and a filing can have no reporting-person rows. Against the Aadi Bioscience
13D in the test data (2,100,000 + 2,435,000 shares), an amendment omitting
its figures compared as a 4,535,000-share sale with is_liquidating True;
against the Jushi Holdings 13G it was a 20,000,000-share sale.

The 6.0 release plan ships 5.x from main and keeps value and type changes for
the 6.0 window, staging an additive half first. This change is that half:

- ReportingPerson.unreported_fields names the figures a filing leaves out,
  and ReportingPerson.reported(figure) returns None for them. The fields keep
  their int/float types and the placeholder 0.
- OwnershipComparison.reported_shares_change and reported_percent_change are
  None when either filing leaves the figure out for any reporting person.
  is_accumulating, is_liquidating and is_unchanged use them, so an unknown
  change sets no direction flag.
- shares_change, percent_change and get_summary() keep returning numbers and
  emit a FutureWarning when they read an unreported figure as 0.
- to_context() and the rich display show unreported figures as not reported
  or unavailable instead of 0.

The 6.0 flip (the fields, totals and shares_change/percent_change become
None) is listed under Still to come in docs/upgrade/6.0.md. A reported 0
stays 0 throughout.

The PR's earlier regression tests in tests/beneficial_ownership move to
tests/issues/regression/test_issue_1379_schedule13_unreported_figures.py.
@monody0007 monody0007 changed the title fix(schedule13): avoid fabricated ownership changes for filings without holdings data fix(schedule13): no sale or purchase from ownership figures a filing leaves out Sep 29, 2026
The regression tests checked only the "Total Shares: unavailable" line of the
rich display. Putting the per-person Shares, Percent, Voting Power and
Dispositive Power cells back to the raw fields (placeholder 0) left every
test passing. The new test drops one person's shares and shared voting power
from the 13D and 13G test data and asserts each cell of both rows: the two
figures left out read "unavailable", and the reported ones stay numbers.

_ownership_figures() calls safe_int() and safe_float() with default=None to
tell a missing figure from a reported 0, but both were annotated to take and
return only int/float. They now have overloads: the default call still
returns int/float, and default=None returns Optional. No runtime change.
@dgunning
dgunning merged commit 18de60a into dgunning:main Sep 30, 2026
11 checks passed
dgunning added a commit that referenced this pull request Sep 30, 2026
… display as not reported (#1384)

Follow-up to #1379.

get_summary() read self.shares_change and self.percent_change, so it emitted
two FutureWarnings whose stacklevel landed inside amendments.py. Python's
default filter keys on that location, so the warning showed once per process
and never named the user's call site. get_summary() now computes the 5.x
values directly and emits one warning of its own at the caller's line.

_reported_total() returned max([], default=0) for a filing with no
reporting-person rows, so to_context() and the Rich tables showed 0 shares
while OwnershipComparison treated the same filing as unknown. It now returns
None, and the display says "not reported".

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
dgunning added a commit that referenced this pull request Sep 30, 2026
…uide (07lk.14) (#1388)

#1200/#1201 shipped the edgar.files deprecations in 5.55.0 with no
docs/upgrade/6.0.md entry, and nothing mechanical noticed. A CHANGELOG entry
gets written by habit; an upgrade-guide entry does not.

scripts/check_upgrade_guide.py reads the PR's added lines in edgar/ (from the
pull-request files API, since the checkout is shallow) and fails when they
stage a 6.0 change -- warn_will_raise(), a *deprecat*( helper,
warn_legacy_html_usage(), DeprecationWarning/FutureWarning, "removed/deprecated
in [edgartools] [v]6.0", or a new deprecated/removed changelog fragment --
without the PR also touching docs/upgrade/6.0.md. Comments and defs are
ignored, and staging markers are netted across the PR, so moving or
restructuring an existing warning, or deleting one in the 6.0 window, passes.
The `no-upgrade-guide` label skips it visibly.

Replayed over the 265 commits merged since the guide was created: 10 staged
and updated the guide (pass); 5 fail. Four are real misses -- #1034, #1201,
2464a53 (effect/form144 moves), 4e31aed (FilingHomepage soup deprecation,
still absent from the guide). The fifth, #1384, added a warning site for a
change #1379 had just documented, the label's case. Netting is what cleared
#1037. The first two trigger rules (generic helper name, "v6.0" spelling) were
widened after the replay showed #1201 and 4e31aed slipping through.

Runs as a step in test-fast, the required context, like the other source
gates. Adds .github/pull_request_template.md: the Definition of Done
checklist, the 6.0 guide item, and an optional working-context section.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
dgunning added a commit that referenced this pull request Oct 1, 2026
…egation_basis (qsk4) (#1400)

OwnershipComparison summed aggregate_amount / percent_of_class over every
reporting person, including rows flagged is_aggregate_exclude_shares, while
total_shares / total_percent and _reported_total take the max over the
unflagged persons: one rule, two implementations, disagreeing twice. In a
control chain (fund, GP, manager, individual each reporting the same
position) the sum counts one position once per person; the Jushi 13G
fixture's two 10,000,000-share rows compared as 20,000,000.

The comparison now goes through models._reported_total (reported_*) and
an unreported-as-0 max (shares_change, percent_change, get_summary). The
reported_* accessors are unreleased (#1379, after 5.59.1); shares_change's
numbers change in 5.x as a correctness fix.

The "max() is always correct" docstrings were false. Sampled joint
filings: most rows restate one position, but GAMCO/Nevro
(0000807249-25-000053) sub-advisers hold separate accounts, 1,322,950 by
max vs 2,123,900 stated in Item 5(a), and the 33-person ZIM group
(0001178913-25-004165) matches neither sum nor max. No structured field
says which rows roll up into which, so a new aggregation_basis reports the
shape: 'single', 'identical', 'parent_sums_children' (largest row = sum of
the distinct smaller ones; Blackstone 0000950170-25-090888 4,782,781 =
2,989,238 + 1,793,543) or 'ambiguous'. On 60 joint filings from 2025Q3:
13D 15 identical / 13 ambiguous / 2 single; 13G 23 identical / 3 parent /
4 ambiguous. to_context() flags ambiguous totals. The guide's "Joint vs.
Separate Filers" section described member_of_group / >100% / identical-
count detection that was never implemented; rewritten. The Item 5(a)
stated total is bead edgartools-vazz.

1379's tests pinned the summed values; updated to the max (Aadi 4,535,000
-> 2,435,000, Jushi 20,000,000 -> 10,000,000).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@dgunning dgunning mentioned this pull request Oct 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants