diff --git a/frontend/src/components/charts/CandlestickChart.tsx b/frontend/src/components/charts/CandlestickChart.tsx index 27fd6b2..586de20 100644 --- a/frontend/src/components/charts/CandlestickChart.tsx +++ b/frontend/src/components/charts/CandlestickChart.tsx @@ -1,18 +1,45 @@ -import { useMemo, useRef, useEffect, useCallback, useState } from 'react'; +import { useRef, useEffect, useCallback, useState } from 'react'; import type { OHLCVBar, SRLevel, SRZone, TradeSetup } from '../../lib/types'; import { formatPrice, formatDate } from '../../lib/format'; interface CandlestickChartProps { data: OHLCVBar[]; srLevels?: SRLevel[]; - maxSRLevels?: number; zones?: SRZone[]; tradeSetup?: TradeSetup; + currentPrice?: number; } -function filterTopSRLevels(levels: SRLevel[], max: number): SRLevel[] { - if (levels.length <= max) return levels; - return [...levels].sort((a, b) => b.strength - a.strength).slice(0, max); +/** A horizontal price marker to draw, with an optional band (zone). */ +interface SRMarker { + price: number; + low: number; + high: number; + strength: number; + role: 'support' | 'resistance'; +} + +/** + * Pick only the nearest support below and nearest resistance above the current + * price — the two levels that actually matter for a trade right now. Keeps the + * chart readable; the full S/R list lives in the S/R tab. + */ +function nearestSRMarkers( + srLevels: SRLevel[], + zones: SRZone[], + price: number, +): SRMarker[] { + const raw: SRMarker[] = zones.length + ? zones.map((z) => ({ price: z.midpoint, low: z.low, high: z.high, strength: z.strength, role: 'support' as const })) + : srLevels.map((l) => ({ price: l.price_level, low: l.price_level, high: l.price_level, strength: l.strength, role: 'support' as const })); + + const below = raw.filter((m) => m.high <= price).sort((a, b) => (price - a.price) - (price - b.price)); + const above = raw.filter((m) => m.low > price).sort((a, b) => (a.price - price) - (b.price - price)); + + const out: SRMarker[] = []; + if (below[0]) out.push({ ...below[0], role: 'support' }); + if (above[0]) out.push({ ...above[0], role: 'resistance' }); + return out; } interface TooltipState { @@ -24,7 +51,7 @@ interface TooltipState { const MIN_VISIBLE_BARS = 10; -export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6, zones = [], tradeSetup }: CandlestickChartProps) { +export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup, currentPrice }: CandlestickChartProps) { const canvasRef = useRef(null); const overlayCanvasRef = useRef(null); const containerRef = useRef(null); @@ -46,8 +73,6 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6, zones = setVisibleRange({ start: 0, end: data.length }); }, [data]); - const topLevels = useMemo(() => filterTopSRLevels(srLevels, maxSRLevels), [srLevels, maxSRLevels]); - const draw = useCallback(() => { const canvas = canvasRef.current; const container = containerRef.current; @@ -79,12 +104,16 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6, zones = const cw = W - ml - mr; const ch = H - mt - mb; + // 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 = topLevels.map((l) => l.price_level); - const zonePrices = zones.flatMap((z) => [z.low, z.high]); + const srPrices = markers.flatMap((m) => [m.low, m.high]); const tradePrices = tradeSetup ? [tradeSetup.entry_price, tradeSetup.stop_loss, tradeSetup.target] : []; - const allVals = [...allPrices, ...srPrices, ...zonePrices, ...tradePrices]; + const allVals = [...allPrices, ...srPrices, ...tradePrices, livePrice]; const minP = Math.min(...allVals); const maxP = Math.max(...allVals); const pad = (maxP - minP) * 0.06 || 1; @@ -121,73 +150,37 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6, zones = ctx.fillText(formatDate(visibleData[i].date), x, H - 6); } - // S/R levels (only when no zones are provided) - if (zones.length === 0) { - topLevels.forEach((level) => { - const y = yScale(level.price_level); - const isSupport = level.type === 'support'; - const color = isSupport ? '#10b981' : '#ef4444'; + // Nearest support/resistance only (band if it came from a zone) + markers.forEach((m) => { + const isSupport = m.role === 'support'; + const color = isSupport ? '#10b981' : '#ef4444'; + const yMid = yScale(m.price); - ctx.strokeStyle = color; - ctx.lineWidth = 1.5; - ctx.globalAlpha = 0.55; - ctx.setLineDash([6, 3]); - ctx.beginPath(); - ctx.moveTo(ml, y); - ctx.lineTo(ml + cw, y); - ctx.stroke(); - ctx.setLineDash([]); - ctx.globalAlpha = 1; + if (m.high > m.low) { + const yTop = yScale(m.high); + const rectHeight = Math.max(yScale(m.low) - yTop, 2); + ctx.fillStyle = isSupport ? 'rgba(16, 185, 129, 0.12)' : 'rgba(239, 68, 68, 0.12)'; + ctx.fillRect(ml, yTop, cw, rectHeight); + } - // Label - ctx.fillStyle = color; - ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace'; - ctx.textAlign = 'left'; - ctx.fillText( - `${level.type[0].toUpperCase()} ${formatPrice(level.price_level)}`, - ml + cw + 4, - y + 3 - ); - }); - } - - // S/R Zone rectangles (drawn before candles so candles render on top) - zones.forEach((zone) => { - const isSupport = zone.type === 'support'; - const fillColor = isSupport ? 'rgba(16, 185, 129, 0.15)' : 'rgba(239, 68, 68, 0.15)'; - const borderColor = isSupport ? 'rgba(16, 185, 129, 0.35)' : 'rgba(239, 68, 68, 0.35)'; - const labelColor = isSupport ? '#10b981' : '#ef4444'; - - const yTop = yScale(zone.high); - const yBottom = yScale(zone.low); - // Handle single-level zones (low == high) as thin 2px-height rectangles - const rectHeight = Math.max(yBottom - yTop, 2); - - // Shaded rectangle spanning full chart width - ctx.fillStyle = fillColor; - ctx.fillRect(ml, yTop, cw, rectHeight); - - // Border lines at top and bottom of zone - ctx.strokeStyle = borderColor; - ctx.lineWidth = 1; + ctx.strokeStyle = color; + ctx.lineWidth = 1.25; + ctx.globalAlpha = 0.6; + ctx.setLineDash([6, 3]); ctx.beginPath(); - ctx.moveTo(ml, yTop); - ctx.lineTo(ml + cw, yTop); - ctx.stroke(); - ctx.beginPath(); - ctx.moveTo(ml, yTop + rectHeight); - ctx.lineTo(ml + cw, yTop + rectHeight); + ctx.moveTo(ml, yMid); + ctx.lineTo(ml + cw, yMid); ctx.stroke(); + ctx.setLineDash([]); + ctx.globalAlpha = 1; - // Label with midpoint price and strength score - const yMid = yTop + rectHeight / 2; - ctx.fillStyle = labelColor; + ctx.fillStyle = color; ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace'; ctx.textAlign = 'left'; ctx.fillText( - `${zone.type[0].toUpperCase()} ${formatPrice(zone.midpoint)} (${zone.strength})`, + `${isSupport ? 'Support' : 'Resistance'} ${formatPrice(m.price)} (${m.strength})`, ml + cw + 4, - yMid + 3 + yMid + 3, ); }); @@ -248,6 +241,28 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6, zones = ctx.fillText(`TP ${formatPrice(tradeSetup.target)}`, ml + cw + 4, targetY + 3); } + // Current price line — the anchor for everything else (drawn on top) + { + const py = yScale(livePrice); + ctx.strokeStyle = 'rgba(226, 232, 240, 0.9)'; + ctx.lineWidth = 1.25; + ctx.beginPath(); + ctx.moveTo(ml, py); + ctx.lineTo(ml + cw, py); + ctx.stroke(); + + const label = `Now ${formatPrice(livePrice)}`; + 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 = '#0e120f'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(label, ml + 6, py); + ctx.textBaseline = 'alphabetic'; + } + // Candles visibleData.forEach((bar, i) => { const x = ml + i * barW + barW / 2; @@ -285,7 +300,7 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6, zones = overlay.style.width = `${W}px`; overlay.style.height = `${H}px`; } - }, [data, topLevels, visibleRange, zones, tradeSetup]); + }, [data, srLevels, visibleRange, zones, tradeSetup, currentPrice]); const drawCrosshair = useCallback(() => { const overlay = overlayCanvasRef.current; diff --git a/frontend/src/components/ticker/RecommendationPanel.tsx b/frontend/src/components/ticker/RecommendationPanel.tsx index c9db944..1fc8d53 100644 --- a/frontend/src/components/ticker/RecommendationPanel.tsx +++ b/frontend/src/components/ticker/RecommendationPanel.tsx @@ -6,6 +6,26 @@ interface RecommendationPanelProps { symbol: string; longSetup?: TradeSetup; shortSetup?: TradeSetup; + currentPrice?: number; +} + +/** + * How far current price has drifted from the setup's entry. A setup whose + * entry is far from the live price (price already ran toward target, or fell + * through the stop) is stale — entering now changes the risk/reward. + */ +function entryDrift(setup: TradeSetup, currentPrice?: number) { + if (currentPrice == null || !setup.entry_price) return null; + const pct = ((currentPrice - setup.entry_price) / setup.entry_price) * 100; + const towardTarget = setup.direction === 'long' ? currentPrice >= setup.entry_price : currentPrice <= setup.entry_price; + // Past the stop entirely = invalidated; moved >1/3 of the way to target = stale + const span = Math.abs(setup.target - setup.entry_price); + const moved = Math.abs(currentPrice - setup.entry_price); + const beyondStop = setup.direction === 'long' ? currentPrice <= setup.stop_loss : currentPrice >= setup.stop_loss; + let status: 'fresh' | 'stale' | 'invalidated' = 'fresh'; + if (beyondStop) status = 'invalidated'; + else if (span > 0 && moved / span > 0.33) status = 'stale'; + return { pct, towardTarget, status }; } function riskClass(risk: TradeSetup['risk_level']) { @@ -54,7 +74,7 @@ function TargetTable({ setup }: { setup: TradeSetup }) { ); } -function SetupCard({ setup, action }: { setup?: TradeSetup; action?: TradeSetup['recommended_action'] }) { +function SetupCard({ setup, action, currentPrice }: { setup?: TradeSetup; action?: TradeSetup['recommended_action']; currentPrice?: number }) { if (!setup) { return (
@@ -64,6 +84,7 @@ function SetupCard({ setup, action }: { setup?: TradeSetup; action?: TradeSetup[ } const recommended = isRecommended(setup, action); + const drift = entryDrift(setup, currentPrice); return (
Alternative setup (ticker bias currently favors the opposite direction).

)} + {drift && drift.status === 'invalidated' && ( +

+ ⚠ Price ({formatPrice(currentPrice!)}) is past the stop — this setup is invalidated. +

+ )} + {drift && drift.status === 'stale' && ( +

+ ⚠ Price has moved {drift.pct >= 0 ? '+' : ''}{drift.pct.toFixed(1)}% from entry{drift.towardTarget ? ' toward target' : ' against the setup'} — entry may be stale. +

+ )} +
-
Entry
{formatPrice(setup.entry_price)}
+
Current
{currentPrice != null ? formatPrice(currentPrice) : '—'}
+
Entry
{formatPrice(setup.entry_price)}{drift ? ` (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}%)` : ''}
Stop
{formatPrice(setup.stop_loss)}
Primary Target
{formatPrice(setup.target)}
R:R
{setup.rr_ratio.toFixed(2)}
@@ -99,7 +132,7 @@ function SetupCard({ setup, action }: { setup?: TradeSetup; action?: TradeSetup[ ); } -export function RecommendationPanel({ symbol, longSetup, shortSetup }: RecommendationPanelProps) { +export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPrice }: RecommendationPanelProps) { const summary = longSetup?.recommendation_summary ?? shortSetup?.recommendation_summary; const action = (summary?.action ?? 'NEUTRAL') as TradeSetup['recommended_action']; const preferredDirection = recommendationActionDirection(action); @@ -143,7 +176,7 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup }: Recommend {preferredDirection !== 'neutral' && preferredSetup ? (
- + {alternativeSetup && (
@@ -151,15 +184,15 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup }: Recommend Alternative scenario ({alternativeSetup.direction.toUpperCase()})
- +
)}
) : (
- - + +
)}
diff --git a/frontend/src/pages/TickerDetailPage.tsx b/frontend/src/pages/TickerDetailPage.tsx index 46292cc..727cd97 100644 --- a/frontend/src/pages/TickerDetailPage.tsx +++ b/frontend/src/pages/TickerDetailPage.tsx @@ -121,12 +121,30 @@ export default function TickerDetailPage() { [setupsForSymbol], ); - // Use the highest-confidence setup for chart overlay fallback. - const tradeSetup: TradeSetup | undefined = useMemo(() => { + // Current price = latest close, with day-over-day change + const priceInfo = useMemo(() => { + const bars = ohlcv.data; + if (!bars || bars.length === 0) return null; + const last = bars[bars.length - 1]; + const prev = bars.length > 1 ? bars[bars.length - 2] : null; + const change = prev && prev.close ? ((last.close - prev.close) / prev.close) * 100 : null; + return { price: last.close, date: last.date, change }; + }, [ohlcv.data]); + + // Which setup the chart overlays. 'auto' = the ticker's preferred direction. + const [overlayChoice, setOverlayChoice] = useState<'auto' | 'long' | 'short' | 'none'>('auto'); + + const action = (longSetup ?? shortSetup)?.recommended_action ?? null; + const overlaySetup: TradeSetup | undefined = useMemo(() => { + if (overlayChoice === 'none') return undefined; + if (overlayChoice === 'long') return longSetup; + if (overlayChoice === 'short') return shortSetup; + // auto: preferred direction's setup, else the highest-confidence available + if (action?.startsWith('LONG') && longSetup) return longSetup; + if (action?.startsWith('SHORT') && shortSetup) return shortSetup; const candidates = [longSetup, shortSetup].filter(Boolean) as TradeSetup[]; - if (candidates.length === 0) return undefined; return candidates.sort((a, b) => (b.confidence_score ?? 0) - (a.confidence_score ?? 0))[0]; - }, [longSetup, shortSetup]); + }, [overlayChoice, longSetup, shortSetup, action]); // Sort visible S/R levels by strength for the table (only levels within chart zones) const sortedLevels = useMemo(() => { @@ -138,9 +156,19 @@ export default function TickerDetailPage() {
{/* Header */}
-
+

{symbol.toUpperCase()}

-

Ticker Detail

+ {priceInfo && ( +
+ {formatPrice(priceInfo.price)} + {priceInfo.change !== null && ( + = 0 ? 'text-emerald-400' : 'text-red-400'}`}> + {priceInfo.change >= 0 ? '+' : ''}{priceInfo.change.toFixed(2)}% + + )} + last close · {timeAgo(priceInfo.date)} +
+ )}
+ ))} + {overlaySetup && ( + + showing {overlaySetup.direction.toUpperCase()} · entry {formatPrice(overlaySetup.entry_price)} → target {formatPrice(overlaySetup.target)} + + )} +
)} + +

+ Only the nearest support & resistance are drawn. Full list in the S/R Levels tab. + {srLevels.isError && ' S/R levels unavailable.'} +

)}