diff --git a/app/routers/ingestion.py b/app/routers/ingestion.py index 1a94512..1b797d2 100644 --- a/app/routers/ingestion.py +++ b/app/routers/ingestion.py @@ -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: diff --git a/app/services/rr_scanner_service.py b/app/services/rr_scanner_service.py index cf6f857..af86f3a 100644 --- a/app/services/rr_scanner_service.py +++ b/app/services/rr_scanner_service.py @@ -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, diff --git a/frontend/src/components/charts/CandlestickChart.tsx b/frontend/src/components/charts/CandlestickChart.tsx index fd77fa0..1fd79b8 100644 --- a/frontend/src/components/charts/CandlestickChart.tsx +++ b/frontend/src/components/charts/CandlestickChart.tsx @@ -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'; } diff --git a/frontend/src/components/ticker/ExitPlanPanel.tsx b/frontend/src/components/ticker/ExitPlanPanel.tsx index 1990d93..4105547 100644 --- a/frontend/src/components/ticker/ExitPlanPanel.tsx +++ b/frontend/src/components/ticker/ExitPlanPanel.tsx @@ -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 ( -
+
how this exits {plan.headline}
-
-
-
Initial stop
-
- {formatPrice(plan.initialStop)}{' '} - (1R = {formatPrice(plan.riskPerShare)}/sh) -
-
+
    + {steps.map((step, i) => { + const isLast = i === steps.length - 1; + return ( +
  1. + {/* Spine */} +
    + + {step.icon ?? i + 1} + + {!isLast && ( + + )} +
    - {plan.mode === 'atr_trailing' && plan.trailTakesOverAt != null && plan.trailWidthR != null && ( - <> -
    -
    Trail takes over
    -
    - {isLong ? 'above' : 'below'} {formatPrice(plan.trailTakesOverAt)} -
    -
    -
    -
    Then it trails
    -
    - {formatPrice(plan.trailWidth ?? 0)} ({plan.trailWidthR.toFixed(1)}R) below the highest close -
    -
    - - )} - -
    -
    Max hold
    -
    {plan.maxHoldDays} trading days
    -
    -
+
+

{step.title}

+ {step.primary && ( +

+ {step.primary} +

+ )} + {step.detail && ( +

{step.detail}

+ )} +
+ + ); + })} + {!plan.honorsTarget && ( -

- There is no take-profit. Winners are ridden until the - trailing stop is hit — that’s where the strategy’s edge comes from, so hitting a level - below is not a reason to sell. The levels shown below are screening context, not exits. +

+ Levels on the chart are screening context, not sell targets.

)}
); } + +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; +} diff --git a/frontend/src/components/ticker/ProductionRankStrip.tsx b/frontend/src/components/ticker/ProductionRankStrip.tsx index 33df0ab..87befc5 100644 --- a/frontend/src/components/ticker/ProductionRankStrip.tsx +++ b/frontend/src/components/ticker/ProductionRankStrip.tsx @@ -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 (
+ {ranksMissing && ( +

+ 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. +

+ )}
@@ -102,10 +114,16 @@ export function ProductionRankStrip({ setup, momentumGate }: ProductionRankStrip {rank != null && (

top {topShare(normalizedRank)} of the universe

)} - {momentum != null && gateEnabled && ( -

- momentum gate {gatePassed ? 'passed' : 'not passed'} + {momentum == null && gateEnabled ? ( +

+ momentum gate: no residual rank

+ ) : ( + momentum != null && gateEnabled && ( +

+ momentum gate {gatePassed ? 'passed' : 'not passed'} +

+ ) )}
diff --git a/frontend/src/components/ticker/RecommendationPanel.tsx b/frontend/src/components/ticker/RecommendationPanel.tsx index efbc3f8..58a5971 100644 --- a/frontend/src/components/ticker/RecommendationPanel.tsx +++ b/frontend/src/components/ticker/RecommendationPanel.tsx @@ -302,13 +302,20 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele preferred )} - {setup.momentum_percentile != null && ( + {setup.momentum_percentile != null ? ( momentum top {Math.max(1, Math.round(100 - setup.momentum_percentile))}% + ) : ( + + no residual rank + )} confidence {setup.confidence_score?.toFixed(0) ?? '—'}% ) : (

- No active setup, so this ticker isn’t 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 isn’t ranked on the momentum axis yet. Run the scanner to place it.'}

)}
diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 6d35a98..ac980bf 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -171,21 +171,28 @@ 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. */}
- {setup.momentum_percentile != null && ( -
-

residual momentum

-

- top {Math.max(1, Math.round(100 - setup.momentum_percentile))} - % -

-
- -
-
- )} +
+

residual momentum

+ {setup.momentum_percentile != null ? ( + <> +

+ top {Math.max(1, Math.round(100 - setup.momentum_percentile))} + % +

+
+ +
+ + ) : ( + <> +

missing

+

no residual rank · cannot qualify

+ + )} +
)} {(longSetup || shortSetup) && ( -
+
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, + }