Scan survives score-refresh failures; fix expired-ORM crash after rollback

A scoring error no longer skips setup detection for the ticker: the
rollback already restores a clean transaction, and qualification
re-gates on live scores at alert time, so a stale score is recoverable
but a skipped scan is not.

Iterating symbol strings instead of Ticker instances fixes a latent
crash the new regression test caught: rollback() expires ORM objects
regardless of expire_on_commit, so touching ticker.symbol in the except
handler triggered sync lazy-loading, which raises on an AsyncSession
and killed the whole scan on the first per-ticker error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 16:29:06 +02:00
co-authored by Claude Fable 5
parent 9450831ef3
commit 292b9934b1
2 changed files with 80 additions and 15 deletions
+19 -15
View File
@@ -553,9 +553,12 @@ async def scan_all_tickers(
``progress_callback(processed, total, current_symbol)`` is invoked as each
ticker is scanned so callers (e.g. the scheduler) can surface live progress.
"""
result = await db.execute(select(Ticker).order_by(Ticker.symbol))
tickers = list(result.scalars().all())
total = len(tickers)
# Plain strings, not Ticker instances: the rollbacks below expire any ORM
# objects held across them, and touching an expired attribute afterwards
# triggers sync lazy-loading, which raises on an AsyncSession.
result = await db.execute(select(Ticker.symbol).order_by(Ticker.symbol))
symbols = list(result.scalars().all())
total = len(symbols)
# Rank the universe up front so each new setup carries both the residual
# activation gate percentile and the promoted production ordering score.
@@ -571,34 +574,35 @@ async def scan_all_tickers(
ranks = {}
all_setups: list[TradeSetup] = []
for index, ticker in enumerate(tickers):
for index, symbol in enumerate(symbols):
if progress_callback is not None:
progress_callback(index, total, ticker.symbol)
progress_callback(index, total, symbol)
# Refresh scores first so the scheduled scan works off current data.
# Nothing else marks scores stale, so without this they'd never update
# for tickers the user doesn't manually fetch.
# for tickers the user doesn't manually fetch. A refresh failure still
# scans the ticker: qualification re-gates on live scores at alert
# time, so a stale score is recoverable but a skipped scan is not.
try:
from app.services import scoring_service
await scoring_service.compute_all_dimensions(db, ticker.symbol)
await scoring_service.compute_composite_score(db, ticker.symbol)
await scoring_service.compute_all_dimensions(db, symbol)
await scoring_service.compute_composite_score(db, symbol)
await db.commit()
except Exception:
await db.rollback()
logger.exception("Error refreshing scores for %s", ticker.symbol)
continue
logger.exception("Error refreshing scores for %s", symbol)
try:
setups = await scan_ticker(
db, ticker.symbol, rr_threshold, atr_multiplier,
momentum_percentile=(ranks.get(ticker.symbol) or {}).get("momentum_percentile"),
strategy_rank=(ranks.get(ticker.symbol) or {}).get("strategy_rank"),
volatility_percentile=(ranks.get(ticker.symbol) or {}).get("volatility_percentile"),
db, symbol, rr_threshold, atr_multiplier,
momentum_percentile=(ranks.get(symbol) or {}).get("momentum_percentile"),
strategy_rank=(ranks.get(symbol) or {}).get("strategy_rank"),
volatility_percentile=(ranks.get(symbol) or {}).get("volatility_percentile"),
)
all_setups.extend(setups)
except Exception:
await db.rollback()
logger.exception("Error scanning ticker %s", ticker.symbol)
logger.exception("Error scanning ticker %s", symbol)
if progress_callback is not None and total:
progress_callback(total, total, "")