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:
2026-08-11 16:10:57 +02:00
co-authored by Claude Opus 5
parent 6ca7f13779
commit fbca38e144
2 changed files with 39 additions and 0 deletions
+7
View File
@@ -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(),