Fix manual refresh dropping qualified ranks and clarify trade UI.
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:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user