From 9c48eb20c5167fc9ce4a5abb6c837c2bee749050 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Fri, 25 Sep 2026 13:17:51 +0200 Subject: [PATCH] Fix backtest benchmark read after optional table probe --- app/services/backtest_service.py | 9 ++++++--- tests/unit/test_backtest_service.py | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/app/services/backtest_service.py b/app/services/backtest_service.py index 93ce5a8..777f23a 100644 --- a/app/services/backtest_service.py +++ b/app/services/backtest_service.py @@ -937,13 +937,16 @@ async def _load_research_rank_only_symbols(db: AsyncSession) -> set[str]: """Symbols that feed signal IC only (no GTL/candidate replay). Optional side table ``research_rank_only`` on research snapshots. Missing - table → empty set (production path unchanged). + table → empty set (production path unchanged). Probe within a savepoint: + PostgreSQL aborts the current transaction after a missing-table error, so + catching that error alone would break the subsequent benchmark read. """ from sqlalchemy import text try: - result = await db.execute(text("SELECT symbol FROM research_rank_only")) - return {str(row[0]).upper() for row in result.fetchall() if row[0]} + async with db.begin_nested(): + result = await db.execute(text("SELECT symbol FROM research_rank_only")) + return {str(row[0]).upper() for row in result.fetchall() if row[0]} except Exception: return set() diff --git a/tests/unit/test_backtest_service.py b/tests/unit/test_backtest_service.py index 391d84e..fab3100 100644 --- a/tests/unit/test_backtest_service.py +++ b/tests/unit/test_backtest_service.py @@ -9,9 +9,11 @@ from types import SimpleNamespace import pytest +from app.models.benchmark_price import BenchmarkPrice from app.models.ohlcv import OHLCVRecord from app.models.ticker import Ticker from app.services import backtest_service as bt +from app.services.benchmark_service import load_benchmark_closes from app.services.outcome_service import ( OUTCOME_EXPIRED, OUTCOME_STOP_HIT, @@ -1600,6 +1602,30 @@ def _pin_benchmark(monkeypatch, closes: dict | None = None) -> None: monkeypatch.setattr(bt, "_load_benchmark_closes_for_backtest", _stub) +async def test_missing_research_table_does_not_abort_benchmark_read(session, monkeypatch): + """The optional research table is absent online; its failed probe must be + isolated so PostgreSQL can still read the SPY prices in the same session. + """ + close_date = date(2025, 1, 2) + session.add(BenchmarkPrice(symbol="SPY", date=close_date, close=470.0)) + await session.commit() + + real_execute = session.execute + probed_in_savepoint = False + + async def track_probe(statement, *args, **kwargs): + nonlocal probed_in_savepoint + if str(statement) == "SELECT symbol FROM research_rank_only": + probed_in_savepoint = session.in_nested_transaction() + return await real_execute(statement, *args, **kwargs) + + monkeypatch.setattr(session, "execute", track_probe) + + assert await bt._load_research_rank_only_symbols(session) == set() + assert probed_in_savepoint + assert await load_benchmark_closes(session) == {close_date: 470.0} + + async def _seed_oscillating_ticker(session, symbol: str, n: int = 160) -> None: t = Ticker(symbol=symbol) session.add(t)