fix(backtest): roll back the portfolio-sim DB failures too
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 <noreply@anthropic.com>
This commit is contained in:
@@ -4230,6 +4230,7 @@ async def run_backtest(
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Benchmark load for the portfolio sim failed")
|
logger.exception("Benchmark load for the portfolio sim failed")
|
||||||
|
await _rollback_quietly(db, "portfolio-sim benchmark load")
|
||||||
|
|
||||||
for policy in ("target", "hold"):
|
for policy in ("target", "hold"):
|
||||||
sim = _simulate_portfolio(
|
sim = _simulate_portfolio(
|
||||||
@@ -4250,6 +4251,7 @@ async def run_backtest(
|
|||||||
live_exit_policy = await get_exit_policy(db)
|
live_exit_policy = await get_exit_policy(db)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Live exit policy load failed; monitor uses defaults")
|
logger.exception("Live exit policy load failed; monitor uses defaults")
|
||||||
|
await _rollback_quietly(db, "exit policy load")
|
||||||
portfolio_monitor_report = _portfolio_monitor(
|
portfolio_monitor_report = _portfolio_monitor(
|
||||||
candidates, price_columns, spy_closes, hold_horizon,
|
candidates, price_columns, spy_closes, hold_horizon,
|
||||||
live_exit_policy=live_exit_policy,
|
live_exit_policy=live_exit_policy,
|
||||||
@@ -4269,6 +4271,11 @@ async def run_backtest(
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Portfolio simulation failed")
|
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 = {
|
report = {
|
||||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
|||||||
@@ -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
|
# the surviving ticker is still replayed after the rollback
|
||||||
assert report["tickers"] == 2
|
assert report["tickers"] == 2
|
||||||
assert report["candidates"] >= 1
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user