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 ( -
{step.title}
+ {step.primary && ( ++ {step.primary} +
+ )} + {step.detail && ( +{step.detail}
+ )} +- 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.
)}+ 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. +
+ )}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'} +
+ ) )}- 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.'}
)}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
+ > + )} +