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

+363
View File
@@ -0,0 +1,363 @@
# Data & signal integrity — implementation plan
Status: **closed, 2026-09-07.** Steps 1, 2 and 3a ran; Step 3b was not started
because 3a did not clear its bar. Findings are in
[`research/survivorship-audit.md`](research/survivorship-audit.md),
[`research/tr-rank-agreement.md`](research/tr-rank-agreement.md) and
[`research/tr-total-return-ab.md`](research/tr-total-return-ab.md). The follow-on
work this raised is in [`data-reliability.md`](data-reliability.md).
Two questions, answered in order, with the production work made conditional on
the answers.
## Objective
1. Measure how the backtest changes when removed index constituents are restored.
This is a survivorship sensitivity check, not a complete bias estimate.
2. Check whether total-return momentum changes the daily admission or ordering
of names. Build the production path only if this screening test finds changes.
**Guiding principle: KISS — measure before building.** Every earlier draft of this
plan started with a schema change (`adj_close`, then `tr_factor`), an ingestion
maintenance path, a snapshot migration and a benchmark column, in order to deliver
a signal change whose effect nobody has measured. Both questions above can be
investigated by research scripts against a snapshot copy. Step 1 needs one small,
explicit replay option to include entries immediately before a terminal event.
No production schema or ingestion changes are needed for either research step.
Step 3 is not started until Step 2 finds a ranking or admission difference.
Why the research scripts need none of that machinery: the whole forward-anchored
factor design exists because *production* ingest is incremental, so back-adjusted
prices drift between fetches. A research script fetches each ticker's whole history
in **one** request, and within a single response the adjustment is internally
consistent. No column, no anchor, no maintenance.
---
## Step 1 — How sensitive is the backtest to omitted constituents?
Arm A uses the universe already present in the base snapshot.
Arm B adds the names that were removed from the S&P 500 since the snapshot's start
— including the ones that failed — and lets the strategy trade them.
Use the same configuration, cadence and evaluation dates in both arms. This is
deliberately an expanded-universe experiment, without historical membership gating.
### Files
- **[NEW] `scripts/data/sp500_removals.json`** — flat list, hand-curated from
Wikipedia's S&P 500 changes table, checked into git:
`[{"symbol": "SIVB", "removed_on": "2023-03-15", "reason": "receivership"}, ...]`
Reasons: `receivership` | `bankruptcy` | `acquisition` | `market_cap`.
No additions, no rename map, no membership intervals — see *Not doing*.
- **[NEW] `scripts/prep_survivorship_snapshot.py`** — makes both arm files from a
base snapshot, leaving the base untouched:
1. Copy base → `survivorship-a.sqlite` and `survivorship-b.sqlite`.
2. **Schema sync** on both: add every column present in `Base.metadata` but
missing from the file, nullable. The snapshots on disk predate migrations 026
and 032, so `select(Ticker)` against them fails today with
`no such column: tickers.cik` — and `_get_ticker` (`price_service.py:22`) is a
full-entity select that `query_ohlcv` → `backtest_service._fetch_columns`
reaches. Derive the column list from the metadata, not a hardcoded list, so
migration 036 doesn't break it again.
3. Into arm B only: for each removed symbol with no bars, fetch daily bars from
Alpaca (it still serves delisted symbols — verified for SIVB, FRC, ATVI, TWTR)
and insert the ticker row. Restrict downloaded bars to the base snapshot's
date range and use the same price adjustment as its existing bars. Log fetched,
already-present and unavailable symbols; an unavailable failure must be called
out as missing coverage, not silently treated as having no strategy exposure.
`removed_on` is an index-removal date, not necessarily a delisting date.
**Nothing is inferred from missing bars**: `delisted_on` / `delisted_reason`
are written only where the dataset carries a confirmed terminal event. A
series that merely ends early leaves them NULL and is reported as an
unresolved *ending status* — as built, that is CMA, SEE, HBI and DISH.
Series are shortened only at a **reviewed cutoff** listed in the dataset
(SBNY 2023-03-10, INFO 2022-02-25). Elsewhere a run of ≥5 zero-volume
sessions is a tripwire, not a rule: the symbol is left out of the arm and
reported for review rather than silently truncated.
4. **Terminal mark**, arm B only: for a confirmed terminal event, append one
synthetic bar on the next SPY trading session after the last real bar, with
`o=h=l=c=0.01, volume=0`. A mark is written only when the retained final bar
equals the confirmation's reviewed `last_real_bar`; a disagreement writes no
mark and is reported. Write a sidecar JSON mapping symbol to terminal-bar
date for the runner.
The portfolio sim already fills a gap through the stop at the open
(`backtest_service.py` ~2280), so the position resolves at ~-100% with no
change to its stop-fill logic. Treat 0.01 as a near-total-loss assumption,
not an observed execution. For acquisitions, retain the last available close
as an approximation; this experiment does not model cash/stock consideration
or release of capital on the acquisition date.
This matters: failures halt mid-collapse (SIVB's last exchange bar is 106.04
on the way to zero), so without it arm B spares exactly the trades it exists
to measure. **Appending the bar alone is insufficient:** `_replay_ticker`
currently stops at `n - HORIZON`, where `HORIZON = 30`. That excludes signals
in the last 29 real bars even after the synthetic bar is added. Implement the
explicit replay option below before interpreting the audit. Only positions
still open at the halt should lose nearly everything; positions stopped or
timed out earlier should retain their normal exits.
`volume=0` is safe — `compute_volume_profile` guards its only divisor
(`indicator_service.py:298-300`).
- **[NEW] `docs/research/survivorship-audit.md`** + an entry in
`docs/research/README.md`.
### Small replay change required
- Add optional `--terminal-events <json>` to `run_backtest_snapshot.py`. Pass the
map explicitly through `run_backtest` and `_replay_and_signals` to
`_replay_ticker`, including the multiprocessing path. Default to no events.
Do not infer terminal events from low prices or zero volume.
- Validate the whole map **before dispatch** and fail the run on any mismatch.
`_replay_ticker` rejects a bad date, but both dispatch paths swallow per-ticker
exceptions so a long replay survives one unreadable name — which would turn a
stale events file into a quietly incomplete report. `_validate_terminal_events`
raises `TerminalEventError` before any work starts, so no report is written.
- For a mapped ticker, validate that the terminal date matches its final bar.
Keep the existing cadence and starting index, but allow signal dates through
the last real bar instead of stopping 30 bars early. Never generate a setup on
the synthetic bar. Unmapped tickers retain the existing replay bounds.
- Use the existing forward bars and outcome evaluation. For the long-only audit,
a setup with a positive stop resolves no later than the terminal bar. Do not
pad the history with 30 invented trading bars or turn unresolved shortened
histories into normal 30-day outcomes.
- Keep terminal prices out of later signal generation; their role is resolving
existing positions. Preserve normal stop, target and time exits before them.
### Focused validation
Add regression tests alongside the existing backtest tests:
1. A qualifying long setup five sessions before a terminal event is emitted with
the option enabled and exits at 0.01 if still held. Use a controlled setup
fixture so the test does not depend on whether SIVB happens to qualify.
2. A position that exits before the terminal event keeps that earlier exit.
3. No setup is emitted on the terminal bar; an ordinary ticker and a run without
the option retain their existing replay behavior.
Before the full runs, smoke-test the prepared files with `_fetch_columns` for one
existing and one restored ticker. Confirm matching base-symbol bars between arms,
matching configuration and date bounds, and no modification of the base file.
### Running it
```powershell
python scripts/prep_survivorship_snapshot.py --base backtest_snapshots/prod.sqlite
python scripts/run_backtest_snapshot.py backtest_snapshots/survivorship-a.sqlite --out reports/backtest-YYYYMMDD-survivorship-a.json
python scripts/run_backtest_snapshot.py backtest_snapshots/survivorship-b.sqlite --terminal-events backtest_snapshots/survivorship-b-terminal-events.json --out reports/backtest-YYYYMMDD-survivorship-b.json
```
No new runner: `run_backtest_snapshot.py` already runs the production sim offline
and reports CAGR, max drawdown, Sharpe and total return. Compare the same production
portfolio-monitor strategy and `all` lookback in both reports, not whichever
variant performs best. Record B minus A for these metrics, trade counts, evaluated
dates, restored-symbol coverage and terminal-event assumptions. A failed ticker
replay or missing production report row makes the run incomplete.
### What it does and doesn't measure
An **expanded-universe sensitivity analysis**, not a lower bound. It restores
omitted names but does not remove addition lookahead. Still-trading former members
remain eligible after removal: `run_backtest` does not gate on `delisted_on`, and
an index-removal date would not be the right delisting field anyway.
Changing the universe changes percentile ranks and competing admissions. Either
correction can raise or lower this strategy's realized returns; do not claim that
deletions are the larger source of bias or that the true drag must exceed B minus A.
A small delta does not establish that survivorship bias is small. Report it as
the sensitivity observed under these assumptions. A complete point-in-time audit
remains separate work, not a requirement for finishing this experiment.
### Base snapshot
- `prod.sqlite`: 2021-06-24 → 2026-07-02, 90MB, the ~505 production names.
- `research.sqlite`: 2016-01-04 → 2026-07-17, 678MB, 4,650 names.
Use `prod.sqlite`: the highest-signal events (SIVB, FRC, SBNY, ATVI — all
2023) are inside its window, the copies are cheap, and it has no completion
manifest to keep in sync. The pre-registration then states a 2021–2026 window, not
a ten-year one. Verify the actual file's bounds and record its hash before running.
Do not silently substitute `research.sqlite`: its much broader universe answers a
different comparison. Keep the first experiment on the production snapshot.
---
## Step 2 — Would total return change any decision?
Use a conservative ranking screen, not a portfolio simulation. Compare the full
ordered momentum-admitted list on every daily decision date. Do not call a top-15
list the traded set: production also applies setup qualification, current holdings,
available slots and other admission constraints. A difference in this screen is
a reason to investigate actual trades in Step 3, not proof of changed P&L.
### File
- **[NEW] `scripts/research_tr_rank_agreement.py`** — use arm A's universe and dates:
1. One `Adjustment.ALL` fetch and one `Adjustment.SPLIT` fetch per symbol, plus
SPY, over the full history. Single fetch each, so each series is internally
consistent. Align by date, never zip by index. Cache the responses locally
for repeatable runs. Require matching dates in both adjustments; report and
resolve missing downloads before applying the gate. Insufficient initial
history is expected and should be recorded, with equal eligibility in both
arms. Use only bars at or before each decision date.
2. At each SPY trading date with sufficient history, compute residual 12-1
momentum both ways with the existing
`momentum_service.compute_residual_12_1_momentum` (price-return series against
nominal SPY; total-return series against total-return SPY), percentile them
with `momentum_service._percentiles`, and take the admitted set at the
production `min_momentum_percentile`.
3. Compute `compute_realized_vol_6m` and its percentiles from the SPLIT series
once per date and hold that leg fixed in both arms. This isolates the change
to momentum. Order the entire admitted lists by `blend_strategy_rank`, with
symbol as a deterministic tie-breaker. Do not truncate to `max_positions`:
a lower-ranked name may be the first actionable setup when others fail gates
or are already held. Iterate symbols in the same sorted order in both arms
before calling `_percentiles` so tied inputs do not create artificial changes.
4. Save per-date results and an overall/per-year summary: number of evaluated
dates, fraction with different admitted sets, fraction with different ordered
lists, and mean and maximum set disagreement. Define set disagreement as
`len(A symmetric_difference B) / len(A union B)`, or zero for two empty sets.
Report Spearman correlation as a diagnostic only. Include the first few
changed dates with the changed names to make the result inspectable.
Validation: identical input series must produce no differences. A fixture where
the admitted members stay the same but two names swap order must register a
changed ordered list. A difference on only one date must still trigger the gate.
Write results to `docs/research/tr-rank-agreement.md` and index it in the research
README, including data coverage, parameters and the exact report/cache paths.
### Pre-registered decision rule
- **No ordered-list differences on any evaluated daily date → stop.** Record
"no admission or ordering differences observed in this window under the tested
configuration." This is historical evidence, not a guarantee about future
decisions. No schema or ingestion changes are needed.
- **Any ordered-list difference → Step 3.** This intentionally conservative rule
can flag names that would never actually trade. Step 3's paired portfolio replay
determines whether the difference reaches actual admissions and P&L.
- **Incomplete downloads or no evaluable dates → inconclusive.** Fix coverage
before deciding. Never turn missing data into agreement.
There is no median-based tolerance: a zero median can hide differences on nearly
half the dates. Checking daily full lists avoids adding a second portfolio engine
to this screening script while retaining changes below the nominal top 15.
---
## Step 3 — Production total-return ranking (conditional)
**Gate cleared 2026-09-07 — Step 3 is now live work.** The screen found ordered
admitted lists differing on 1,009 of 1,009 evaluated dates, with the shift
correlating +0.79 with trailing dividend yield
([results](research/tr-rank-agreement.md)). That is a ranking difference, not a
P&L difference; Step 3's job is to find out whether it reaches trades.
### Step 3a — Paired portfolio replay (do this first, no schema)
Different ranks are not different trades. Before any schema or ingestion work,
replay the production book twice and measure actual admissions and P&L. If the
books are identical, the basis cannot matter and Step 3b never starts.
**[NEW] `scripts/run_total_return_ab.py`**, sourcing everything from artifacts
that already exist — `backtest_snapshots/survivorship-a.sqlite` for bars and
`backtest_snapshots/tr-rank-cache.sqlite` for the two adjusted series.
1. **Generate setup candidates once.** One replay pass produces the candidate set;
both arms reuse it. Only momentum and what depends on it — the momentum
percentile, qualification against `min_momentum_percentile`, and
`blend_strategy_rank` ordering — are recomputed per arm. Setup geometry
(entry, stop, target, R:R) is generated once and never differs.
2. **Both arms read the same cache.** Control takes SPLIT stock series against
SPLIT SPY; treatment takes ALL stock against ALL SPY. Neither arm reads a
freshly fetched series, and neither mixes cache with snapshot closes for the
momentum input — a refreshed-vs-snapshot difference would confound the basis
difference being measured.
3. **Everything else is pinned.** Execution uses the snapshot's nominal bars in
both arms; the volatility leg, the non-momentum gates, capacity
(`max_positions`), risk per trade and the exit policy are identical. Each book
then carries its **own** resulting holdings — a divergent admission changes
which slots are occupied on later dates, and that path dependence is part of
what is being measured.
4. **Fixed up front:** universe (arm A's 505 symbols with bars), window
(2021-06-24 → 2026-07-02), cadence, and the production config as read from the
snapshot. Only bars at or before each signal date are used, in both arms.
5. **Saved to `reports/tr-total-return-ab.json`:** per-arm trade count, CAGR, max
drawdown, Sharpe, total return, win rate; annual returns for both arms and the
**count of years treatment beats control**; and the entry-level diff — dates
and symbols entered by one arm and not the other, in both directions.
6. **Tests** (`tests/unit/test_total_return_ab.py`): an identical-input parity
test — feeding the same series to both arms must produce byte-identical books,
so any observed difference is attributable to the basis and not to replay
nondeterminism; and a controlled case where a hand-built momentum difference
flips one name across the cutoff and the resulting admission actually changes,
proving the harness can see what it is looking for.
**Reading the result.** Different trades establish that the basis is
production-relevant. They do **not** authorize a rollout: that is the
non-inferiority decision below, taken on the paired numbers, and Step 3b starts
only after the paired results have been reported.
**Step 3a result (2026-09-07):** the basis reaches trades — ~30% of entries
differ. Which basis is better is unresolved: a paired block bootstrap on the
date-aligned daily return streams (5,000 replicates, 21- and 63-session blocks,
seed fixed beforehand, both reported) puts ΔCAGR at −3.6/−3.8pp with 95%
intervals spanning zero, ΔSharpe ≈ −0.11, and ΔMaxDD −1.0/−1.8pp in treatment's
favour. **Production stays unchanged; 3b not started.**
[Results](research/tr-total-return-ab.md).
### Step 3b — Production storage and ingestion (only if 3a shows real differences)
The design points already settled, worth carrying forward:
- `ohlcv_records.tr_factor`, `Float`, nullable, **no default and no server_default**
— a default of 1.0 makes "never backfilled" indistinguishable from "no dividends"
and would silently stamp a price-return ranking as total return.
- Forward-anchored: `f = 1.0` at `max(2016-01-04, ticker's first bar)`, compounded
as `f_t = f_{t-1} * (R_t / R_{t-1})` where `R = all_close / split_close`. The
ratio is anchor-invariant **only within one API response** — never persist `R` and
compare across days.
- `upsert_ohlcv` must not clobber a stored factor:
`coalesce(excluded.tr_factor, ohlcv_records.tr_factor)`. Daily ingest re-fetches
the boundary bars, so overlapping writes are routine.
- Daily ingest pulls a ~5-bar overlap under both adjustments to extend the factor;
a clean-ratio mismatch on the overlap bars means a split, which needs a
full-history refetch (the current incremental ingest never restates history).
- One basis for the whole cross-section, never mixed, stamped in the rank log.
- Beta leg: if the stock series goes total return, SPY must too, in the live path
and the backtest, or beta picks up SPY's ~1%/yr yield.
- A/B through the existing matrix on CAGR and max drawdown with per-year win/loss
counts. Reuse the same setup candidates, nominal execution bars, core gates,
volatility leg, capacity and exit policy; change only momentum and its dependent
admission/ranking fields. Replay each book with its own resulting holdings and
compare actual entry dates/symbols as well as P&L. Do not substitute top-15 lists
for the existing portfolio admission logic. Non-inferiority, not superiority:
total return is the definition 12-1
momentum is built on, so an in-sample shortfall shouldn't lock in the basis we
believe is measured wrong.
---
## Not doing
- **Chart total-return toggle.** The candlestick chart is an execution chart:
stops, S/R levels, entry and paper-trade markers are all nominal. Rescaling the
candles draws prices that never traded next to a stop that did.
- **`[D]` dividend markers.** Cosmetic, and they need `tr_factor` to exist. Revisit
after Step 3, if Step 3 ever happens.
- **Point-in-time addition gating** — interval map, reverse-chronological replay,
rename map and removal gating are deferred from this sensitivity experiment.
Their relative impact is unknown; a small Step 1 delta does not rule them out.
Note if a point-in-time audit is
ever done: the ranking cross-section must be filtered *before*
`_assign_*_percentiles` (`backtest_service.py:4198-4200`), not only at admission
in `_simulate_portfolio` (4257), or the percentile cutoff is still computed
against a non-PIT universe.
- **Prune protection and the `add_ticker` regex.** Only needed if delisted names
are written to the production database. The audit works entirely in snapshot
copies. (`bootstrap_universe(prune_missing=True)` cascading away a still-trading
ex-constituent's history is a real latent risk — a separate ticket, not this one.)
- **`benchmark_prices.tr_close`.** Only needed for Step 3. Note for then: store a
forward factor like the OHLCV side, not a back-adjusted close —
`refresh_benchmark_prices` writes a row only when `close` changed and is called
with variable spans, which would leave different anchors either side of the last
refreshed window.
- **Migrating the snapshots in place.** The prep script syncs its own copies.
`research.sqlite` stays byte-identical, so its completion manifest and the reports
that cite it stay valid.
+10
View File
@@ -145,6 +145,8 @@ knobs.
| **`fip_id` / liquid breadth** | Fingerprint −0.045 / t −2.91; liquid unconditional **−0.017 / t −1.85** (not green); mom-conditional **−0.088 / t −4.58** | **Parked.** Orphan +0.0575 died (snapshot race). Breadth did not strengthen resid-mom t-stat. Optional reopen = pre-registered two-arm liquid-1500 book first. See [fip-breadth-ic.md](fip-breadth-ic.md) |
| **Broader universe** | Composition changes factor signs (fip tug-of-war); vol-tilt on breadth is only a **directional hypothesis** (auth. −0.048 / t −1.36) | Any prod broaden must re-validate 80/20 tilt; offline research only; research.sqlite requires completion manifest |
| **Forward paper-trade record** | The only true out-of-sample evidence the snapshot cannot give | Time; mark entries at actual near-close fill once ops ships |
| **Total-return momentum basis** | Ranks change every day (screen: **1,009 of 1,009** dates, ~4.6 names replaced per date, shift correlates **+0.79** with trailing yield) **and it reaches trades** — paired replay differs on ~30% of entries (51 control-only, 63 treatment-only, 271 shared). Which basis is better is unresolved: paired block bootstrap (5,000 reps, 21- and 63-session blocks, seed fixed beforehand) gives ΔCAGR −3.6/−3.8pp with 95% intervals spanning zero ([−14.8, +6.4] and [−14.7, +5.4]), ΔSharpe ≈ −0.11, ΔMaxDD −1.0/−1.8pp in treatment's favour; ~75% of replicates on the point-estimate side. All of the CAGR shortfall sits in 2025 | **Production unchanged.** Needs a threshold pre-registered before it is applied, plus out-of-sample evidence; a capacity/date/cadence matrix would be sensitivity checks, not independent samples. Schema/ingestion deferred. [Screen](tr-rank-agreement.md) · [Paired replay](tr-total-return-ab.md) · [plan](../data-integrity-plan.md) |
| **Universe sensitivity / point-in-time membership** | Restoring 85 removed constituents (2021-06→2026-07, prod snapshot) moves the production row from **Sharpe 2.09 / CAGR 51.6%** to **1.57 / 38.1%** — all of it universe recomposition; no failed-bank position reached either book (trade extremes identical, and a control shows the extra replay signals changed no portfolio decision). Says the ranks are highly sensitive to the universe, not that bias is 13.5pp | Not a bias estimate: addition lookahead is still unremoved and arm B is not point-in-time. A real PIT audit is separate work. [Survivorship sensitivity](survivorship-audit.md) |
| **Better target model for clear-air names** | The return is demonstrably there (#2 wins on raw CAGR in *both* train and test); it's the *flat* 3× ATR target that makes it too expensive in risk | Needs a per-name model, not a constant k×ATR |
| **Minimum effective-risk floor** | ⛔ CLOSED NEGATIVE, not run. The floor lifts EV/trade (+0.032) and PF (+0.073) *by deleting trades* — 11.4 fewer per path, never one more — and costs **−0.753pp CAGR**, −0.047 Sharpe, −0.051 Calmar | Do not run the A/B; its EV-based pass rule would have shipped it. [Withdrawn specification](effective-risk-floor-ab.md) / [findings](portfolio-capacity-bracket-findings.md#correction-2026-08-05-ev-per-trade-was-the-wrong-lens) |
@@ -176,6 +178,14 @@ knobs.
`research.sqlite`. Extender now writes a completion manifest; breadth mode
refuses without a match. Same class of protection as calendar-truncation
asserts — do not re-mythologize numbers computed on half a universe.
8. **A big delta is not the mechanism you assumed.** Restoring 85 removed index
constituents cost 13.5pp of CAGR — and none of it came from the failed banks
the experiment was built around. The driver was the universe the ranks are
computed over. Measure which part of a change did the work before naming the
effect, and state what the control actually isolates: ours compared replay
*bounds* on one snapshot, so it measured the extra signals, not the synthetic
bars those signals resolve against.
[Survivorship sensitivity](survivorship-audit.md)
---
+173
View File
@@ -0,0 +1,173 @@
# Survivorship sensitivity — restoring removed S&P 500 constituents
**Run date:** 2026-09-07 · **Status:** measured, no strategy change proposed
**Reports:** `reports/backtest-20260907-survivorship-a.json`,
`reports/backtest-20260907-survivorship-b.json`,
`reports/backtest-20260907-survivorship-b-nomarks.json`
## Question
The backtest universe is the ~505 names tracked *today*. Every constituent that
left the index between 2021 and 2026 is missing from it, including the ones that
failed. How different does the production strategy look when they are put back?
This is an **expanded-universe sensitivity analysis**, not a bias estimate. It is
pre-registered as a measurement: there is no pass/fail rule and no promotion
attached to the outcome.
## Method
**Base:** `backtest_snapshots/prod.sqlite`, sha256 `7241f0ff0f6187a0…`, window
**2021-06-24 → 2026-07-02** (~5 years, not the full Alpaca history). Never
modified — both arms are copies, and the hash is re-checked after the build.
| Arm | Universe |
|---|---|
| **A** — control | 506 tickers, the snapshot as it stands |
| **B** — treatment | 591 tickers: the same 506 plus 85 restored removals |
Both arms ran the same binary, config, cadence (weekly) and date bounds, and are
compared on the same report row: `portfolio_monitor.runs` where
`is_production` is true and `lookback` is `all`
(`residual80_highvol80_20_atr3`).
Removals come from `scripts/data/sp500_removals.json` — 92 rows curated from
Wikipedia's *Historical components of the S&P 500* changes table, each carrying
the verbatim source text plus independently measured Alpaca evidence. Build with
`scripts/prep_survivorship_snapshot.py`.
Three data rules, all learned from the price data rather than assumed:
1. **Nothing is inferred from missing bars.** `delisted_on` / `delisted_reason`
are written only for confirmed terminal events. A series that merely stops
early leaves them NULL and is reported as an unresolved ending.
2. **Series are shortened only at a reviewed cutoff.** Alpaca does not end a
halted symbol's series — it repeats the last price at zero volume. SBNY sits
at 70.00 for 509 sessions after the FDIC closed it; INFO sits at 108.61 for
1,090 sessions, after which a *different company* reuses the ticker with no
date gap. Both have explicit reviewed cutoffs (2023-03-10, 2022-02-25).
Elsewhere a run of ≥5 zero-volume sessions is a tripwire: the symbol is left
out and reported, never silently cut.
3. **Terminal marks are explicit assumptions.** A synthetic `0.01` bar is written
on the next session after the last real bar for SIVB, SBNY and FRC only —
each with an FDIC source and only where the retained final bar matches the
reviewed `last_real_bar`. The FDIC sources establish the closure *event*; they
do not establish what holders recovered. The mark is a near-total-loss
assumption, not an observed closing-day execution.
**Replay change.** `_replay_ticker` normally stops `HORIZON = 30` bars before the
end so every setup has a full resolution window — which would hide every signal
in the final 29 real bars, exactly the ones a collapse resolves. A new
`--terminal-events` map opts the three marked symbols into walking through their
last real bar; no setup is ever generated on the synthetic bar, and unmapped
symbols are untouched. The map is validated against the snapshot's actual final
bars *before* any replay is dispatched (`_validate_terminal_events`), because both
dispatch paths swallow per-ticker exceptions and would otherwise turn a stale map
into a quietly incomplete report.
## Result
Source: `portfolio_monitor.runs` where `is_production` is true and `lookback` is
`all` (strategy `residual80_highvol80_20_atr3`), from
`reports/backtest-20260907-survivorship-a.json` and
`…-survivorship-b.json`. Both runs: weekly cadence, 6 workers,
`--allow-spawn`, gate cutoff 80.0, full period.
| Metric | Arm A | Arm B | B − A |
|---|---:|---:|---:|
| Total return % | 424.60 | 264.40 | **−160.20** |
| CAGR % | 51.60 | 38.10 | **−13.50** |
| Max drawdown % | 21.40 | 23.60 | +2.20 |
| Sharpe | 2.09 | 1.57 | **−0.52** |
| Trades | 322 | 346 | +24 |
| Win rate % | 37.0 | 35.0 | −2.0 |
| Final equity | 52,459 | 36,439 | −16,020 |
Context: 202,765 → 229,642 candidates, 1,086 → 1,315 qualified. Gate cutoff 80.0
in both arms.
## What actually caused it
**Not the failures.** `worst_trade_r` is **−3.26 in both arms**, and
`best_trade_r` is 12.87 in both. A position held into a terminal mark gaps to
$0.01 and fills at the open, which would land far below −3.26 — at candidate
level those trades score −3.13R (SIVB) and −5.99R (SBNY). No such trade appears
in either book, so none of the three failed banks reached the production
portfolio.
**What the control does and does not show.** Arm B replayed *without*
`--terminal-events` (`backtest-20260907-survivorship-b-nomarks.json`, same
snapshot, same config) produces a production row identical to arm B with them in
every field — 346 trades, 38.1% CAGR, 1.57 Sharpe, 23.6% drawdown, 264.4% total
return, −3.26 worst trade. Candidates rise 229,617 → 229,642, the 25 extra
signals the option exposes in the final 29 bars of the three symbols, while
`qualified` is 1,315 either way.
So the control establishes precisely this: **those 25 additional replay signals
changed no portfolio decision.** It does *not* independently establish that the
terminal marks had no effect — both runs read `survivorship-b.sqlite`, which
contains the synthetic bars either way, so a setup generated earlier could still
resolve against one in both runs. Isolating the marks themselves would need a
third snapshot built without them, which has not been run.
The separate evidence that no marked position reached the book is the trade
extremes: a position resolving against a $0.01 bar fills at that bar's open and
scores far worse than −3.26 (−3.13R for SIVB and −5.99R for SBNY at candidate
level), yet arm B's `worst_trade_r` equals arm A's exactly. That is inference
from the extremes, not a controlled measurement.
On that basis the −13.5pp CAGR is **universe recomposition**: 85 additional names
(+17% of the universe) shift the cross-sectional percentile ranks and compete for
the same 15 position slots, displacing trades arm A took. The strategy is
markedly sensitive to which universe it ranks over — which is the finding, and it
is a different claim from "survivorship bias costs 13.5pp".
## Caveats
- **This is not a bias estimate and not a lower bound.** Restoring names is only
one of the two corrections. Index-*addition* lookahead — today's members being
tradeable before they joined — is not removed. Either correction can move
returns in either direction, so B − A is the sensitivity observed under these
assumptions, nothing more. A small delta would not have shown the bias to be
small, and this large one does not establish its size either.
- **Arm B is not a point-in-time universe.** Still-trading former members remain
eligible after their removal date; `run_backtest` does not gate on
`delisted_on`, and an index-removal date would be the wrong field for it
anyway. A true PIT audit is separate work.
- **Four unresolved endings.** CMA (ends 2026-01-30), SEE (2026-04-08), HBI
(2025-11-28) and DISH (2023-12-29) stop trading before the window closes for
reasons the source row does not explain. Their bars are real and their
delisting fields are NULL; the ending status is simply unknown.
- **Three near-total-loss assumptions** (SIVB, SBNY, FRC), as described above.
The control shows they did not affect this result, but they remain assumptions
and would matter in any run where such a name did clear the gate.
- **Acquisitions are approximated** by the last available close. Cash or stock
consideration and the release of capital on the deal date are not modelled.
- **FBIN carries a corporate action.** Fortune Brands distributed MasterBrand on
2022-12-14 and began trading as FBIN on 2022-12-15. Alpaca's split adjustment
does not adjust for spin-offs, so the distribution appears as a real −6.91%
close-to-close move (63.22 → 61.90 → 57.62). A holder did not lose that value,
but any signal or stop crossing that date reads it as a decline. MBC enters the
dataset separately with its first bar the same day.
- **Window.** 2021-06 to 2026-07, because that is what `prod.sqlite` holds. The
2023 bank failures are inside it; the 2016–2021 period is not.
- **Snapshot schema.** The snapshot files on disk predate migrations 026 and 032,
so `select(Ticker)` against them fails with `no such column: tickers.cik` — the
path `backtest_service._fetch_columns` takes. Per-ticker exceptions are caught
and logged, so a run against an unsynced snapshot completes and writes a report
with **zero candidates** rather than failing. The prep script syncs both copies
against `Base.metadata`; anything else reading these snapshots needs the same.
## Reproduce
```powershell
python scripts/prep_survivorship_snapshot.py --base backtest_snapshots/prod.sqlite
python scripts/run_backtest_snapshot.py backtest_snapshots/survivorship-a.sqlite `
--out reports/backtest-YYYYMMDD-survivorship-a.json --allow-spawn --workers 6
python scripts/run_backtest_snapshot.py backtest_snapshots/survivorship-b.sqlite `
--terminal-events backtest_snapshots/survivorship-b-terminal-events.json `
--out reports/backtest-YYYYMMDD-survivorship-b.json --allow-spawn --workers 6
```
`survivorship-b-prep-manifest.json` records the base hash, per-symbol coverage,
the terminal events written and every unresolved item for the run.
+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.
+160
View File
@@ -0,0 +1,160 @@
# Total-return momentum — paired portfolio replay (Step 3a)
**Run date:** 2026-09-07 · **Status:** measured; **no rollout decision taken**
**Report:** `reports/tr-total-return-ab.json` · **Script:** `scripts/run_total_return_ab.py`
**Inputs:** `backtest_snapshots/survivorship-a.sqlite` (bars),
`backtest_snapshots/tr-rank-cache.sqlite` (both adjusted series),
`backtest_snapshots/tr-ab-candidates.pkl` (202,765 shared candidates)
## Question
[The ranking screen](tr-rank-agreement.md) showed the momentum basis changes the
admitted list on every evaluated date. Does that reach actual admissions and P&L?
## What was held identical
Setup candidates were generated **once** (202,765 over 505 symbols, matching the
count in the standalone backtest report) and reused by both books. Only momentum
and its dependents were recomputed per arm: residual and activation percentiles,
`qualified`, and the 80/20 blend used for ordering.
| Held fixed | Varied |
|---|---|
| Setup geometry — entry, stop, target, R:R | Momentum series: SPLIT stock vs SPLIT SPY (control), ALL vs ALL SPY (treatment) |
| Execution on the snapshot's nominal bars | The percentiles, `qualified` and ordering that follow from it |
| `vol_6m` volatility leg, from candidate generation | |
| Non-momentum gates, `max_positions`, risk per trade, ATR-trail exit, gate-reset re-entry | |
Both arms read the same cache, so a refreshed-vs-snapshot difference cannot be
mistaken for a basis difference. Each book then keeps its own holdings, so a
divergent admission changes later slot occupancy — that path dependence is part
of what is measured.
**Harness parity.** The control arm reproduces the production row of
`reports/backtest-20260907-survivorship-a.json` exactly — 322 trades, 424.6%
total return, 51.6% CAGR, 21.4% drawdown, 2.09 Sharpe. Any difference below is
therefore attributable to the basis, not to a re-implemented book.
## Result
| Metric | Control (price return) | Treatment (total return) | Δ |
|---|---:|---:|---:|
| Trades | 322 | 334 | +12 |
| Total return % | 424.6 | 376.5 | −48.1 |
| CAGR % | 51.6 | 47.9 | **−3.7** |
| Max drawdown % | 21.4 | 19.5 | **−1.9** (better) |
| Sharpe | 2.09 | 1.98 | −0.11 |
| Win rate % | 37.0 | 36.8 | −0.2 |
| Profit factor | 2.48 | 2.33 | −0.15 |
**The books really do differ.** 271 entries are shared; **51 are taken only by
control and 63 only by treatment** — about 30% of the union of entries. The
composition follows the Step 2 mechanism: control takes MAR, AAPL, BIIB, LVS;
treatment takes BG, OKE, DE, ARES. Some differences are timing rather than
selection — ABBV is entered on 2022-09-15 by control and 2022-09-22 by treatment.
## Where the difference sits
**One year carries all of it.** Per-year returns:
| Year | Control % | Treatment % | Δpp |
|---|---:|---:|---:|
| 2022 | 8.1 | 9.9 | +1.8 |
| 2023 | 42.0 | 41.9 | −0.1 |
| 2024 | 36.4 | 38.4 | +2.0 |
| 2025 | 80.2 | 58.1 | **−22.1** |
| 2026 | 39.2 | 39.6 | +0.4 |
Treatment wins three years and loses two, and four of the five differ by under
2.1pp. The entire −3.7pp CAGR gap is 2025. That is a description of *where* the
shortfall sits — a useful thing to know when reading it — and nothing more. It is
not grounds for calling the shortfall noise or the estimate unstable.
**Uncertainty is not yet quantified.** The per-arm `sharpe_se` values (0.497 and
0.498) are the standard errors of each Sharpe *individually*. They say nothing
about the uncertainty of the **difference**: the two books share dates and most
of their positions, so the arms are strongly correlated and the standard error of
their difference depends on that covariance, not on the two marginal errors. An
earlier version of this document compared the 0.11 Sharpe gap to a 0.50 marginal
SE and concluded the books were "statistically indistinguishable" — that
inference does not follow, and the same applies to the drawdown difference, for
which no error estimate was reported at all. Both claims are withdrawn.
## Paired block bootstrap on the difference
Pre-specified before running — seed `20260907`, **5,000** replicates, block
lengths **21 and 63** sessions, both reported rather than whichever reads better.
The two arms' daily return streams are date-aligned (the script aborts if they
are not), and **the same block indices are drawn for both arms**, so the pairing
and whatever cross-arm correlation exists on those days are preserved. Moving
blocks retain some of the serial dependence an i.i.d. resample would destroy.
Treatment − control, 95% interval:
| Block | Metric | Mean | 2.5% | 97.5% | Share of replicates < 0 |
|---|---|---:|---:|---:|---:|
| 21 | ΔCAGR pp | −3.61 | −14.76 | +6.45 | 0.75 |
| 21 | ΔSharpe | −0.109 | −0.433 | +0.198 | 0.75 |
| 21 | ΔMaxDD pp | −1.00 | −7.50 | +4.34 | 0.63 |
| 63 | ΔCAGR pp | −3.78 | −14.67 | +5.40 | 0.76 |
| 63 | ΔSharpe | −0.113 | −0.428 | +0.182 | 0.76 |
| 63 | ΔMaxDD pp | −1.79 | −8.22 | +2.68 | 0.78 |
The two block lengths agree closely, which is reassuring about the resampling
itself. Every interval spans zero, and roughly three-quarters of replicates fall
below zero for CAGR and Sharpe (treatment worse) and for drawdown (treatment
better, since a negative ΔMaxDD is a shallower drawdown). So the observed window
leans toward control on return and treatment on drawdown without resolving either
sign confidently.
**What this diagnostic is not.** It resamples the *realised* daily return streams;
it does not re-run the strategy, so it cannot capture how a reordered history
would have changed admissions or holdings. It is an exploratory uncertainty
estimate from the observed window — not out-of-sample validation, and not a
rollout gate.
## Verdict
**Decision: production stays unchanged.** No schema, no ingestion, no wider
parameter sweep.
- **Established:** the basis is production-relevant. It changes roughly 30% of
entries, in the yield-driven direction the screen predicted.
- **Not established:** which basis performs better. Point estimates favour control
on return and Sharpe and treatment on drawdown; every bootstrap interval spans
zero, with about three-quarters of the mass on the point-estimate side. The
observed window does not resolve the sign.
- No acceptable return sacrifice is being chosen from these numbers, and no
threshold is being fitted to them after the fact.
What a decision would still need:
1. **A pre-registered numeric rule, set before it is applied.** The plan commits
to non-inferiority in principle — total return is the basis 12-1 momentum is
defined on, so an in-sample shortfall should not automatically lock in the
basis we believe is measured wrong — but carries no threshold. An earlier draft
used ΔCAGR ≥ −0.50pp at ΔMaxDD ≤ +1.50pp. Choosing a threshold now, with
−3.7pp on the table, would be fitting the rule to the answer.
2. **Out-of-sample evidence.** One five-year snapshot, in-sample throughout.
3. Note what would *not* help: a matrix over capacity, start date or cadence
produces **sensitivity checks, not independent samples**. Fifty-three
overlapping paths from one price history are not fifty-three pieces of
evidence, and treating them that way would overstate what a sweep buys.
## Reproduce
```powershell
python scripts/run_total_return_ab.py --build-candidates # ~9 min, cached
python scripts/run_total_return_ab.py # both arms + bootstrap, ~40 s
```
The bootstrap parameters are constants in the script (`BOOTSTRAP_SEED`,
`BOOTSTRAP_REPLICATES`, `BOOTSTRAP_BLOCKS`) rather than flags, so a run cannot
quietly pick a different seed or block length.
Guards in `tests/unit/test_total_return_ab.py`: identical input series must
produce identical books (so an observed difference is the basis, not replay
nondeterminism); a controlled momentum change must move a name across the cutoff
(so the harness can see what it is looking for); the volatility leg must be
untouched between arms; and a name with no computable momentum in an arm must not
qualify on a value carried over from candidate generation.