fix(backtest): roll back the session after a swallowed DB failure

Every DB call in run_backtest is best-effort so one unreadable ticker cannot
abort the whole replay, but the handlers swallowed the exception without
clearing the transaction. asyncpg then reports "current transaction is
aborted" for every later statement, and the first unguarded one — the report
write — surfaced it as the job error, long after the real cause.

Add _rollback_quietly at the three swallowing sites (benchmark load, parallel
fetch, sequential replay), matching the guard price_service already uses.

Load plain symbols instead of Ticker instances: a rollback expires ORM objects
held across it, and touching an expired attribute afterwards triggers sync
lazy-loading, which raises on an AsyncSession. rr_scanner_service hit this
same trap. Only .symbol was ever used.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 14:20:19 +02:00
co-authored by Claude Opus 5
parent d02fd82ced
commit 6ca7f13779
2 changed files with 72 additions and 15 deletions
+36
View File
@@ -1620,3 +1620,39 @@ async def test_run_backtest_smoke(session):
sweep = sorted(report["sweep"], key=lambda r: r["min_momentum_percentile"], reverse=True)
counts = [r["total"] for r in sweep]
assert counts == sorted(counts) # ascending as threshold descends
async def test_run_backtest_rolls_back_a_failed_ticker_fetch(session, monkeypatch):
"""A failed per-ticker read must not leave the session mid-failed-transaction.
Every DB call in the replay loop is best-effort, but swallowing the error
without a rollback leaves asyncpg in "current transaction is aborted": every
later statement fails the same way until the first unguarded one — the report
write — surfaces it as the job error, long after the real cause.
"""
await _seed_oscillating_ticker(session, "AAA")
await _seed_oscillating_ticker(session, "OSC")
real_fetch = bt._fetch_columns
rolled_back: list[str] = []
async def failing_fetch(db, symbol):
if symbol == "AAA":
raise RuntimeError("simulated OHLCV read failure")
return await real_fetch(db, symbol)
real_rollback = session.rollback
async def tracking_rollback():
rolled_back.append("x")
await real_rollback()
monkeypatch.setattr(bt, "_fetch_columns", failing_fetch)
monkeypatch.setattr(session, "rollback", tracking_rollback)
report = await bt.run_backtest(session)
assert rolled_back, "a failed ticker fetch left the session un-rolled-back"
# the surviving ticker is still replayed after the rollback
assert report["tickers"] == 2
assert report["candidates"] >= 1