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,
@@ -148,8 +148,17 @@ export function CandlestickChart({
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, W, H);
// Margins
const ml = 12, mr = 70, mt = 12, mb = 32;
// Current price = explicit prop, else latest close
const livePrice = currentPrice ?? visibleData[visibleData.length - 1].close;
// Only the nearest support/resistance are drawn — keep the chart legible
const markers = nearestSRMarkers(srLevels, zones, livePrice);
// Margins: line labels (Entry / Stop / Support…) on the LEFT, pure price
// ticks on the RIGHT. Packing both on the right made trade overlays unreadable.
const hasLineLabels = Boolean(tradeSetup) || markers.length > 0;
const ml = hasLineLabels ? 78 : 16;
const mr = 58;
const mt = 12, mb = 32;
const cw = W - ml - mr;
const volumeH = VOLUME_PANE_HEIGHT;
const ch = H - mt - mb - volumeH - PANE_GAP;
@@ -157,11 +166,6 @@ export function CandlestickChart({
const volumeTop = priceBottom + PANE_GAP;
const volumeBottom = volumeTop + volumeH;
// Current price = explicit prop, else latest close
const livePrice = currentPrice ?? visibleData[visibleData.length - 1].close;
// Only the nearest support/resistance are drawn — keep the chart legible
const markers = nearestSRMarkers(srLevels, zones, livePrice);
// Price range from visible data
const allPrices = visibleData.flatMap((b) => [b.high, b.low]);
const srPrices = markers.flatMap((m) => [m.low, m.high]);
@@ -180,13 +184,13 @@ export function CandlestickChart({
const maxVolume = Math.max(...visibleData.map((b) => Math.max(0, b.volume)), 1);
const volumeScale = (v: number) => volumeTop + volumeH - (Math.max(0, v) / maxVolume) * volumeH;
// Grid lines (horizontal)
// Grid lines (horizontal) + pure price scale on the RIGHT
const nTicks = 6;
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 1;
ctx.fillStyle = '#6e7484';
ctx.font = '11px "IBM Plex Mono", ui-monospace, monospace';
ctx.textAlign = 'right';
ctx.textAlign = 'left';
for (let i = 0; i <= nTicks; i++) {
const v = lo + ((hi - lo) * i) / nTicks;
const y = yScale(v);
@@ -194,7 +198,7 @@ export function CandlestickChart({
ctx.moveTo(ml, y);
ctx.lineTo(ml + cw, y);
ctx.stroke();
ctx.fillText(formatPrice(v), W - 8, y + 4);
ctx.fillText(formatPrice(v), ml + cw + 6, y + 4);
}
// X-axis labels
@@ -285,6 +289,10 @@ export function CandlestickChart({
ctx.restore();
}
// Collect left-side line labels; prices live on the right axis only.
type LineLabel = { y: number; text: string; color: string; weight?: 'normal' | 'bold' };
const lineLabels: LineLabel[] = [];
// Nearest support/resistance only (band if it came from a zone)
markers.forEach((m) => {
const isSupport = m.role === 'support';
@@ -311,14 +319,11 @@ export function CandlestickChart({
ctx.setLineDash([]);
ctx.globalAlpha = 1;
ctx.fillStyle = color;
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
ctx.textAlign = 'left';
ctx.fillText(
`${isSupport ? 'Support' : 'Resistance'} ${formatPrice(m.price)} (${m.strength})`,
ml + cw + 4,
yMid + 3,
);
lineLabels.push({
y: yMid,
text: isSupport ? 'Support' : 'Resist',
color,
});
});
// Trade setup overlay (drawn before candles so candles render on top)
@@ -363,15 +368,11 @@ export function CandlestickChart({
ctx.stroke();
ctx.setLineDash([]);
// Labels on right side
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
ctx.textAlign = 'left';
ctx.fillStyle = 'rgba(154, 160, 176, 0.95)';
ctx.fillText(`Entry ${formatPrice(tradeSetup.entry_price)}`, ml + cw + 4, entryY + 3);
ctx.fillStyle = 'rgba(239, 145, 130, 0.9)';
ctx.fillText(`SL ${formatPrice(tradeSetup.stop_loss)}`, ml + cw + 4, stopY + 3);
ctx.fillStyle = 'rgba(196, 181, 253, 0.95)';
ctx.fillText(`Gate ${formatPrice(tradeSetup.target)}`, ml + cw + 4, targetY + 3);
lineLabels.push(
{ y: entryY, text: 'Entry', color: 'rgba(154, 160, 176, 0.95)', weight: 'bold' },
{ y: stopY, text: 'Stop', color: 'rgba(239, 145, 130, 0.95)', weight: 'bold' },
{ y: targetY, text: 'Gate', color: 'rgba(196, 181, 253, 0.95)', weight: 'bold' },
);
}
// Current price line — the anchor for everything else (drawn on top)
@@ -384,15 +385,58 @@ export function CandlestickChart({
ctx.lineTo(ml + cw, py);
ctx.stroke();
const label = `Now ${formatPrice(livePrice)}`;
lineLabels.push({
y: py,
text: 'Now',
color: 'rgba(226, 232, 240, 0.95)',
weight: 'bold',
});
}
// Left-side role labels (no prices — those sit on the right axis). Spread
// stacked labels so Entry/Stop/Gate don't paint over each other when close.
if (lineLabels.length > 0) {
const ordered = [...lineLabels].sort((a, b) => a.y - b.y);
const minGap = 13;
for (let i = 1; i < ordered.length; i++) {
if (ordered[i].y - ordered[i - 1].y < minGap) {
ordered[i].y = ordered[i - 1].y + minGap;
}
}
// If we overflow the pane bottom, pull the stack back up.
const maxY = mt + ch - 4;
if (ordered.length > 0 && ordered[ordered.length - 1].y > maxY) {
let shift = ordered[ordered.length - 1].y - maxY;
for (let i = ordered.length - 1; i >= 0 && shift > 0; i--) {
const prevFloor = i === 0 ? mt + 4 : ordered[i - 1].y + minGap;
const room = ordered[i].y - prevFloor;
const pull = Math.min(shift, Math.max(0, room));
ordered[i].y -= pull;
shift -= pull;
}
}
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
const tw = ctx.measureText(label).width;
ctx.fillStyle = 'rgba(226, 232, 240, 0.95)';
ctx.fillRect(ml + 2, py - 8, tw + 8, 16);
ctx.fillStyle = '#0a0b11';
ctx.textAlign = 'left';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(label, ml + 6, py);
for (const label of ordered) {
ctx.fillStyle = label.color;
if (label.weight === 'bold') {
ctx.font = '600 10px "IBM Plex Mono", ui-monospace, monospace';
} else {
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
}
// Short connector tick from label into the plot edge
ctx.strokeStyle = label.color;
ctx.globalAlpha = 0.35;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(ml - 4, label.y);
ctx.lineTo(ml, label.y);
ctx.stroke();
ctx.globalAlpha = 1;
ctx.fillText(label.text, ml - 8, label.y);
}
ctx.textBaseline = 'alphabetic';
}
@@ -7,56 +7,119 @@ import { formatPrice } from '../../lib/format';
* Deliberately sits *above* the levels ladder: the levels are context, this is
* the plan. Before this existed the card showed a "Target" with the same visual
* weight as the entry, implying a take-profit that the live exit never fires.
*
* Laid out as a vertical timeline (not label↔value rows) so the sequence —
* stop protects → trail arms → trail rides → time stop — reads top to bottom.
*/
export function ExitPlanPanel({ plan, direction }: { plan: ExitPlan; direction: string }) {
const isLong = direction === 'long';
const steps = buildSteps(plan, isLong);
return (
<div className="mt-3 rounded-xl border border-white/[0.07] bg-white/[0.02] p-3">
<div className="mt-5 rounded-xl border border-white/[0.07] bg-white/[0.02] p-4">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className="num text-[10px] uppercase tracking-[0.16em] text-gray-500">how this exits</span>
<span className="text-[11.5px] font-medium text-gray-300">{plan.headline}</span>
</div>
<dl className="mt-2.5 grid gap-x-4 gap-y-1.5 text-[11.5px] sm:grid-cols-2">
<div className="flex items-baseline justify-between gap-2">
<dt className="text-gray-500">Initial stop</dt>
<dd className="num text-gray-200">
{formatPrice(plan.initialStop)}{' '}
<span className="text-gray-600">(1R = {formatPrice(plan.riskPerShare)}/sh)</span>
</dd>
</div>
{plan.mode === 'atr_trailing' && plan.trailTakesOverAt != null && plan.trailWidthR != null && (
<>
<div className="flex items-baseline justify-between gap-2">
<dt className="text-gray-500">Trail takes over</dt>
<dd className="num text-gray-200">
{isLong ? 'above' : 'below'} {formatPrice(plan.trailTakesOverAt)}
</dd>
</div>
<div className="flex items-baseline justify-between gap-2 sm:col-span-2">
<dt className="text-gray-500">Then it trails</dt>
<dd className="num text-gray-200">
{formatPrice(plan.trailWidth ?? 0)} ({plan.trailWidthR.toFixed(1)}R) below the highest close
</dd>
</div>
</>
<ol className="relative mt-4 space-y-0">
{steps.map((step, i) => {
const isLast = i === steps.length - 1;
return (
<li key={step.title} className="relative flex gap-3 pb-4 last:pb-0">
{/* Spine */}
<div className="flex w-5 shrink-0 flex-col items-center">
<span
className={`num mt-0.5 flex h-5 w-5 items-center justify-center rounded-full border text-[10px] font-semibold ${
step.tone === 'muted'
? 'border-white/[0.12] text-gray-500'
: 'border-blue-400/35 bg-blue-400/10 text-blue-200'
}`}
aria-hidden
>
{step.icon ?? i + 1}
</span>
{!isLast && (
<span className="mt-1 w-px flex-1 min-h-[12px] bg-white/[0.08]" aria-hidden />
)}
<div className="flex items-baseline justify-between gap-2">
<dt className="text-gray-500">Max hold</dt>
<dd className="num text-gray-200">{plan.maxHoldDays} trading days</dd>
</div>
</dl>
<div className="min-w-0 flex-1 pt-0.5">
<p className="text-[12px] font-medium text-gray-200">{step.title}</p>
{step.primary && (
<p className="num mt-0.5 text-[13px] font-semibold tracking-tight text-gray-100">
{step.primary}
</p>
)}
{step.detail && (
<p className="mt-0.5 text-[11.5px] leading-snug text-gray-500">{step.detail}</p>
)}
</div>
</li>
);
})}
</ol>
{!plan.honorsTarget && (
<p className="mt-2.5 border-t border-white/[0.05] pt-2 text-[11px] leading-relaxed text-gray-500">
There is <span className="text-gray-400">no take-profit</span>. Winners are ridden until the
trailing stop is hit that&rsquo;s where the strategy&rsquo;s edge comes from, so hitting a level
below is not a reason to sell. The levels shown below are screening context, not exits.
<p className="mt-1 border-t border-white/[0.05] pt-3 text-[11px] leading-relaxed text-gray-500">
Levels on the chart are screening context, not sell targets.
</p>
)}
</div>
);
}
interface ExitStep {
title: string;
primary?: string;
detail?: string;
/** Optional glyph instead of a step number (e.g. clock for time stop). */
icon?: string;
tone?: 'default' | 'muted';
}
function buildSteps(plan: ExitPlan, isLong: boolean): ExitStep[] {
const steps: ExitStep[] = [
{
title: 'Protected by stop',
primary: formatPrice(plan.initialStop),
detail: `Risk 1R = ${formatPrice(plan.riskPerShare)} per share until the trail is live.`,
},
];
if (plan.mode === 'atr_trailing' && plan.trailTakesOverAt != null && plan.trailWidthR != null) {
steps.push({
title: 'Trail arms',
primary: `${isLong ? 'above' : 'below'} ${formatPrice(plan.trailTakesOverAt)}`,
detail: 'When the trail would sit beyond the initial stop, it takes over as the floor.',
});
steps.push({
title: 'Then rides the trail',
primary: `${formatPrice(plan.trailWidth ?? 0)} · ${plan.trailWidthR.toFixed(1)}R give-back`,
detail: isLong
? 'Stop = highest close minus trail width. Winners run — no take-profit.'
: 'Stop = lowest close plus trail width. Winners run — no take-profit.',
});
} else if (plan.mode === 'trailing' && plan.trailWidth != null && plan.trailWidthR != null) {
steps.push({
title: 'Trailing stop',
primary: `${formatPrice(plan.trailWidth)} · ${plan.trailWidthR.toFixed(1)}R give-back`,
detail: 'Follows price; no fixed take-profit.',
});
} else if (plan.mode === 'target') {
steps.push({
title: 'Take profit',
primary: 'At the selected level',
detail: 'Or exit at the stop if price reverses first.',
});
}
steps.push({
title: 'Time stop',
primary: `${plan.maxHoldDays} trading days`,
detail: 'Flat if still open after the max hold.',
tone: 'muted',
});
return steps;
}
@@ -70,19 +70,31 @@ export function ProductionRankStrip({ setup, momentumGate }: ProductionRankStrip
: null;
const rank = storedRank ?? computedBlend ?? momentum;
if (rank == null && momentum == null && volatility == null) return null;
// Always render when a setup exists. Previously we returned null when ranks
// were missing, which hid the entire strip after a single-ticker rescan that
// forgot to attach percentiles — leaving no clue why the name dropped out of
// qualified trades.
if (!setup) return null;
const ranksMissing = rank == null && momentum == null && volatility == null;
const normalizedRank = clampPercent(rank ?? 0);
const momentumContribution = hasBlend ? clampPercent(momentum) * MOMENTUM_WEIGHT : normalizedRank;
const momentumContribution = hasBlend ? clampPercent(momentum) * MOMENTUM_WEIGHT : (rank != null ? normalizedRank : 0);
const volatilityContribution = hasBlend ? clampPercent(volatility) * VOLATILITY_WEIGHT : 0;
const gateEnabled = momentumGate > 0;
const gatePassed = momentum != null && (!gateEnabled || momentum >= momentumGate);
return (
<section
className="mt-4 border-y border-white/[0.07] py-4"
className="mt-6 border-y border-white/[0.07] py-4"
aria-label="Production ranking snapshot"
>
{ranksMissing && (
<p className="mb-3 rounded-lg border border-amber-400/25 bg-amber-400/10 px-3 py-2 text-[12px] leading-snug text-amber-100">
Residual momentum rank missing on this setup the activation gate
cannot qualify it until ranks are attached. Refresh the ticker (or wait
for the daily scan) to recompute universe ranks.
</p>
)}
<div className="grid gap-5 lg:grid-cols-[minmax(170px,0.55fr)_minmax(0,1.8fr)] lg:gap-8">
<div className="flex items-end justify-between gap-4 lg:block">
<div>
@@ -102,10 +114,16 @@ export function ProductionRankStrip({ setup, momentumGate }: ProductionRankStrip
{rank != null && (
<p className="text-[11px] text-gray-400">top {topShare(normalizedRank)} of the universe</p>
)}
{momentum != null && gateEnabled && (
{momentum == null && gateEnabled ? (
<p className="mt-0.5 text-[10px] text-amber-300">
momentum gate: no residual rank
</p>
) : (
momentum != null && gateEnabled && (
<p className={`mt-0.5 text-[10px] ${gatePassed ? 'text-blue-300' : 'text-red-300'}`}>
momentum gate {gatePassed ? 'passed' : 'not passed'}
</p>
)
)}
</div>
</div>
@@ -302,13 +302,20 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele
preferred
</span>
)}
{setup.momentum_percentile != null && (
{setup.momentum_percentile != null ? (
<span
className="num rounded-full border border-blue-400/25 bg-blue-400/10 px-2.5 py-0.5 text-[11px] text-blue-200"
title="Residual 12-1 month momentum percentile across the universe. This is the actual signal — the reason the ticker was selected at all."
>
momentum top {Math.max(1, Math.round(100 - setup.momentum_percentile))}%
</span>
) : (
<span
className="num rounded-full border border-amber-400/30 bg-amber-400/10 px-2.5 py-0.5 text-[11px] text-amber-200"
title="No residual 12-1 momentum percentile on this setup. Missing ranks do not clear the activation gate."
>
no residual rank
</span>
)}
<Chip>confidence {setup.confidence_score?.toFixed(0) ?? '—'}%</Chip>
<span
@@ -192,7 +192,9 @@ export default function StandingMatrix({
</>
) : (
<p className="text-sm leading-relaxed text-gray-500">
No active setup, so this ticker isnt ranked on the momentum axis yet. Run the scanner to place it.
{composite != null && momentum == null
? 'Setup is present, but residual momentum rank is missing — the activation gate treats unranked setups as not qualified. Refresh this ticker (or wait for the daily scan) so universe ranks are attached.'
: 'No active setup, so this ticker isnt ranked on the momentum axis yet. Run the scanner to place it.'}
</p>
)}
</div>
+9 -2
View File
@@ -171,9 +171,10 @@ function FocusCard({ setup, name, badge, badgeTone, footNote, onReset }: {
R:R and reach probability are gate inputs computed from a GTL
proposal the trade never exits at — they get quiet treatment. */}
<div className="flex items-start gap-10 text-right">
{setup.momentum_percentile != null && (
<div title="Residual 12-1 month momentum percentile across the universe. This is why the ticker was selected.">
<p className="section-index">residual momentum</p>
{setup.momentum_percentile != null ? (
<>
<p className="font-display mt-1 text-3xl font-semibold text-gray-100">
top {Math.max(1, Math.round(100 - setup.momentum_percentile))}
<span className="text-lg text-gray-400">%</span>
@@ -184,8 +185,14 @@ function FocusCard({ setup, name, badge, badgeTone, footNote, onReset }: {
style={{ width: `${Math.min(100, Math.round(setup.momentum_percentile))}%` }}
/>
</div>
</div>
</>
) : (
<>
<p className="font-display mt-1 text-2xl font-semibold text-amber-200">missing</p>
<p className="mt-1 text-[11px] text-gray-500">no residual rank · cannot qualify</p>
</>
)}
</div>
<div
className="max-w-[13rem]"
title="Gate metrics. The reward/risk and reach probability of the headline Gate Target Ladder proposal are what admitted this setup. The trade does NOT exit there — it exits on the trailing stop."
+1 -1
View File
@@ -469,7 +469,7 @@ export default function TickerDetailPage() {
</>
)}
{(longSetup || shortSetup) && (
<div className="mt-6 border-t border-white/[0.06] pt-5">
<div className="mt-8 border-t border-white/[0.06] pt-7">
<RecommendationPanel
frameless
symbol={symbol}
+151
View File
@@ -0,0 +1,151 @@
"""Single-ticker activation ranks: manual refresh must attach residual ranks.
A daily ``scan_all`` path ranks the universe and stamps each setup. Manual
``/ingestion/fetch`` re-scans one symbol; without the same stamps the new
setup has ``momentum_percentile=None`` and fails the activation gate.
"""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from app.models.ticker import Ticker
from app.models.trade_setup import TradeSetup
from app.services import rr_scanner_service
from tests.conftest import _test_session_factory # type: ignore
@pytest.fixture
async def session():
async with _test_session_factory() as s:
yield s
async def test_resolve_ranks_uses_fresh_universe_rank(session, monkeypatch):
session.add(Ticker(symbol="AAA"))
await session.commit()
async def _fake_ranks(db):
return {
"AAA": {
"momentum_percentile": 91.0,
"strategy_rank": 88.5,
"volatility_percentile": 70.0,
}
}
monkeypatch.setattr(
"app.services.momentum_service.compute_activation_ranks",
_fake_ranks,
)
ranks = await rr_scanner_service.resolve_activation_ranks_for_symbol(session, "aaa")
assert ranks["momentum_percentile"] == 91.0
assert ranks["strategy_rank"] == 88.5
assert ranks["volatility_percentile"] == 70.0
async def test_resolve_ranks_falls_back_to_previous_setup(session, monkeypatch):
ticker = Ticker(symbol="BBB")
session.add(ticker)
await session.flush()
session.add(
TradeSetup(
ticker_id=ticker.id,
direction="long",
entry_price=100.0,
stop_loss=95.0,
target=110.0,
rr_ratio=2.0,
composite_score=60.0,
momentum_percentile=84.0,
strategy_rank=80.0,
volatility_percentile=55.0,
detected_at=datetime.now(timezone.utc),
)
)
await session.commit()
async def _empty_ranks(db):
return {}
monkeypatch.setattr(
"app.services.momentum_service.compute_activation_ranks",
_empty_ranks,
)
ranks = await rr_scanner_service.resolve_activation_ranks_for_symbol(session, "BBB")
assert ranks["momentum_percentile"] == 84.0
assert ranks["strategy_rank"] == 80.0
assert ranks["volatility_percentile"] == 55.0
async def test_resolve_ranks_skips_null_prior_setups(session, monkeypatch):
"""Broken prior setups (null percentile) must not block a still-older ranked row."""
ticker = Ticker(symbol="CCC")
session.add(ticker)
await session.flush()
now = datetime.now(timezone.utc)
session.add_all([
TradeSetup(
ticker_id=ticker.id,
direction="long",
entry_price=100.0,
stop_loss=95.0,
target=110.0,
rr_ratio=2.0,
composite_score=60.0,
momentum_percentile=93.0,
strategy_rank=90.0,
volatility_percentile=40.0,
detected_at=now.replace(year=now.year - 1) if now.year > 2000 else now,
),
TradeSetup(
ticker_id=ticker.id,
direction="long",
entry_price=101.0,
stop_loss=96.0,
target=111.0,
rr_ratio=2.1,
composite_score=61.0,
momentum_percentile=None,
strategy_rank=None,
volatility_percentile=None,
detected_at=now,
),
])
await session.commit()
async def _empty_ranks(db):
return {}
monkeypatch.setattr(
"app.services.momentum_service.compute_activation_ranks",
_empty_ranks,
)
ranks = await rr_scanner_service.resolve_activation_ranks_for_symbol(session, "CCC")
assert ranks["momentum_percentile"] == 93.0
assert ranks["strategy_rank"] == 90.0
async def test_resolve_ranks_returns_empty_when_unavailable(session, monkeypatch):
session.add(Ticker(symbol="DDD"))
await session.commit()
async def _boom(db):
raise RuntimeError("ranker down")
monkeypatch.setattr(
"app.services.momentum_service.compute_activation_ranks",
_boom,
)
ranks = await rr_scanner_service.resolve_activation_ranks_for_symbol(session, "DDD")
assert ranks == {
"momentum_percentile": None,
"strategy_rank": None,
"volatility_percentile": None,
}