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
478 lines
20 KiB
Python
478 lines
20 KiB
Python
"""Build the two survivorship-audit snapshots from a base snapshot.
|
|
|
|
Arm A is the universe the backtest already runs on. Arm B is the same universe
|
|
plus the S&P 500 constituents removed while the snapshot window was open, so the
|
|
strategy can trade the names that later left the index — including the ones that
|
|
failed.
|
|
|
|
The base file is never modified: both arms are copies, and the base hash is
|
|
re-checked at the end.
|
|
|
|
Three things this script has to get right, all of them learned from the data:
|
|
|
|
* **Schema drift.** The snapshot files on disk predate migrations 026 and 032,
|
|
so ``select(Ticker)`` against them fails with ``no such column: tickers.cik``
|
|
— and that is the path ``backtest_service._fetch_columns`` takes. Both copies
|
|
are synced against ``Base.metadata`` before anything else touches them.
|
|
* **Frozen tails.** 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, and INFO sits at 108.61 for 1,090 sessions before a different
|
|
company reuses the ticker. Only the symbols with a *reviewed cutoff* in the
|
|
dataset are truncated. Everywhere else a long zero-volume run is a tripwire:
|
|
the symbol is left out and reported for review rather than silently cut.
|
|
* **Terminal marks.** A failed bank stops trading mid-collapse, so the exchange
|
|
bars never show the loss. A mark is written only where the dataset carries an
|
|
explicit confirmation *and* the retained final bar matches the reviewed
|
|
``last_real_bar``. The mark is an assumption, not an observed execution.
|
|
|
|
Nothing is inferred. ``delisted_on`` / ``delisted_reason`` are written only for
|
|
confirmed terminal events; a series that merely ends early leaves them NULL and
|
|
is reported as an unresolved ending status.
|
|
|
|
Usage::
|
|
|
|
python scripts/prep_survivorship_snapshot.py
|
|
python scripts/prep_survivorship_snapshot.py --base backtest_snapshots/prod.sqlite --force
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import shutil
|
|
import sqlite3
|
|
import sys
|
|
from datetime import date, datetime, time, timedelta, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
# Bars are cut at the first run of this many consecutive zero-volume sessions.
|
|
ZERO_VOLUME_RUN = 5
|
|
# Close assigned to a confirmed terminal event. Not zero: a zero price divides
|
|
# through several return calculations, and 1 cent is a near-total loss either way.
|
|
TERMINAL_CLOSE = 0.01
|
|
|
|
DEFAULT_BASE = ROOT / "backtest_snapshots" / "prod.sqlite"
|
|
DEFAULT_REMOVALS = ROOT / "scripts" / "data" / "sp500_removals.json"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# schema sync
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def sync_schema(path: Path) -> dict[str, list[str]]:
|
|
"""Add every column the models declare that the snapshot file lacks.
|
|
|
|
Nullable and untyped-default only: this brings an old snapshot up to the
|
|
current models without inventing data. Tables the snapshot does not have at
|
|
all are left alone — a snapshot is a subset of production by design.
|
|
"""
|
|
from sqlalchemy.dialects import sqlite as sqlite_dialect
|
|
|
|
from app.database import Base
|
|
import app.models # noqa: F401 (registers every model on Base.metadata)
|
|
|
|
dialect = sqlite_dialect.dialect()
|
|
added: dict[str, list[str]] = {}
|
|
con = sqlite3.connect(path)
|
|
try:
|
|
present = {r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
|
for table in Base.metadata.sorted_tables:
|
|
if table.name not in present:
|
|
continue
|
|
have = {r[1] for r in con.execute(f'PRAGMA table_info("{table.name}")')}
|
|
for column in table.columns:
|
|
if column.name in have:
|
|
continue
|
|
type_sql = column.type.compile(dialect)
|
|
con.execute(f'ALTER TABLE "{table.name}" ADD COLUMN "{column.name}" {type_sql}')
|
|
added.setdefault(table.name, []).append(column.name)
|
|
con.commit()
|
|
finally:
|
|
con.close()
|
|
return added
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# price data
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def frozen_run_start(bars: list[Any]) -> int | None:
|
|
"""Index where the first run of >= ZERO_VOLUME_RUN zero-volume bars begins.
|
|
|
|
A detector, not a cutter: the caller decides what to do with the answer, and
|
|
only a reviewed cutoff is allowed to shorten a series.
|
|
"""
|
|
volumes = [int(b.volume) for b in bars]
|
|
i = 0
|
|
while i < len(volumes):
|
|
if volumes[i] == 0:
|
|
j = i
|
|
while j < len(volumes) and volumes[j] == 0:
|
|
j += 1
|
|
if j - i >= ZERO_VOLUME_RUN:
|
|
return i
|
|
i = j
|
|
else:
|
|
i += 1
|
|
return None
|
|
|
|
|
|
def apply_reviewed_cutoff(bars: list[Any], cutoff: date) -> tuple[list[Any], int]:
|
|
"""Keep bars up to and including *cutoff*. Returns (kept, dropped)."""
|
|
kept = [b for b in bars if b.timestamp.date() <= cutoff]
|
|
return kept, len(bars) - len(kept)
|
|
|
|
|
|
async def fetch_bars(symbol: str, start: date, end: date) -> list[Any]:
|
|
"""Daily SPLIT-adjusted bars, matching how the snapshot's existing bars were ingested."""
|
|
from app.config import settings
|
|
from app.providers.alpaca import AlpacaOHLCVProvider
|
|
|
|
if not settings.alpaca_api_key or not settings.alpaca_api_secret:
|
|
raise SystemExit("Alpaca credentials are not configured; cannot restore price history.")
|
|
|
|
from alpaca.data.enums import Adjustment
|
|
from alpaca.data.requests import StockBarsRequest
|
|
from alpaca.data.timeframe import TimeFrame
|
|
|
|
provider = AlpacaOHLCVProvider(settings.alpaca_api_key, settings.alpaca_api_secret)
|
|
alpaca_symbol = provider._to_alpaca_symbol(symbol)
|
|
request = StockBarsRequest(
|
|
symbol_or_symbols=alpaca_symbol,
|
|
timeframe=TimeFrame.Day,
|
|
start=datetime.combine(start, time.min, tzinfo=timezone.utc),
|
|
end=datetime.combine(end + timedelta(days=1), time.min, tzinfo=timezone.utc),
|
|
adjustment=Adjustment.SPLIT,
|
|
)
|
|
# 85 sequential requests are enough for Alpaca to drop one connection, and a
|
|
# half-built arm is worse than a slow one.
|
|
last_error: Exception | None = None
|
|
for attempt in range(4):
|
|
try:
|
|
bars = await asyncio.to_thread(provider._client.get_stock_bars, request)
|
|
return list(bars.data.get(alpaca_symbol, []))
|
|
except Exception as exc: # noqa: BLE001 - retry any transport failure
|
|
last_error = exc
|
|
if attempt < 3:
|
|
await asyncio.sleep(2 * (attempt + 1))
|
|
raise SystemExit(f"Alpaca fetch failed for {symbol} after 4 attempts: {last_error}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# snapshot writes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def snapshot_window(path: Path) -> tuple[date, date]:
|
|
con = sqlite3.connect(path)
|
|
try:
|
|
lo, hi = con.execute("SELECT min(date), max(date) FROM ohlcv_records").fetchone()
|
|
finally:
|
|
con.close()
|
|
return date.fromisoformat(lo), date.fromisoformat(hi)
|
|
|
|
|
|
def trading_calendar(path: Path) -> list[date]:
|
|
"""SPY sessions from the snapshot's own benchmark series."""
|
|
con = sqlite3.connect(path)
|
|
try:
|
|
rows = con.execute(
|
|
"SELECT date FROM benchmark_prices WHERE symbol = 'SPY' ORDER BY date"
|
|
).fetchall()
|
|
finally:
|
|
con.close()
|
|
return [date.fromisoformat(r[0]) for r in rows]
|
|
|
|
|
|
def next_session(calendar: list[date], after: date) -> date | None:
|
|
for d in calendar:
|
|
if d > after:
|
|
return d
|
|
return None
|
|
|
|
|
|
def insert_symbol(
|
|
con: sqlite3.Connection,
|
|
symbol: str,
|
|
name: str,
|
|
bars: list[Any],
|
|
*,
|
|
delisted_on: str | None,
|
|
delisted_reason: str | None,
|
|
terminal_date: date | None,
|
|
) -> int:
|
|
"""Insert one restored ticker and its bars. Returns the bar count written."""
|
|
now = datetime.utcnow().isoformat(sep=" ")
|
|
cur = con.execute(
|
|
"INSERT INTO tickers (symbol, name, created_at, delisted_on, delisted_reason) "
|
|
"VALUES (?, ?, ?, ?, ?)",
|
|
(symbol, name, now, delisted_on, delisted_reason),
|
|
)
|
|
ticker_id = int(cur.lastrowid)
|
|
|
|
rows = [
|
|
(
|
|
ticker_id,
|
|
b.timestamp.date().isoformat(),
|
|
float(b.open),
|
|
float(b.high),
|
|
float(b.low),
|
|
float(b.close),
|
|
int(b.volume),
|
|
now,
|
|
)
|
|
for b in bars
|
|
]
|
|
if terminal_date is not None:
|
|
rows.append((
|
|
ticker_id, terminal_date.isoformat(),
|
|
TERMINAL_CLOSE, TERMINAL_CLOSE, TERMINAL_CLOSE, TERMINAL_CLOSE, 0, now,
|
|
))
|
|
con.executemany(
|
|
"INSERT INTO ohlcv_records "
|
|
"(ticker_id, date, open, high, low, close, volume, created_at) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
rows,
|
|
)
|
|
return len(rows)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# driver
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def file_hash(path: Path) -> str:
|
|
h = hashlib.sha256()
|
|
with path.open("rb") as fh:
|
|
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
async def build(args: argparse.Namespace) -> int:
|
|
base = Path(args.base).resolve()
|
|
if not base.exists():
|
|
raise SystemExit(f"Base snapshot not found: {base}")
|
|
|
|
out_dir = Path(args.out_dir).resolve()
|
|
arm_a = out_dir / args.arm_a_name
|
|
arm_b = out_dir / args.arm_b_name
|
|
events_path = arm_b.with_name(arm_b.stem + "-terminal-events.json")
|
|
|
|
doc = json.loads(Path(args.removals).read_text(encoding="utf-8"))
|
|
removals = doc["removals"]
|
|
confirmations = {c["symbol"]: c for c in doc["terminal_confirmations"] if c.get("confirmed")}
|
|
cutoffs = {
|
|
c["symbol"]: date.fromisoformat(c["last_real_bar"])
|
|
for c in doc.get("reviewed_cutoffs", {}).get("cutoffs", [])
|
|
}
|
|
# A corporate action ends a listing; a market-cap removal does not, so a
|
|
# series that stops early without one is an ending we cannot account for.
|
|
ENDING_EXPLAINED = {"acquisition", "receivership", "bankruptcy", "ticker_change"}
|
|
|
|
base_hash_before = file_hash(base)
|
|
window_start, window_end = snapshot_window(base)
|
|
print(f"base : {base}")
|
|
print(f"window : {window_start} .. {window_end}")
|
|
print(f"removals : {len(removals)} rows, {sum(1 for r in removals if r['restore_symbol'])} restorable")
|
|
|
|
for target in (arm_a, arm_b):
|
|
if target.exists() and not args.force:
|
|
raise SystemExit(f"{target} already exists (use --force to rebuild)")
|
|
if target.exists():
|
|
target.unlink()
|
|
shutil.copy2(base, target)
|
|
added = sync_schema(target)
|
|
detail = ", ".join(f"{t}: {', '.join(c)}" for t, c in added.items()) or "nothing missing"
|
|
print(f"copied : {target.name} (schema sync -> {detail})")
|
|
|
|
calendar = trading_calendar(base)
|
|
con = sqlite3.connect(arm_b)
|
|
existing = {r[0].upper() for r in con.execute("SELECT symbol FROM tickers")}
|
|
|
|
restored: list[dict] = []
|
|
unresolved: list[dict] = []
|
|
terminal_events: dict[str, str] = {}
|
|
|
|
try:
|
|
for row in removals:
|
|
symbol = row["restore_symbol"]
|
|
if not symbol:
|
|
continue
|
|
if symbol.upper() in existing:
|
|
unresolved.append({"kind": "skipped", "symbol": symbol, "why": "already present in the base snapshot"})
|
|
continue
|
|
|
|
bars = await fetch_bars(symbol, window_start, window_end)
|
|
if not bars:
|
|
unresolved.append({"kind": "coverage", "symbol": symbol, "why": "Alpaca returned no bars in the window"})
|
|
print(f" {symbol:<6} NO DATA (unresolved coverage)")
|
|
continue
|
|
|
|
cutoff = cutoffs.get(symbol) or cutoffs.get(row["symbol"])
|
|
frozen_at = frozen_run_start(bars)
|
|
if cutoff is not None:
|
|
kept, dropped = apply_reviewed_cutoff(bars, cutoff)
|
|
if not kept or kept[-1].timestamp.date() != cutoff:
|
|
unresolved.append({
|
|
"symbol": symbol,
|
|
"kind": "coverage",
|
|
"why": f"reviewed cutoff {cutoff} is not a bar in the downloaded series",
|
|
})
|
|
print(f" {symbol:<6} CUTOFF MISSING {cutoff} (unresolved coverage)")
|
|
continue
|
|
elif frozen_at is not None:
|
|
# Detector only. An unreviewed frozen run means the data changed
|
|
# under us; restoring either half would be a guess, so report it.
|
|
unresolved.append({
|
|
"kind": "coverage",
|
|
"symbol": symbol,
|
|
"why": (
|
|
f"unreviewed frozen run of >= {ZERO_VOLUME_RUN} zero-volume sessions "
|
|
f"from {bars[frozen_at].timestamp.date()}; needs a reviewed cutoff "
|
|
"before this symbol can be restored"
|
|
),
|
|
})
|
|
print(f" {symbol:<6} FROZEN RUN at {bars[frozen_at].timestamp.date()} (unresolved, not restored)")
|
|
continue
|
|
else:
|
|
kept, dropped = bars, 0
|
|
|
|
effective_last = kept[-1].timestamp.date()
|
|
confirmation = confirmations.get(row["symbol"]) or confirmations.get(symbol)
|
|
terminal_date = None
|
|
if confirmation is not None:
|
|
confirmed_last = date.fromisoformat(confirmation["last_real_bar"])
|
|
if effective_last != confirmed_last:
|
|
unresolved.append({
|
|
"symbol": symbol,
|
|
"kind": "terminal_mark",
|
|
"why": (
|
|
f"retained final bar {effective_last} does not match the reviewed "
|
|
f"last_real_bar {confirmed_last}; no terminal mark written"
|
|
),
|
|
})
|
|
print(f" {symbol:<6} MARK SKIPPED final bar {effective_last} != confirmed {confirmed_last}")
|
|
else:
|
|
terminal_date = next_session(calendar, effective_last)
|
|
if terminal_date is None or terminal_date > window_end:
|
|
terminal_date = None
|
|
unresolved.append({
|
|
"symbol": symbol,
|
|
"kind": "terminal_mark",
|
|
"why": "confirmed terminal event has no trading session left inside the window",
|
|
})
|
|
else:
|
|
terminal_events[symbol] = terminal_date.isoformat()
|
|
|
|
# Documentary only, and only where the event is confirmed. A series
|
|
# that merely ends early is an unresolved ending, not a delisting.
|
|
delisted_on = None
|
|
delisted_reason = None
|
|
if terminal_date is not None:
|
|
delisted_on = terminal_date.isoformat()
|
|
delisted_reason = row["reason"]
|
|
elif effective_last < window_end and row["reason"] not in ENDING_EXPLAINED:
|
|
unresolved.append({
|
|
"kind": "ending_status",
|
|
"symbol": symbol,
|
|
"why": (
|
|
f"price history ends {effective_last}, before the window closes on "
|
|
f"{window_end}, and the source row ({row['reason']}) does not explain "
|
|
"an ending; ending status unresolved and delisting fields left null"
|
|
),
|
|
})
|
|
|
|
written = insert_symbol(
|
|
con, symbol, row["name"], kept,
|
|
delisted_on=delisted_on, delisted_reason=delisted_reason,
|
|
terminal_date=terminal_date,
|
|
)
|
|
restored.append({
|
|
"symbol": symbol,
|
|
"removed_on": row["removed_on"],
|
|
"reason": row["reason"],
|
|
"bars": written,
|
|
"first_bar": kept[0].timestamp.date().isoformat(),
|
|
"effective_last_bar": effective_last.isoformat(),
|
|
"frozen_bars_dropped": dropped,
|
|
"terminal_mark": terminal_events.get(symbol),
|
|
})
|
|
mark = f" terminal={terminal_events[symbol]} @ {TERMINAL_CLOSE}" if symbol in terminal_events else ""
|
|
drop = f" dropped={dropped}" if dropped else ""
|
|
print(f" {symbol:<6} bars={written:<5} {kept[0].timestamp.date()}..{effective_last}{drop}{mark}")
|
|
|
|
con.commit()
|
|
finally:
|
|
con.close()
|
|
|
|
events_path.write_text(json.dumps(terminal_events, indent=2) + "\n", encoding="utf-8")
|
|
|
|
if file_hash(base) != base_hash_before:
|
|
raise SystemExit(f"BASE SNAPSHOT WAS MODIFIED: {base}")
|
|
|
|
con_a = sqlite3.connect(arm_a)
|
|
con_b = sqlite3.connect(arm_b)
|
|
counts = {
|
|
"arm_a_tickers": con_a.execute("SELECT count(*) FROM tickers").fetchone()[0],
|
|
"arm_a_bars": con_a.execute("SELECT count(*) FROM ohlcv_records").fetchone()[0],
|
|
"arm_b_tickers": con_b.execute("SELECT count(*) FROM tickers").fetchone()[0],
|
|
"arm_b_bars": con_b.execute("SELECT count(*) FROM ohlcv_records").fetchone()[0],
|
|
}
|
|
con_a.close()
|
|
con_b.close()
|
|
|
|
by_kind: dict[str, list[dict]] = {}
|
|
for row in unresolved:
|
|
by_kind.setdefault(row.get("kind", "other"), []).append(row)
|
|
|
|
print("\nsummary")
|
|
print(f" restored : {len(restored)}")
|
|
print(f" unresolved coverage: {len(by_kind.get('coverage', []))} (symbols not restored)")
|
|
print(f" unresolved endings : {len(by_kind.get('ending_status', []))} (restored, ending status unknown)")
|
|
print(f" terminal marks : {len(terminal_events)} -> {events_path.name}")
|
|
print(f" arm A : {counts['arm_a_tickers']} tickers, {counts['arm_a_bars']} bars")
|
|
print(f" arm B : {counts['arm_b_tickers']} tickers, {counts['arm_b_bars']} bars")
|
|
print(f" base unchanged : sha256 {base_hash_before[:16]}...")
|
|
for kind in ("coverage", "ending_status", "terminal_mark"):
|
|
for row in by_kind.get(kind, []):
|
|
print(f" UNRESOLVED[{kind}] {row['symbol']:<6} {row['why']}")
|
|
|
|
manifest = {
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
"base": str(base),
|
|
"base_sha256": base_hash_before,
|
|
"window": {"start": window_start.isoformat(), "end": window_end.isoformat()},
|
|
"zero_volume_run": ZERO_VOLUME_RUN,
|
|
"terminal_close": TERMINAL_CLOSE,
|
|
"counts": counts,
|
|
"restored": restored,
|
|
"unresolved": unresolved,
|
|
"terminal_events": terminal_events,
|
|
}
|
|
manifest_path = arm_b.with_name(arm_b.stem + "-prep-manifest.json")
|
|
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
|
print(f" manifest : {manifest_path.name}")
|
|
return 0
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
p.add_argument("--base", default=str(DEFAULT_BASE), help="Base snapshot to copy (never modified).")
|
|
p.add_argument("--out-dir", default=str(DEFAULT_BASE.parent), help="Where the arm copies are written.")
|
|
p.add_argument("--arm-a-name", default="survivorship-a.sqlite")
|
|
p.add_argument("--arm-b-name", default="survivorship-b.sqlite")
|
|
p.add_argument("--removals", default=str(DEFAULT_REMOVALS))
|
|
p.add_argument("--force", action="store_true", help="Rebuild the arm files if they already exist.")
|
|
return p.parse_args()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(asyncio.run(build(parse_args())))
|