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
5.9 KiB
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), 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
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.