Skip to content

feat: DLRET reconstruction table as primary output#1

Merged
royelee merged 26 commits into
mainfrom
feat/dlret-reconstruction-finish
Jun 1, 2026
Merged

feat: DLRET reconstruction table as primary output#1
royelee merged 26 commits into
mainfrom
feat/dlret-reconstruction-finish

Conversation

@royelee

@royelee royelee commented May 31, 2026

Copy link
Copy Markdown
Owner

Summary

  • Primary output output/dlret.csv: a per-delisting DLRET reconstruction table built on a self-explaining DLRET hub (dlret.py) and a central EnrichedDelistRecord + build_dlret_table/write_dlret_csv (reconstruction.py).
  • Full cash+stock merger consideration: terminal = cash + stock_ratio×acquirer_price. classify_universe.py gains --last-trade-closes / --merger-terms / --recoveries / --dlret-output flags.
  • CRSP 501–519 fix: up-migrations route to EXCHANGE_TRANSFER, not COMPLIANCE_FAILURE.
  • bmp_correction.py re-points at the DLRET hub (back-compat re-exports).

Update — LLM-based cash+stock merger extractor (d2ba93cde3f77e)

Replaces hand-written --merger-terms CSVs with an LLM that reads deal terms straight from EDGAR, behind --extract-merger-terms-llm.

  • New modules: llm_merger_extractor.py (extractor + versioned prompt + cache/llm/ cache), llm_client.py (injectable OpenAI JSON client, json_schema→json_object→bare fallback), raw_tiingo.py (nominal-close price join), filing_selection.py (filing-tier picker shared with payout_extractor.py).
  • Acquirer price + last-trade close joined from the raw Tiingo panel; a sanity gate (|terminal/last_close−1| ≤ tol, default 0.15) drops any term that doesn't reconcile, rejecting mis-resolved acquirers. Explicit --merger-terms rows always override the LLM.
  • Calibrated live on 10 labeled deals (scripts/eval_merger_extractor.py, 10/10) before fan-out.
  • Coverage recovery: pure-cash deals the regex extractor missed fall back to the LLM's cash (+5); an ACQUIRER_RENAMES map (UTX→RTX, ECA→OVV, COG→CTRA, HRS→LHX, Q→IQV, SXCI→CTRX, SPF→CAA, ESV→VAL) resolves later-renamed acquirers' panel prices (+6).
  • No empty DLRET (de3f77e): a completed merger / fund closure with a known last price but no computable consideration has terminal ≈ last price (arbitrage closes the gap; funds redeem at NAV), so it's filled as ASSUMED_PAR (DLRET 0, low confidence) rather than left blank — filterable via dlret_confidence, never mistaken for a computed return. Table-only; the firm-month facade is unchanged.

Recall on the full universe (461 delistings):

prototype (regex) LLM extractor
cash+stock / stock-only deals 10 104 (48 cash+stock + 56 stock-only)
computed (high/medium-confidence) DLRET 340 418
rows carrying a DLRET value 340 / 461 461 / 461
stock-leg reconciliation (|dlret|) median 1.0%, 93/104 within ±5%, all within ±15%

dlret_method mix: cash_only 216 · stock_only 56 · cash_plus_stock 48 · exchange_transfer_zero 45 · assumed_par 43 (low-conf ≈0) · shumway_nyse_amex 32 · shumway_nasdaq 21. The 43 assumed_par rows are completed mergers whose acquirer isn't in the US price panel (foreign/private/delisted), sanity-rejected, or wrong-dated, plus fund/recycled-ticker closures (e.g. HOT→Marriott, now a Defiance ETF).

Test plan

  • Full offline suite: 192 passed (conda run -n rdagent4qlib pytest)
  • LLM extractor: offline tests with fake LLM + fake EDGAR; PayoutExtractor's 53 tests byte-identical after the filing_selection.py refactor
  • Live e2e: classify_universe.py --extract-merger-terms-llmoutput/dlret.csv (461/461 valued, 0 blank); AET −0.21% / AGN −0.01% reconcile; sanity gate caught the MRD 65× regression; no stock-leg outliers >15%

🤖 Generated with Claude Code

royelee and others added 26 commits May 31, 2026 12:57
Add the verified research report on how CRSP generates DLRET across all
delisting situations (Shumway -30% / Shumway-Warther -55% / worthless -1
corrections; the 501/502 up-migration bug), and the approved design spec
making the per-delisting DLRET reconstruction table the library's primary
output via a central EnrichedDelistRecord type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8 TDD tasks: dlret.py hub (resolve_dlret/DlretResult + mixed cash+stock),
bmp_correction re-point, 501/502 fix, EnrichedDelistRecord + enrich,
build_dlret_table + CSV, firm-month stock-leg threading, classify_universe
wiring to output/dlret.csv, and docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ion back-compat)

The no-consideration ABSTAIN branch fired before the price guard, so a merger
with neither consideration terms nor a valid last_trade_close returned 0.0
instead of NaN, silently turning a firm-month drop into a ~-100% return and
breaking compute_dlret's documented value-preservation invariant.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ock terms, clamp confidence

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…maps; av_listing import

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nk comment

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(dedup _read_map)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…al verify')

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reads EDGAR merger filings with an LLM and returns structured
consideration (cash leg, stock exchange ratio, acquirer), lifting
cash+stock/stock-only DLRET coverage from 10 to 98 deals (407/461
total computed). The acquirer price and target last-trade close are
joined from the raw Tiingo panel; a sanity gate
(|terminal/last_close-1| <= tol) rejects mis-resolved acquirers.

New modules:
- raw_tiingo.py: nominal-close price-panel join
- llm_client.py: injectable OpenAI JSON client (json_schema ->
  json_object -> bare fallback; last-resort drops temperature)
- filing_selection.py: filing-tier selection shared with
  PayoutExtractor (its 53 tests stay byte-identical)
- llm_merger_extractor.py: the extractor (prompt constant + version,
  excerpt windowing, disk cache, completeness gate)
- scripts/eval_merger_extractor.py: live calibration harness over 10
  labeled deals (proven 10/10 before fan-out)

Wired into classify_universe.py behind --extract-merger-terms-llm.
When the LLM reads a deal as all-stock, the payout extractor's
(mis-read) cash is suppressed so build_dlret_table cannot inflate a
stock-only deal into a bogus cash_plus_stock (the MRD 65x bug).

190 offline tests; e2e validated (median |dlret| 0.9%, max 13.8%).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the LLM merger-terms auto-extraction command (network: SEC +
OpenAI) and architecture pointers for llm_merger_extractor.py /
llm_client.py / raw_tiingo.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two sanity-gated coverage wins on the LLM merger path:
- Pure-cash deals where the regex payout extractor found nothing now
  fall back to the LLM's cash (sanity-gated vs last_close): +5
  (AMED, MCW, MSP, NRE, TWKS).
- ACQUIRER_RENAMES maps a deal-era acquirer ticker to the symbol the
  raw Tiingo panel files its history under (UTX->RTX, ECA->OVV,
  COG->CTRA, HRS->LHX, Q->IQV, SXCI->CTRX, SPF->CAA, ESV->VAL), so the
  acquirer price resolves though the issuer was later renamed: +6
  (COL, NFX, XEC, XLS, IMS, CHSI). ATW/RDC/RYL stay blank — their
  renamed price didn't reconcile and the gate correctly rejected them.

Computed DLRET 407 -> 418/461; no regressions (10 labeled deals
unchanged, all 104 stock-leg deals within +/-15%). 190 tests green.

Diagnosed but not fixed: 7 of 8 "found-nothing" mergers (MON, TIN,
THOR, STR, ...) carry wrong observed_delist_dates in the input (MON
2022-12-23 vs the 2018 Bayer close), so filing selection misses the
real 8-K -- an input-data issue, separate from the extractor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every delisting in output/dlret.csv now carries a numeric DLRET (was
418/461, now 461/461). A completed merger or a fund/non-equity closure
with a known last price but no computable consideration has terminal
value ≈ that last price — merger arbitrage closes the gap to the deal
value before the last trade, and a fund/ETF redeems at NAV ≈ its last
trade. So DLRET ≈ 0 is the maximum-likelihood estimate, not a missing
value.

These rows are emitted as a new ASSUMED_PAR method at "low" confidence
(terminal_value = last_close), so the value is filled but never
mistaken for a realized/computed return — readers can filter on
dlret_confidence. Requires a positive last price (no denominator
otherwise; a no-price abstain still renders blank).

Table-only: enrich() applies the fill; the firm-month facade
(compute_dlret / bmp_correction) is byte-for-byte unchanged. The 43
filled rows = 26 merger abstains (acquirer not in the price panel,
sanity-rejected, or wrong-dated) + 17 expiration/non-equity closures
(mostly recycled tickers whose original company was acquired, e.g.
HOT→Marriott now a Defiance ETF). 192 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "Using DLRET to build training labels and backtests" section: the
two formulas (label = (1+RET)(1+DLRET)-1; exit = last_close×(1+DLRET) =
terminal_value), a pandas recipe straight off output/dlret.csv, a
dlret_confidence trust guide, and per-bucket intuition.

Also reconcile the DLRET-policy table and the AET worked example with the
shipped behavior: document the assumed_par fill (no empty / no silent
zero), and correct AET to its real last trade close ($212.70 → dlret
-0.21%) — the old example divided by the pre-announcement $190 and showed
a +11.6% that double-counts the premium already earned in RET.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@royelee
royelee merged commit b60aa87 into main Jun 1, 2026
2 checks passed
@royelee
royelee deleted the feat/dlret-reconstruction-finish branch June 1, 2026 05:00
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