Files
signal-platform/scripts/create_backtest_snapshot.py
T
dennisthiessenandClaude Opus 4.8 85b3ef618f Research: S/R levels, the target exit, and the entry gate
Investigated whether our support/resistance detection follows best practice
and whether we actually use it that way. Three findings, all backed by runs
against the prod snapshot and written up in docs/research/sr-levels-and-exits.md:

- The S/R target must NOT become an exit. Honoring it as a take-profit on top
  of the 3x ATR trail drops Sharpe 2.04 -> 1.47 and halves CAGR. Win rate rises
  (37.5% -> 40.0%), which is the tell: it truncates the right tail where
  momentum's edge lives.
- The clear-air fallback (synthesize a 3xATR target where no resistance exists,
  so 52-week-high breakouts stop being vetoed) looked strictly better in-sample
  (Sharpe 2.04 -> 2.07, CAGR 50.4% -> 62.3%, DD 21.4% -> 20.1%) but FAILED a
  real out-of-sample holdout: on entries after 2024-07-01 it is worse on Sharpe
  (2.78 -> 2.45) and Calmar, better only on raw CAGR. Not shipped.
- The detector itself is weak vs best practice (POC/VAH/VAL computed then
  discarded, HVN = any above-mean bin, 1.48x volume double-counting, "touch"
  counts pass-throughs, no round numbers), but its only causal path to P&L is
  the entry gate. Fix it for the displayed levels, not for returns.

Method note: nested lookback windows are NOT out-of-sample. The in-sample result
was clean, large, and consistent across five windows, and still did not survive
a proper entry-date split.

All research paths are off by default and the default report is unchanged:
  BACKTEST_RESEARCH_EXITS=1        take-profit exit rows
  BACKTEST_ATR_TARGET_FALLBACK=k   synthetic k*ATR target when S/R offers none
  BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1  restrict that to genuinely clear air
  BACKTEST_HOLDOUT_SPLIT=YYYY-MM-DD   train/test split by entry date

Also fixes two reproducibility holes found while reconciling our local baseline
against the live report:

- create_backtest_snapshot.py now copies paper_% settings. The production
  monitor row replays the runtime exit policy via get_exit_policy(); without
  those keys a snapshot silently falls back to code defaults, so a live-tuned
  exit would never be reflected.
- Migration 020 drops activation_min_expected_value and
  activation_min_target_probability. Both are orphans of the June EV-gate
  redesign, read by no code path, but prod carries min_target_probability = 50.0
  which implies a probability floor that is not enforced (the real floor is the
  20% constant in qualification.py).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 15:05:34 +02:00

169 lines
5.9 KiB
Python

"""Create a minimal local SQLite snapshot for offline backtest research.
Copies only the data required by app.services.backtest_service.run_backtest:
tickers, OHLCV bars, SPY benchmark closes, and the activation / recommendation /
paper-exit settings the run reads. Other system settings are intentionally
skipped to avoid copying secrets into local snapshot files.
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
from pathlib import Path
from sqlalchemy import func, insert, or_, select
from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def _normalize_postgres_url(url: str) -> str:
if url.startswith("postgresql+asyncpg://"):
return url
if url.startswith("postgresql://"):
return "postgresql+asyncpg://" + url[len("postgresql://") :]
if url.startswith("postgres://"):
return "postgresql+asyncpg://" + url[len("postgres://") :]
return url
def _sqlite_url(path: Path) -> str:
return f"sqlite+aiosqlite:///{path.resolve().as_posix()}"
def _hide_password(url: str) -> str:
return make_url(url).render_as_string(hide_password=True)
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--database-url",
default=os.getenv("DATABASE_URL"),
help="Source Postgres URL. Defaults to DATABASE_URL, then app .env database_url.",
)
parser.add_argument(
"--output",
default="backtest_snapshots/prod-backtest.sqlite",
help="SQLite snapshot path to create.",
)
parser.add_argument("--batch-size", type=int, default=5000)
parser.add_argument("--force", action="store_true", help="Overwrite an existing snapshot file.")
return parser.parse_args()
async def _copy_table(
source: AsyncSession,
dest: AsyncSession,
model: type,
*,
batch_size: int,
where=None,
) -> int:
table = model.__table__
columns = list(table.columns)
count_stmt = select(func.count()).select_from(table)
stmt = select(*columns)
if where is not None:
count_stmt = count_stmt.where(where)
stmt = stmt.where(where)
primary_key_columns = list(table.primary_key.columns)
if primary_key_columns:
stmt = stmt.order_by(*primary_key_columns)
expected = int((await source.execute(count_stmt)).scalar_one())
if expected == 0:
print(f"{table.name}: 0 rows")
return 0
copied = 0
stream = await source.stream(stmt.execution_options(yield_per=batch_size))
async for partition in stream.partitions(batch_size):
rows = [dict(row._mapping) for row in partition]
if not rows:
continue
await dest.execute(insert(table), rows)
await dest.commit()
copied += len(rows)
print(f"{table.name}: {copied}/{expected}", end="\r")
print(f"{table.name}: {copied} rows")
return copied
async def _main() -> None:
args = _parse_args()
from app.config import settings
from app.database import Base
import app.models # noqa: F401 - registers all metadata tables
from app.models.benchmark_price import BenchmarkPrice
from app.models.ohlcv import OHLCVRecord
from app.models.settings import SystemSetting
from app.models.ticker import Ticker
source_url = _normalize_postgres_url(args.database_url or settings.database_url)
output = Path(args.output)
if output.exists():
if not args.force:
raise SystemExit(f"{output} already exists. Pass --force to overwrite it.")
output.unlink()
output.parent.mkdir(parents=True, exist_ok=True)
source_engine = create_async_engine(
source_url,
pool_pre_ping=True,
connect_args={"server_settings": {"default_transaction_read_only": "on"}},
)
dest_engine = create_async_engine(_sqlite_url(output))
SourceSession = async_sessionmaker(source_engine, class_=AsyncSession, expire_on_commit=False)
DestSession = async_sessionmaker(dest_engine, class_=AsyncSession, expire_on_commit=False)
print(f"Source: {_hide_password(source_url)}")
print(f"Snapshot: {output}")
try:
async with dest_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with SourceSession() as source, DestSession() as dest:
counts = {
"tickers": await _copy_table(source, dest, Ticker, batch_size=args.batch_size),
"system_settings": await _copy_table(
source,
dest,
SystemSetting,
batch_size=args.batch_size,
where=or_(
SystemSetting.key.like("activation_%"),
SystemSetting.key.like("recommendation_%"),
# The production portfolio-monitor row replays the RUNTIME
# exit policy via get_exit_policy(). Without these keys a
# snapshot silently falls back to the code defaults, so a
# live-tuned exit would not be reflected — the snapshot run
# would disagree with prod and give no hint why.
SystemSetting.key.like("paper_%"),
),
),
"benchmark_prices": await _copy_table(source, dest, BenchmarkPrice, batch_size=args.batch_size),
"ohlcv_records": await _copy_table(source, dest, OHLCVRecord, batch_size=args.batch_size),
}
finally:
await source_engine.dispose()
await dest_engine.dispose()
print("Done:")
for name, count in counts.items():
print(f" {name}: {count}")
if __name__ == "__main__":
asyncio.run(_main())