fix(schedule13): no sale or purchase from ownership figures a filing leaves out - #1379
Merged
dgunning merged 4 commits intoSep 30, 2026
Merged
Conversation
…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.
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
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>
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
OwnershipComparisonadded upaggregate_amountandpercent_of_classoverreporting_persons, and the Schedule 13D/G parsers stored a figure the filing does not report as0. 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:docs/sec/schedule13/schema-reference.md:mon 13D/13G,oon the amendments). The reporting-person row itself is still mandatory.safe_int()/safe_float()turned an absent element into0.has_structured_dataisFalse). Their reporting persons carry placeholder zeros.<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: addpreviousAccessionNumberandamendmentNo, and drop the six optional figures from both reporting persons. Compared with the original,mainreturnsshares_change == -4535000,percent_change == -18.4andis_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, andengineering/6.0-release-plan.md("Branching and release model") saysmainkeeps 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 forFactQuery.to_dataframe(), wherefiscal_yearstaysint64until 6.0 and is listed under Still to come indocs/upgrade/6.0.md. So this PR ships the additive half and leaves every existing field and property with the type it has onmain.An earlier revision of this PR made the fields
Optionalright away. The previous head (fad3358) also madeshares_change/percent_changereturnNonefor 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, orNoneif the filing leaves it out. A reported0returns0. An unknown field name raisesValidationError.int/floattypes and still hold the placeholder0. The same goes fortotal_voting_power,total_dispositive_power,total_sharesandtotal_percent.OwnershipComparison.reported_shares_change/reported_percent_change(new) returnNonewhen 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_unchangednow use those new properties, so an unknown change sets none of them. They staybool. This is the fix for the fabricated sale or purchase.shares_change,percent_change,get_summary()still return the same numbers as onmain. When they count an unreported figure as0, they now emit aFutureWarningsaying that 6.0 returnsNoneand naming thereported_*replacement. The warning text is fixed, so Python's default filter shows it once per call site.get_summary()also gains the keysreported_shares_changeandreported_percent_change.to_context()prints "not reported" and the Rich tables print "unavailable" for unreported figures, instead of0.safe_int()/safe_float()gettyping.overloadsignatures. The parsers call them withdefault=Noneto tell a missing figure from a reported0, which the oldint/floatannotations did not allow. The default call still returnsint/float. There is no runtime change.0is still0everywhere. 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.mdcovers the direction flags and the warning.changelog.d/1379.added.mdcovers the new accessors.docs/upgrade/6.0.mdgets one Still to come row for the 6.0 flip: the fields, totals,shares_change,percent_changeand the summary values becomeNone. 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.mddocuments the new accessors and shows aNone-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 totests/beneficial_ownership/test_beneficial_ownership.pymoved there, so that file is unchanged frommain.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.This branch: 14 passed (12 functions; two are parametrised over 13D and 13G).
Same test file against
edgar/frommain(73491ae): 10 failed, 4 passed. Four failures areassert True is Falseon 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_unavailablechecks 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 theTotal Shares:line. Two mutations ofrendering.pynow fail both cases (13D and 13G), where before they passed every test:'0' != 'unavailable';0in 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_typesfails withTypeError: unsupported format string passed to NoneType.__format__, becausef"{comparison.shares_change:+,}"against a header-only filing getsNone. The rest are the new accessors being absent.Controls that pass on
main, on fad3358 and on this branch:-4,535,000, and 0 → 0 is unchanged);The three controls also assert that no
FutureWarningis raised when every figure is reported.test_unreported_figures_keep_their_5x_typesalso passes onmain. It checks thatformat(person.aggregate_amount, ',')andf"{comparison.shares_change:+,}"still work for a header-only filing.57 passed, 1 deselected. The deselected test is the network test
test_aapl_pre_mandate_sc13g_ground_truth, which was not run.Other checks:
TestSchedule13DToContextintests/display/test_to_context.pyneeds the SEC, so it was not run.ruff checkon the touched files reports the same three findings as onmain(S110 twice, F841) and none new.pyright(1.1.414) onedgar/beneficial_ownership/schedule13.py: the twodefault=Noneargument errors onsafe_int/safe_floatare 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.