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
+12 -1
View File
@@ -23,7 +23,10 @@ from app.models.ticker import Ticker
from app.models.user import User
from app.providers.alpaca import AlpacaOHLCVProvider
from app.providers.fundamentals_chain import build_fundamental_provider_chain
from app.services.rr_scanner_service import scan_ticker
from app.services.rr_scanner_service import (
resolve_activation_ranks_for_symbol,
scan_ticker,
)
from app.services.sentiment_provider_service import build_sentiment_provider
from app.schemas.common import APIEnvelope
from app.services import (
@@ -216,15 +219,23 @@ async def fetch_symbol(
sources_out["scores"] = {"status": "error", "message": str(exc)}
# --- Derived pipeline: scanner (free, always) ---
# Attach the same residual-momentum / strategy ranks the daily scan writes.
# Without them the new setup lands with null momentum_percentile and fails
# the activation gate (missing ranks do not qualify).
try:
ranks = await resolve_activation_ranks_for_symbol(db, symbol_upper)
setups = await scan_ticker(
db,
symbol_upper,
rr_threshold=settings.default_rr_threshold,
momentum_percentile=ranks.get("momentum_percentile"),
strategy_rank=ranks.get("strategy_rank"),
volatility_percentile=ranks.get("volatility_percentile"),
)
sources_out["scanner"] = {
"status": "ok",
"setups_found": len(setups),
"momentum_percentile": ranks.get("momentum_percentile"),
"message": None,
}
except Exception as exc:
+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,