# 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.