Fix manual refresh dropping qualified ranks and clarify trade UI.
Deploy / lint (push) Successful in 9s
Deploy / test (push) Successful in 1m20s
Deploy / deploy (push) Successful in 39s

Single-ticker fetch now attaches residual-momentum ranks so setups do not silently fail the activation gate. Exit plan is a timeline, chart labels move left of the price scale, and missing ranks surface explicitly.
This commit is contained in:
2026-07-14 10:10:30 +02:00
parent cd7dc7973c
commit 8db535b889
10 changed files with 468 additions and 94 deletions
+71
View File
@@ -428,6 +428,77 @@ async def _create_signal_context_snapshots(
)
async def resolve_activation_ranks_for_symbol(
db: AsyncSession,
symbol: str,
) -> dict[str, float | None]:
"""Universe activation ranks for one symbol (manual single-ticker scans).
The daily ``scan_all_tickers`` path ranks the whole universe once and passes
percentiles into ``scan_ticker``. Manual refresh must do the same: without
``momentum_percentile`` the activation gate treats the setup as unranked and
it silently drops out of qualified trades.
Prefer a fresh cross-sectional rank; if ranking fails or the symbol is
missing from the universe slice, fall back to the most recent prior setup
that still carries ranks so a refresh never zeroes the gate inputs.
"""
symbol_u = symbol.strip().upper()
empty: dict[str, float | None] = {
"momentum_percentile": None,
"strategy_rank": None,
"volatility_percentile": None,
}
try:
from app.services import momentum_service
ranks = await momentum_service.compute_activation_ranks(db)
hit = ranks.get(symbol_u)
if hit is not None and hit.get("momentum_percentile") is not None:
return {
"momentum_percentile": hit.get("momentum_percentile"),
"strategy_rank": hit.get("strategy_rank"),
"volatility_percentile": hit.get("volatility_percentile"),
}
except Exception:
logger.exception(
"Activation ranking failed for single-ticker scan of %s", symbol_u
)
ticker_result = await db.execute(
select(Ticker.id).where(Ticker.symbol == symbol_u)
)
ticker_id = ticker_result.scalar_one_or_none()
if ticker_id is None:
return empty
prev_result = await db.execute(
select(TradeSetup)
.where(
TradeSetup.ticker_id == ticker_id,
TradeSetup.momentum_percentile.is_not(None),
)
.order_by(TradeSetup.detected_at.desc(), TradeSetup.id.desc())
.limit(1)
)
prev = prev_result.scalar_one_or_none()
if prev is None:
return empty
return {
"momentum_percentile": (
float(prev.momentum_percentile) if prev.momentum_percentile is not None else None
),
"strategy_rank": (
float(prev.strategy_rank) if prev.strategy_rank is not None else None
),
"volatility_percentile": (
float(prev.volatility_percentile) if prev.volatility_percentile is not None else None
),
}
async def scan_ticker(
db: AsyncSession,
symbol: str,