From fbca38e14441bfe7b5988fc50551821fdf00eba1 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Tue, 11 Aug 2026 16:10:57 +0200 Subject: [PATCH] fix(backtest): roll back the portfolio-sim DB failures too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass guarded the replay loop but not the portfolio-simulation block, which re-fetches price columns and loads the benchmark and the live exit policy from the same session much later. A failure in any of those swallows the exception without clearing the transaction — the identical failure mode, with the identical symptom: the report write is the first unguarded statement and takes the blame. The outer handler is the backstop for the price_columns loop, which has no handler of its own; rolling back a session an inner handler already cleared is a no-op. Co-Authored-By: Claude Opus 5 --- app/services/backtest_service.py | 7 +++++++ tests/unit/test_backtest_service.py | 32 +++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/app/services/backtest_service.py b/app/services/backtest_service.py index 45aa35a..beb978c 100644 --- a/app/services/backtest_service.py +++ b/app/services/backtest_service.py @@ -4230,6 +4230,7 @@ async def run_backtest( ) except Exception: logger.exception("Benchmark load for the portfolio sim failed") + await _rollback_quietly(db, "portfolio-sim benchmark load") for policy in ("target", "hold"): sim = _simulate_portfolio( @@ -4250,6 +4251,7 @@ async def run_backtest( live_exit_policy = await get_exit_policy(db) except Exception: logger.exception("Live exit policy load failed; monitor uses defaults") + await _rollback_quietly(db, "exit policy load") portfolio_monitor_report = _portfolio_monitor( candidates, price_columns, spy_closes, hold_horizon, live_exit_policy=live_exit_policy, @@ -4269,6 +4271,11 @@ async def run_backtest( ) except Exception: logger.exception("Portfolio simulation failed") + # Catches the price_columns fetch loop, which has no handler of its + # own. The inner handlers above may already have rolled back; a + # rollback on a clean session is a no-op, so this stays safe as the + # backstop for whichever DB call actually failed. + await _rollback_quietly(db, "portfolio simulation") report = { "generated_at": datetime.now(timezone.utc).isoformat(), diff --git a/tests/unit/test_backtest_service.py b/tests/unit/test_backtest_service.py index 9742582..c196a9c 100644 --- a/tests/unit/test_backtest_service.py +++ b/tests/unit/test_backtest_service.py @@ -1656,3 +1656,35 @@ async def test_run_backtest_rolls_back_a_failed_ticker_fetch(session, monkeypatc # the surviving ticker is still replayed after the rollback assert report["tickers"] == 2 assert report["candidates"] >= 1 + + +async def test_run_backtest_rolls_back_a_failed_portfolio_sim_load(session, monkeypatch): + """The portfolio-sim block loads the benchmark and the live exit policy from + the same session, well after the replay loop. A failure there poisons the + transaction exactly as one in the loop does, and the report write pays for it. + """ + await _seed_oscillating_ticker(session, "OSC") + + rolled_back: list[str] = [] + called: list[str] = [] + + async def failing_exit_policy(db): + called.append("x") + raise RuntimeError("simulated exit-policy read failure") + + real_rollback = session.rollback + + async def tracking_rollback(): + rolled_back.append("x") + await real_rollback() + + monkeypatch.setattr( + "app.services.paper_trade_service.get_exit_policy", failing_exit_policy + ) + monkeypatch.setattr(session, "rollback", tracking_rollback) + + report = await bt.run_backtest(session) + + assert called, "the portfolio-sim block never ran; test proves nothing" + assert rolled_back, "a failed portfolio-sim load left the session un-rolled-back" + assert report["tickers"] == 1