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