Skip to content

Classify delistings right the first time: identity, evidence-based buckets, checked payouts - #3

Open
royelee wants to merge 48 commits into
mainfrom
feat/classify-right-first-time
Open

royelee wants to merge 48 commits into
mainfrom
feat/classify-right-first-time

Conversation

@royelee

@royelee royelee commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Makes the classifier get renames, recycled tickers, bankruptcies, payouts and impostor series right the first time, so a downstream consumer no longer needs hand-written corrections on top of dlret.csv.

A consumer typically turns a compliance_failure row into a −100% return and a liquidation row into −90%, so a wrong bucket fabricates a large loss and a wrong payout fabricates a wrong return. Every rule below exists to keep that from happening without positive evidence.

What changed

Golden harness (Task 1). data/golden_events.csv holds 31 verified events; tests/fixtures/golden/ holds the recorded EDGAR, EFTS and LLM responses; tests/golden.py replays them offline. scripts/build_golden_fixtures.py rebuilds them.

SEC blocks are loud (Task 2). A 403 or 429 raises EdgarBlocked instead of being cached as "no CIK found". Misses are no longer persisted, and old cik: null entries are ignored. The CLI exits 2.

Identity (Task 3). The resolver takes index-member names, compares them near the event date, and keeps a disagreeing EFTS answer as a flagged fallback rather than letting a live company with the same name replace the company that filed the delisting forms. Rows carry member_name_mismatch and resolved_by_current_ticker_map.

Classifier rules (Tasks 4–9).

  • A bankruptcy tag counts only when the filing's own Item 1.03 section reports one.
  • A frozen vendor tail is anchored on the Form 25 date, recorded as frozen_tail:<days>.
  • A change of control (5.01 with 2.01, 3.01 or 3.03) is a merger.
  • A rename or listing transfer yields when a nearby 8-K shows an acquisition.
  • A SPAC liquidation is an expiration, not distress.
  • Every code 570 routes through one default rule that needs positive evidence: deregistration, deficiency wording, merger evidence or a late-filing notice. Otherwise the row is unknown, and a deregistered row with a real close is marked at par.

Payouts (Tasks 10–11). The reader handles whole-dollar amounts, ignores preferred redemptions and award tables, treats elections as mixed, and abstains on ambiguous ties. payout_gate.reconcile then checks every payout against the last trade close: one that does not reconcile is replaced by LLM terms that do, or dropped. An election takes the leg the close matches. payouts.csv carries only checked values.

Review surface (Task 12). dlret.csv gains a trailing review_flags column, and output/review.csv lists every row the rules could not settle, with its cik and anchor 8-K.

Final-review fixes. The resolver cache is versioned, so a cache holding old wrong CIKs is ignored. A cached SEC filing list saved before the event is refetched, and a failed refetch serves the cached copy flagged submissions_stale instead of failing the row. EXCHANGE_CIKS now holds 12 exchange registrants verified against EDGAR and against the Form 25-NSE filer list: 9 of the previous 12 entries were unrelated filers. MANUAL_OVERRIDES["KWK"] pointed at the wrong company and now points at Quicksilver Resources (1060990). Outputs are written to a temp file and replaced only when complete.

Code-review round. A later review found and this branch fixes: the payout class guard discarding the real common-share consideration when the preceding clause mentioned warrants, preferred stock or redeemed notes; item_text reading the cover-page index instead of the item's own section; the standard "Bankruptcy or Receivership" caption confirming a bankruptcy by itself; an unreadable 3.01 notice falling through to the merger branch; a bankruptcy the company emerged from outranking a later merger; and a fixture-builder crash on a filing with an empty date. New review flags: merger_at_par, submissions_stale, bankruptcy_before_merger.

Testing

413 tests pass, all offline. The 3 warnings predate this branch. All 31 golden cases pass on bucket, code, resolved CIK, flags and payout. Corpus checks over the cached filings: all 13 real Item 1.03 filings still confirm as bankruptcies and a mis-tagged one does not; deficiency and listing-transfer detection are unchanged on all 408 Item 3.01 filings.

Behaviour changes worth knowing

  • The first run after this lands re-resolves every ticker against SEC, because the old resolver cache format is ignored.
  • "Form 25 plus Form 15 with no other evidence" is no longer a liquidation mark. It is unknown, which resolves to par. A distress bucket now requires positive evidence.
  • Rows change wherever resolution used to skip one of the nine removed CIKs. Diff a regenerated table against the previous one before trusting it.

Follow-ups

An acceptance run against a full universe, plus README, CLAUDE.md and docs/data-flow.md updates, is still open (Task 13 of the plan). The plan file's "Execution record" section lists every ruling made during execution and the carry-over items.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz

royelee and others added 30 commits September 16, 2026 22:35
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…ers, bankruptcies, payouts)

31 EDGAR-verified cases in data/golden_events.csv; scripts/build_golden_fixtures.py
captures what the resolver and classifier read for each into tests/fixtures/golden/;
tests/golden.py replays it offline. test_golden_events.py strict-xfails the 24 cases
today's code gets wrong, each mapped to the plan task that should fix it.
TickerResolver gains an unused keyword-only member_names= (read from Task 3 on);
pytest now imports this checkout (pythonpath = src, .).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…icker-free name match, proxy window)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…ded plan

HLTH's state-court receivership (8-K 1.03, 2019-09-24) 14 days after the
delisting decides its terminal value. IMCL has no automatic path, so the golden
test pins it as production does (GOLDEN_MANUAL); it now classifies 573 via
REVOKED and waits only for Task 3's flag. XFAIL_BUCKET follows the review
rulings: HLTH -> 4, XTO -> 6, KCI -> 9, reasons updated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
… first, two-word name match, ±30-day names)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
SEC refusals (403/429) were silently absorbed by broad `except Exception`
clauses in the ticker resolver and recorded as "No CIK found" — exactly
what happened from 2026-05-28 to 2026-09-16. Adds `EdgarBlocked` and
`check_response()` in edgar.py, called after every SEC HTTP response in
edgar.py and the EFTS helpers in ticker_resolver.py; `except EdgarBlocked:
raise` guards in front of the broad excepts so a refusal always propagates.
classify_universe.py re-raises per-ticker and aborts the process (exit 2)
instead of writing an error row.

A resolver miss (cik=None) is no longer persisted to the cache, and
TickerResolver.__init__ skips loading any pre-existing cache entry with
cik=None, so misses recorded by older/blocked runs get retried instead of
being trusted forever.

Adds an autouse `_no_efts_network` fixture (tests/conftest.py) so the
suite no longer makes live efts.sec.gov calls in
test_unknown_ticker_returns_unknown, which the sandbox was silently
swallowing — the same failure class this fix targets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…pt blocks

Task-2 fix round 1. PayoutExtractor.extract and LLMMergerTermsExtractor.extract
already let EdgarBlocked propagate cleanly (they only catch
requests.RequestException), but scripts/classify_universe.py's per-ticker
payout- and llm-terms-extraction try/excepts caught it via their bare
`except Exception as e:`, printed "payout ERROR" / "llm-terms ERROR", and kept
going — the same refusal-read-as-a-miss failure this task exists to close.
Adds `except EdgarBlocked: raise` in front of both, matching the guard already
in place for classify_ticker().

Covering tests in tests/test_edgar_blocked.py assert PayoutExtractor and
LLMMergerTermsExtractor let EdgarBlocked propagate out of extract() when a
fake EDGAR's fetch_filing_text raises it for a merger record with a closing
8-K (so extraction reaches the text fetch instead of short-circuiting).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…ember/company mismatch

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…own Form 25; replay raw EFTS in golden tests

- _efts_lookup returns (cik, name, fallback); a disagreeing second-pass hit is a fallback that
  the name search may replace only with a Form 25/15 within 90 days, else kept as efts_name_mismatch
- _expected_name skips names with no usable word (AT&T, 3M); _name_search ranks with name_tokens
- golden fixtures gain efts_raw (both EFTS answers, keyed by URL); tests/golden.py serves them to
  the real EFTS methods; build_golden_fixtures.py records them and has --efts-only

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…onfirmed 1.03 tag is ignored

A company that kept filing periodic reports after emerging from Chapter 11
with the same CIK (Oasis Petroleum) was misread as an exchange transfer,
because the "continued filings" override ran before any bankruptcy check.
A merger 8-K's Item 1.03 tag with no bankruptcy text and no Item 1.03
section in the body (Vista Outdoor) was taken at face value and misread
as a bankruptcy.

- evidence.bankruptcy_8ks(filings, on, before=540, after=30): 8-Ks tagged
  1.03 within the window, oldest first.
- evidence.mentions_bankruptcy(text): "bankruptcy" / "chapter 11" / "chapter
  7" / "receivership", case-insensitive.
- classifier._confirmed_bankruptcy: the first windowed 1.03 8-K whose text
  confirms it (an empty/fetch-miss text counts as confirmed — the tag is
  SEC's own metadata). Runs right after the SEC-REVOKED check and before
  the continued-filings exchange-transfer override, so a real bankruptcy on
  record always wins.
- classifier._effective_items: strips a 1.03 tag from an anchor 8-K's item
  set when its text is fetched and does not mention a bankruptcy, flagging
  "bankruptcy_tag_unconfirmed" (deduped) so the merger/compliance fingerprint
  underneath can classify normally.

Golden harness: deletes the Task 4 XFAIL_BUCKET entries (OAS, MDR, WE,
LYLT, VSTO, HLTH); all six now pass for real. SPWR (already passing) now
resolves through the bankruptcy check with reason "Bankruptcy (8-K item
1.03 filed 2024-08-06)".

Suite: 246 passed, 13 xfailed, 3 pre-existing warnings (up from baseline
236 passed / 19 xfailed: +4 new unit tests, +6 golden cases moved from
xfail to pass).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
… flag frozen vendor tails

_pick_delist_filing now returns (filing, gap_days) and gates a Form 25
candidate more than FORM25_TAIL_DAYS (45) before the observed date: it is
rejected unless the nearest/backscanned 8-K carries an M&A item (2.01/5.01/
3.03) or no operating filing (10-K/10-Q/20-F/40-F, or an earnings 8-K)
appears between the Form 25 and the observed date. This stops an unrelated
older Form 25 (SKLZ's 2021 warrant delisting, a stale candidate >1500 days
out) from anchoring a later event. A Form 25 that is accepted despite a
gap > 45 days is flagged frozen_tail:<days> (evidence["anchor_gap_days"]
records the gap for every case). New pure helper evidence.filed_operating_between.

Golden harness: 18 passed, 13 xfailed, unchanged — KCI and XTO now carry
frozen_tail (370 and 955 days) as their golden fixtures expect, but both
still need a later task's item-set/evidence rule to reach the right bucket,
so neither XFAIL_BUCKET entry moves. SKLZ's Form 25 is now correctly
rejected (delist_filing is None) but it still needs Task 7's rename/
listing-transfer handling, so it stays mapped there too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…2.01, and beats 2.04

Takeover closing 8-Ks often carry items 3.01+3.03+5.01 but no 2.01 (C.R.
Bard, Rohm & Haas), and some carry 2.04 because notes are put on the
change in control (Onyx). Both used to fall through to compliance
failure (570) or distress (470), producing a fake -100%/-90% training
label. `_classify_items` now recognizes a "control" fingerprint --
5.01 plus any of 2.01/3.01/3.03 -- as 231 (M&A change in control,
closing 8-K without 2.01), checked before the plain 2.01+3.01 and
2.04+3.01 rules so it wins over an incidental 2.04.

Deletes the Task 6 XFAIL_BUCKET entries (BCR, ONXX, XTO), which now
classify correctly. Confirmed SPWR still passes via Task 4's
bankruptcy check, which runs before _classify_items. All other golden
cases are unchanged (verified by diffing the full 31-id table before
and after).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…s, not compliance failures

LendingClub -> Happen (NYSE -> Nasdaq) and Skillz -> Firy both filed a
3.01 notice and kept reporting results; today they fall through to a
570 compliance failure, which downstream becomes a fake -100% training
label. Add evidence.renamed_near, still_operating, item_text, and
says_listing_transfer, and check them in classify_ticker right after
the confirmed-bankruptcy rule and before the continued-filings
override, so a frozen Form 25 tail (SKLZ's 2021 SPAC-merger Form 25)
can't hide it. still_operating (needs a Form 15) is what keeps BLD's
merger-time rename to "QXO Insulation, LLC" out of this rule.

Delete the Task 7 XFAIL_BUCKET entries for LC and SKLZ; both now pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
Code review found _rename_or_transfer fires on renamed_near alone: a
company acquired through a standard 2.01+3.01+5.01 merger, renamed at
closing, with registered debt still filing 10-Qs and no Form 15, came
back as EXCHANGE_TRANSFER (304) instead of MERGER (231).

Per the controller's ruling, decline the rule whenever an 8-K within
+/-30 days of the observed date (report_date falling back to
filing_date, as elsewhere in the classifier) shows the registrant was
acquired: item 5.01, or 2.01 together with 3.01 or 3.03. A bare 2.01
(a disposition) does not block it. The merger fingerprint then decides
via the existing _classify_items path.

Also: _TRANSFER_TEXT now matches past tense ("transferred its
listing"), not just present tense.

Golden suite unchanged: 23 pass, 8 xfail; LC, SKLZ, HYH still pass;
BLD stays merger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…ot distress

A blank-check company (SPAC) that liquidates its trust redeems shares at
trust value (~$10, true return ~0). Today such delistings land in
compliance_failure or liquidation, which downstream becomes a fake
-100%/-90% training label.

Add evidence.is_spac(sub, on): true when the EDGAR SIC is 6770 or the
name at the delisting date matches /acquisition\s+corp/i. In
classify_ticker, check it after the rename/transfer rule and before the
continued-filings override (only when a Form 25 or dereg is on record),
emitting flag "spac" and CRSP 600/EXPIRATION.

Golden harness: FST, BWC, HMA, LEAP move from XFAIL to passing (all
carry spac + member_name_mismatch). PEAK and LBRDA/LBRDK, whose
resolved companies are not SPACs, are unaffected. A pre-existing unit
test (test_resolver_member_names.py) asserted an exact flags list for
the FST/FAST-Acquisition-Corp case; updated it to include the new
"spac" flag, which is correct now that the classifier can see it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…t a bare SIC hit

Review finding: is_spac ORed the SIC-6770 test unconditionally, so a
de-SPAC whose EDGAR SIC was never updated from 6770 (EDGAR doesn't
always update it after the merger) would still read as a SPAC. Since
the SPAC branch runs before the Form-25/8-K fingerprint, a later real
merger or compliance failure under the same CIK got pre-empted into a
false EXPIRATION/600 (dlret 0).

Ruling: is_spac(sub, on) is True when the name at `on` matches
"Acquisition Corp", or when the SIC is 6770 AND no formerNames entry
has a `to` date on or before `on` (no completed rename by `on`). A
de-SPAC always renames; the four real SPAC liquidations in the golden
set (FST, BWC, HMA, LEAP) all have SIC 6770 and empty formerNames, so
they are unaffected. Docstring rewritten to state the exact rule and
the reason.

Golden suite unchanged: 27 passed, 4 xfailed (FST/BWC/HMA/LEAP still
pass). Full suite: 275 passed, 4 xfailed, 3 pre-existing warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…a closing 8-K are found by their proxy/tender filings

Every no-fingerprint return and every 3.01-alone 570 (Form 25 path and
8-K-only path) now goes through _default_without_fingerprint:
2.01 + Form 15 -> merger 233; 3.01 text citing a listing deficiency ->
570/580; merger proxy/tender filing (window from the Form 25) -> merger
231; NT 10-K/Q alone -> 580; else unknown + no_evidence_default with
evidence["deregistered"]. enrich renders an unknown deregistration with
a valid last close at par (ASSUMED_PAR). SIAL, UTIW, AABA, KCI pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…tandard exchange deficiency wording

- item_text finds the Item heading in any case (ITEM 3.01 notices were read as
  empty, so a real deficiency fell through to unknown at par).
- _DEFICIENCY_TEXT adds: failure to comply with / satisfy the|its|one or more
  continued listing, abnormally low, average global market capitalization,
  no longer suitable for (continued) listing, commence(d) proceedings to delist.
  The standard 3.01 heading alone still does not match.
- The helper flags notice_text_missing when a 3.01 anchor's text is empty.
- Tests: uppercase heading, the phrase table, both 580 branches (NT window
  from the Form 25), text-missing flag, exact codes on the merger tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
… elections are mixed, ties abstain

Veritiv's "$170 in cash" (whole dollars, no decimals) was missed while a
"$1.00 multiplied by ... PBU award" figure was read instead; Two Harbors'
$25 preferred redemption won a tie over the $12.00 common cash; TopBuild's
cash-or-stock election was read as all cash. Fixes:

- _AMT now accepts whole dollars or exactly two decimals (still refuses a
  truncated 4-decimal figure); _AMT_LONG reads a long decimal only after
  "cash payment of" (CPWR's $10.389188).
- _CLASS_CONTEXT/_AWARD_AFTER skip a figure that belongs to another security
  class (preferred redemption) or an award payout.
- _MIXED_AFTER also joins on "or" (a cash-or-stock election) with an optional
  lettered/roman list marker; _MIXED_WINDOW widened to 120 to span BLD's
  "(the Cash Consideration) or (ii) 20.200 shares".
- _select now abstains on a tie between two comparably-sized figures (TWO)
  instead of always preferring the larger value.
- classify_universe.py writes payout values as %.6g so long decimals survive.

Golden payout test added (test_golden_payout, parametrized over merger cases
with expected_dlret); XFAIL_PAYOUT holds only BLD (a genuine cash-or-stock
election, resolved by Task 11's price check). VRTV, TWO, CPWR, ONXX, SIAL,
UTIW, KCI all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…ecision, tie-branch regression test

Applies three controller rulings on top of 1660886:

1. test_golden_payout now asserts pr.value is not None before computing the
   implied return, dropping the "abstain -> 0.0 fallback" that let
   VRTV_2023-11-30 pass the golden test even when the extractor found no
   match at all (pre-Task-10). All 8 PAYOUT_CASES now require a real
   extraction; BLD_2026-07-01 stays in XFAIL_PAYOUT (strict) and still xfails
   on its mixed-election abstain.
2. Added test_match_cash_plus_cvr_equal_count_tie_still_takes_cash: unlike
   the existing APLS test (cash 2x beats CVR 1x via the higher-count path),
   this repeats the CVR figure to force an equal-count tie ($41.00 x2 vs
   $4.00 x2) and asserts _select still returns the cash floor because the
   CVR is under 25% of the cash — exercising the tie-but-small-second-value
   branch the 25%-abstain rule was written for.
3. classify_universe.py: both payout CSV writes changed .6g -> .10g so
   CPWR's $10.389188 (8 significant digits) survives verbatim instead of
   rounding to $10.3892.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…by the leg the price reconciles with

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…r-deal cash, gated payouts.csv

- payout_gate.gate_payouts: the post-loop routing as a pure, tested function
- an election where both legs fit takes the leg nearer the last close (tie: stock)
- LLM cash is accepted for any non-election terms without a stock ratio (cash + CVR "other")
- full cash+stock terms clear their key's payout_gate_failed flags
- payouts.csv is written after the gate from the gated values
- election stock rows keep payout_source llm_election_stock
- DEFAULT_TOL shared by the CLI and the golden test
- builder: lazy script import, skip LLM capture without --raw-tiingo-dir, snapshot-relative raw_tiingo_dir

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
… the rules could not settle

- EnrichedDelistRecord.review_flags joins evidence["flags"] with enrich()'s
  new extra_flags, plus a distress_at_normal_price flag when a
  compliance/liquidation bucket's last trade closed at $5+.
- review_flags is appended (last) to DLRET_TABLE_COLUMNS and enriched_to_row;
  build_dlret_table takes a new payout_flags map to source extra_flags.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
… flag; wire gated.flags and write output/review.csv

- gate_payouts flags every cash+stock terms-gate drop (no_acq_ticker,
  no_acq_price, no_last_close, fail_sanity) as terms_gate_failed:<reason>,
  appended to any flags the key already carries. csv_override stays silent
  (it's an intentional override, not a failure).
- reconcile no longer records payout_gate_failed:* when an election settles
  by either leg and the regex value is within 1% of the LLM's cash leg --
  the regex read a real term of the deal, just not the leg the price
  reconciled with. A regex value matching neither leg still keeps the flag.
- classify_universe.py passes payout_flags=gated.flags to build_dlret_table
  and writes output/review.csv (ticker, observed_delist_date, bucket, dlret,
  review_flags, reason, cik, anchor_8k) for every flagged row, joining table
  rows to all_records on (ticker.upper(), observed_delist_date); always
  written (header-only when nothing is flagged) so a stale file never
  survives. Flag counts print in descending-count, then-name order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…never save an answer reached through a transient error

The unversioned cache/ticker_resolution.json returned answers saved before the
date and name checks (CPWR, SPWR, HYH, PEAK). A file without __version__ 2 is
now ignored (one warning) and replaced on the next save; each entry records the
member name it was checked with, and a different member name is a miss. A
RequestException or 5xx in any check during one resolve() keeps that answer
out of the file, like a miss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…flag a missing text

Credit-agreement boilerplate in takeover 8-Ks (KCI, VRTV, McDermott 2018) says
"bankruptcy", so reading the whole filing would confirm a mis-tagged 1.03 and
give a takeover -90%. A tag is now confirmed only when item_text(text, "1.03")
finds the section and it mentions a bankruptcy. An empty text (fetch miss)
still counts as confirmed and adds bankruptcy_text_missing. The rule confirms
all 13 real 1.03 8-K texts in the golden fixtures and rejects VSTO.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
_AMT's lookahead (?!\.?\d) let the regex backtrack to "$1" before ",618", so
"cash consideration of $1,618.7928" read 1.0 and "$12,345.678" read 12.0. The
lookahead is now (?![.,]?\d). _AMT_LONG needs its "." and already reads the
whole figure. No read changes over the 1,080 cached filing texts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
EdgarClient served cached submissions with no freshness check, so an event after
the fetch (LBRDA's 2026-08-20 Form 25 vs a 2026-05-26 cache) was invisible.
submissions(cik, fresh_after) now refetches, and rewrites the cache, when the
copy was fetched before fresh_after. Each JSON payload records __fetched__; an
unstamped file is dated by its mtime. classify_ticker asks once, up front, with
min(observed + 45 days, today), so its later reads hit the fresh copy. A 403/429
still raises before any cache write. The test fakes accept and ignore the
parameter, so the offline golden replay is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
The CIK assertion skipped the member_name_mismatch and unknown cases (IMCL,
AABA, FST, BWC, HMA, LEAP, HLTH), so a wrong company there went unnoticed. All
31 cases now assert rec.cik == case.cik, and all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…ackscan; a SPAC reaching the default is an expiration; skip NYSE LLC and Cboe BZX

- Backscan: the 2.04+3.01 branch sat after the bare-3.01 branch and never ran;
  a change in control (5.01 with 3.01 or 3.03, no 2.01) was not scored. The
  order is now 2.01+3.01+5.01 > 2.01+5.01 > 2.01+3.01 > control > 2.04+3.01 >
  3.01.
- A SPAC with no Form 25 in the window and no Form 15 reached the default
  helper, where NYSE's liquidation notice ("commence proceedings to delist")
  read as a deficiency (570, -100% on a ~$10 trust). The helper now checks
  is_spac first: 600 expiration, flag spac.
- EXCHANGE_CIKS gains 876661 (New York Stock Exchange LLC) and 1417835 (Cboe
  BZX Exchange, Inc.), as EFTS names them. 1067442 kept its NYSE label, but no
  cached submission or fixture names it; it is now marked unverified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
royelee and others added 18 commits September 17, 2026 03:14
…rows in the gate summary

- reconcile adds llm_gate_failed when it was given cash or election terms and
  nothing reconciled, keeping the existing flags. Before, a merger row whose
  LLM terms failed with no regex value landed at par with no flag. Stock-ratio
  terms go to the cash+stock gate, which flags its own drops.
- GatedPayouts.gate_failed, and so the "Payout gate" summary line, counts only
  rows flagged payout_gate_failed that have no gated payout and no merged terms.
  TWO (regex $25 failed, LLM $12 settled) is no longer counted. The printed
  text says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…when complete; drop shadowed overrides

- delist_classifications.csv was opened for writing before the loop, so an
  abort (EdgarBlocked, Ctrl-C) left a partial file over the committed one. It,
  payouts.csv, dlret.csv and review.csv are now written to .<name>.tmp in the
  same directory and moved into place with os.replace once complete. On an
  exception the temp file is removed and the old file stays. The CLI and the
  exit code 2 on EdgarBlocked are unchanged.
- MANUAL_OVERRIDES listed VNTV and RICE twice. The earlier entries (1467373,
  Accenture plc; 1604665, Westlake Chemical Partners) were shadowed; they are
  deleted, so the dict is unchanged. The unused json import is dropped as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…o Quicksilver Resources

Nine of the twelve EXCHANGE_CIKS entries were not exchanges, per EDGAR on
2026-09-17: Prudential Investment Portfolios 16, Merrill Lynch Internet
Strategies Fund, a CIK with no record, Dashe Murray H, Muhlenkamp & Co, Perfect
Pear, C VIII Real Estate Opportunities, T-Mobile US and Westergaard.com. The
resolver skipped all nine as exchanges. The set is now the twelve verified
exchange registrants: Nasdaq, NYSE, NYSE Arca, NYSE American, NYSE Texas,
NYSE National, Cboe BZX, Cboe, Cboe EDGX, Nasdaq PHLX, Nasdaq Texas and the
Boston Stock Exchange.

MANUAL_OVERRIDES["KWK"] pointed at 1283699 (T-Mobile US), so production
labelled KWK exchange_transfer 0% from T-Mobile's filings. It now points at
1060990, QUICKSILVER RESOURCES INC in the cached submissions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…r check

F4 refreshed only the classifier's reads, so the resolver's date and name
checks ran first on a stale copy. A cache from 2026-05-26 holds LBRDA's 2015
Form 25 but not its 2026-08-20 one, so _validate_cik rejected the right
company. _fits_date, _validate_cik, _accept_member_candidate and
_name_match_score now read submissions with
fresh_after = edgar.submissions_fresh_after(observed), which is
min(observed + 45 days, today); every recent_filings read comes after such a
read. The classifier uses the same helper. The F4 classifier test pins the
resolver with a manual override, so it still counts only the classifier's
up-front call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…aracters

A closing 8-K disposes of warrants, preferred stock or redeemed notes in the
clause before the common share's consideration, so the flat lookback threw the
real payout away ("each Company Warrant was cancelled, and each Share converted
into the right to receive $113.00 in cash" read as {}).

_collect now scans _CLASS_CONTEXT only from the last subject marker ("each",
"per share", "holders of") inside the same 120-character lookback, so another
security's clause no longer counts as this amount's subject while "each share of
Series A Preferred Stock ... $25.00" is still discarded. The window only ever
narrows, so no new match can appear.

Also flag merger_at_par in reconstruction.enrich when a MERGER row resolves to
ASSUMED_PAR: a payout that is never found used to land at par unseen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…gged stale

Every event newer than SUBMISSIONS_FRESH_DAYS refetches on every run, so one
transport failure turned a ticker with a usable cached copy into an error row.
_get_json now catches requests.RequestException around the refetch and returns
the cached payload with __stale__ added to the returned dict only — never
written to disk. EdgarBlocked still propagates (it is not a RequestException),
and with no cached copy the error still propagates.

classify_ticker flags submissions_stale when the submissions payload carries the
mark, so the row is reviewable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…e heading

item_text anchored on the first "Item N.NN" match — usually the cover-page index
— and took 1,500 characters without stopping at the next item, so the standard
heading "Item 1.03 Bankruptcy or Receivership" alone satisfied
_confirms_bankruptcy and a mis-tagged takeover 8-K read as a bankruptcy.

item_text now returns the section from the chosen match to the next Item N.NN or
`width` characters, whichever comes first, and picks the first match whose
section is at least 200 characters (falling back to the old behaviour when every
match is shorter). _confirms_bankruptcy drops the heading line before matching
mentions_bankruptcy, or takes chapter 7/11, petition, bankruptcy court or a
court-appointed receiver anywhere in the section.

Corpus (1,236 cached + fixture texts): all 13 real Item 1.03 8-Ks still confirm
and VSTO still does not; over the 408 filings carrying an Item 3.01, neither
cites_listing_deficiency (31) nor says_listing_transfer (4) changes on any row.

The `receivers?` term is beyond the reviewer's list: without it HLTH/Nobilis, a
state-court receivership whose body says only "Temporary Receiver", stopped
confirming and its golden case flipped from liquidation to compliance_failure,
reversing the Task 1 concern-3 ruling. `receivers?` does not match the heading's
"Receivership", so the heading still confirms nothing on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…e read

_default_without_fingerprint fell through to the merger-evidence branch — the
least conservative option — on the path where the evidence is weakest. When
notice_text_missing is set, the branch now needs a proxy or tender filing within
120 days of the anchor instead of 400; otherwise the row continues to the NT
10-K/Q branch (580) or to unknown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
… the merger

bankruptcy_8ks(..., before=540) plus the confirmed-bankruptcy override beat every
merger signal, so a company that filed Chapter 11, emerged, and was acquired
within 18 months was marked liquidation (-90% in training).

The merger path now wins when every confirmed item 1.03 is more than 180 days
before the delisting AND an 8-K within +/-30 days of it carries a change in
control (5.01, or 2.01 with 3.01 or 3.03), and the record is flagged
bankruptcy_before_merger. A confirmed 1.03 inside 180 days, a second bankruptcy
near the delisting, or a bare 2.01 all keep the override.

_confirmed_bankruptcy takes the window as an argument so the "no recent
bankruptcy" question is asked directly, and the change-in-control fingerprint
becomes one helper shared with _rename_or_transfer instead of a fourth copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
member_name_mismatch was computed for manual overrides too, so the ~40
hand-verified pins (the new KWK pin among them) showed up in review.csv. The
member name is a check on the automatic resolver; a pin has already been checked
by hand, so the check and the flag are skipped when resolution.source is
"manual".

Golden: IMCL is pinned through GOLDEN_MANUAL, so its expected_flags loses
member_name_mismatch. The row's note records why. Production resolves IMCL the
same way (MANUAL_OVERRIDES), so its dlret row loses the flag too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…he capture

date.fromisoformat(f.filing_date) ran without the empty-string guard _trim has
two lines earlier, so one filing with an empty filingDate aborted a capture after
the network work was already spent. The window filter moves into _near_event,
which skips empty dates the way _trim does, and is covered by a unit test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
_effective_items ran twice on the same 8-K in the Form 25 path — once to test for
None, once to keep the result — refetching the text, re-running the 1.03
confirmation and making the flag side effects order-dependent. It is now computed
into a local and recomputed only when the backscan replaces the 8-K.

Also record above EXCHANGE_CIKS that the set was checked against the 25-NSE filer
list (no IEX, Cboe EDGA, MEMX, MIAX Pearl or LTSE filer exists).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…ing 400

The R4 branch chose between 120 days and a literal 400 that had to stay in sync
with merger_evidence's default. MERGER_EVIDENCE_DAYS is now the one source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…s a pin

Reverses R6. member_name_mismatch states a fact about the security — the vendor
series is not the named member — not a to-do about the CIK, and it is the signal
the consumer uses to catch an impostor series (IMCL is ImmunoClin trading under
ImClone's old symbol). A manual pin must not silence it, so the check runs for
every resolution again and IMCL's golden row is restored unchanged.

A manual resolution now also carries resolved_by_manual_override, so review
triage can tell "the name differs" apart from "the CIK needs checking".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…rees

resolved_by_manual_override was unconditional, so all 46 pinned tickers landed in
review.csv — a superset of the volume the original R6 finding complained about.
review.csv lists rows the rules could not settle, and a pin whose name agrees is
settled, so the flag now only qualifies member_name_mismatch: it is appended
inside the mismatch branch, after the name check has run.

A pin whose name differs still carries both flags; a pin whose name agrees
carries neither.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
Drops the local environment details from the branch: the conda env name, the
consumer repo's name and paths, and the price-snapshot directory. The golden
fixture no longer records the snapshot path it was built from; the acquirer
price it stores is the provenance that matters, and data/golden_events.csv
already names it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
…3 caption

Two Important defects from the re-review, both in R3's new code.

_BANKRUPTCY_BODY's `petition` was unanchored and matched inside "competition".
Takeover 8-Ks routinely report antitrust and competition clearance, so a
mis-tagged 1.03 confirmed as a bankruptcy and the row became liquidation at
-0.90. It is now `\bpetitions?\b`.

_drop_heading only removed a heading that ended in punctuation, and its newline
branch is dead in production: edgar._strip_html collapses every newline to a
space (0 of 1,080 cached texts contain one), so an unpunctuated heading kept its
own words and the caption alone still confirmed — the thing R3 exists to prevent.
6 of the 13 real Item 1.03 filings kept heading words, SVB among them
("Item 1.03. Bankruptcy or Receivership On March 10, 2023, ..."). _drop_heading
now matches SEC's caption itself and takes it off, falling back to the
newline/sentence rules only for a heading it does not recognise. 0 of 13 keep it
now, and SVB still confirms on the body's own "appointed as receiver".

Corpus unchanged otherwise: 13 of 13 real Item 1.03 filings confirm, VSTO still
does not, and over the 408 filings carrying an Item 3.01 cites_listing_deficiency
(31) and says_listing_transfer (4) are the same on every row.

Also pin the stale fallback's 5xx behaviour with tests and say in the _get_json
docstring that it is deliberate: requests.HTTPError is a RequestException, so an
SEC outage serves the cached copy instead of erroring the row out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UauyzPDg1987fjTMJKrTkz
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.

1 participant