research: survivorship sensitivity and total-return momentum investigation

Three questions answered against snapshot copies, with no production change.

Step 1 — how sensitive is the backtest to the ranking universe? Restoring 85
S&P 500 constituents removed between 2021-06 and 2026-07 moves the same window
from 2.09 Sharpe / 51.6% CAGR to 1.57 / 38.1%. The failed banks never reached
either book (identical best/worst trade R), so this measures universe
recomposition, not survivorship bias. The removals list is curated by hand with
per-symbol sources; only three explicitly confirmed terminal events get marks,
and four unresolved endings are reported as caveats rather than inferred.

Step 2 — does a total-return basis change the ranking? Ordered lists differ on
1009 of 1009 evaluated dates, 4.56 replacements per date. The shift correlates
with trailing implied yield (mean Spearman +0.79), which is strong evidence
consistent with a dividend effect but does not isolate own-yield, since both
the stock and benchmark legs change.

Step 3a — does that reach P&L? A paired replay reusing one shared candidate set
(control reproduces the production row exactly) changes ~30% of entries.
Treatment is -3.7pp CAGR and -1.9pp drawdown, with the entire return gap in
2025. A paired moving-block bootstrap (seed 20260907, 5000 replicates, 21/63
blocks, both reported) puts every interval across zero.

Decision: production stays unchanged. No schema, no ingestion, no wider sweep.
The observed window does not resolve which basis is better, and choosing a
threshold now — with -3.7pp on the table — would be fitting the rule to the
answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HAH7vVtn7qbcn1EVheecHQ
This commit is contained in:
dennisthiessenandClaude Opus 5 committed 2026-09-07 19:29:34 +02:00
1 parent 8453b87290
commit ebe9047403
16 files changed
+442522

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
# Total-return vs price-return momentum — ranking screen
**Run date:** 2026-09-07 · **Verdict: proceed to Step 3** (pre-registered rule fired)
**Reports:** `reports/tr-rank-agreement.json`, `reports/tr-rank-agreement-mechanism.json`
**Script:** `scripts/research_tr_rank_agreement.py`
**Cache:** `backtest_snapshots/tr-rank-cache.sqlite` (507 symbols, 630,524 aligned bars)
## Question
12-1 momentum is defined on total returns; production computes it on
split-adjusted prices, which ignore dividends. Before building the production
`tr_factor` machinery (Step 3 of [the plan](../data-integrity-plan.md)), does the
basis actually change what the gate admits and how it orders?
Deliberately a screen, not a portfolio simulation. A difference here is a reason
to measure trades, **not** evidence that P&L changes.
## Configuration
| | |
|---|---|
| Universe | `backtest_snapshots/survivorship-a.sqlite`, 505 symbols with bars (RHM has a ticker row but no bars in the snapshot, so it is inert in both arms) |
| Window | 2021-06-24 → 2026-07-02, clipped to the snapshot |
| Decision dates | every SPY session; 1,009 had enough history to evaluate |
| Momentum | production `compute_residual_12_1_momentum`, unchanged; price series against nominal SPY, total-return series against total-return SPY |
| Volatility leg | production `compute_realized_vol_6m` on the SPLIT series, computed once per date and **held fixed in both arms** so only momentum varies |
| Gate | production `min_momentum_percentile` = 80.0, via production `_percentiles` |
| Ordering | production `blend_strategy_rank`, symbol as deterministic tie-break, symbols fed in sorted order in both arms |
| Comparison | the full admitted list, never truncated to `max_positions` |
| Runtime | 146 s with the production function — no NumPy rewrite needed, so no equivalence test was required |
**Coverage is complete.** One `Adjustment.SPLIT` and one `Adjustment.ALL` request
per symbol; 507 symbols cached, **zero** dates present in one adjustment but not
the other. No symbol with snapshot bars is missing from the cache — the script
refuses to run in that case rather than reporting the gap as agreement.
## Result
| | |
|---|---:|
| Evaluated dates | 1,009 |
| Dates with a **different ordered list** | **1,009 (100%)** |
| Dates with a different admitted set | 1,007 (99.8%) |
| Mean set disagreement, `\|A △ B\| / \|A ∪ B\|` | 0.0867 |
| Max set disagreement | 0.2143 |
| Mean admitted names per arm | 100.09 |
| Mean **replacements** per date (one out paired with one in) | **4.56** |
| Max replacements on a single date | 12 |
| Mean symmetric difference (names counted on both sides) | 9.13 |
Per year, every evaluated date differs:
| Year | Dates | Different set | Different order |
|---|---:|---:|---:|
| 2022 | 132 | 132 | 132 |
| 2023 | 250 | 250 | 250 |
| 2024 | 252 | 252 | 252 |
| 2025 | 250 | 248 | 250 |
| 2026 | 125 | 125 | 125 |
Counted directly from the daily set differences rather than derived from the
Jaccard ratio: **4.56 names are replaced on an average date** — 4.56 leave and
4.56 arrive, so 9.13 names appear on one side or the other, out of about 100
admitted. A swap moves two names, which makes the symmetric difference twice the
replacement count and the two easy to conflate.
## Strong evidence of a dividend effect
First changed date, 2022-06-24 — 99 names admitted in each arm, nine swapped:
- **Only on price return:** CDNS, MAR, FIX, COST, LIN, AON, STE, CI, AJG
- **Only on total return:** EXC, PSX, WY, AMCR, DTE, GIS, FE, BMY, ES
Growth and low-yield names out; utilities, energy, staples and other high payers
in. Nine tickers are not evidence, so it was measured (`--mechanism 25`, saved to
`reports/tr-rank-agreement-mechanism.json` with its sampled dates and per-date
values): across 40 sampled dates, the Spearman correlation between a name's
trailing 12-month implied yield — from the ALL/SPLIT ratio over the formation
window — and its momentum-percentile shift between arms is **+0.79** (range
+0.71 to +0.85, every sampled date positive).
**What that does and does not pin down.** The reordering is systematic and
directional rather than numerical noise, and it moves with yield. It is *not* a
clean isolation of the stock's own yield: both the stock series and the benchmark
leg change between arms, so the measured shift also contains the beta-weighted
benchmark yield. Read it as strong evidence consistent with a dividend effect.
Isolating own-yield would need a third variant — total-return stock series
against a nominal SPY leg — which has not been run.
## Verdict
The pre-registered rule was "no ordered-list difference on any evaluated date →
stop". Differences occur on **every** evaluated date, so **Step 3 proceeds**.
What this does and does not establish:
- **Does:** the momentum basis materially changes gate admission and ordering,
every day, in a yield-driven direction.
- **Does not:** show that realised P&L changes. Production also applies setup
qualification, existing holdings, available slots and the rest of the admission
path. A name that swaps into the admitted list may never produce a setup. Step
3's paired portfolio replay is what decides whether any of this reaches trades.
- **Worth noting:** the tuning recorded as closed in the research log was carried
out on a price-return basis throughout. This screen does not invalidate it —
it says the input those conclusions were computed on is not the one the factor
is defined on.
## Reproduce
```powershell
python scripts/research_tr_rank_agreement.py --fetch # 1,014 requests, cached
python scripts/research_tr_rank_agreement.py --time-batch 20 # runtime projection
python scripts/research_tr_rank_agreement.py # full screen
python scripts/research_tr_rank_agreement.py --mechanism 25 # yield vs percentile shift
```
The cache makes re-runs free and the screen deterministic; delete
`backtest_snapshots/tr-rank-cache.sqlite` to refetch.