From cad1be96da7dc862eb461a8998bc7b6b1eebae90 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Tue, 14 Jul 2026 11:24:08 +0200 Subject: [PATCH] Add human status labels to all indicators and small UI tweaks. Interpret RSI, ADX, EMA, ATR, volume profile, and pivots like EMA cross; tighten exit timeline spacing; risk presets are 1/2/3/5%. --- .../src/components/ticker/ExitPlanPanel.tsx | 12 +- .../components/ticker/IndicatorSelector.tsx | 107 ++++++++++++++++-- .../components/ticker/RecommendationPanel.tsx | 2 +- 3 files changed, 102 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/ticker/ExitPlanPanel.tsx b/frontend/src/components/ticker/ExitPlanPanel.tsx index d60fe29..169d81a 100644 --- a/frontend/src/components/ticker/ExitPlanPanel.tsx +++ b/frontend/src/components/ticker/ExitPlanPanel.tsx @@ -22,11 +22,11 @@ export function ExitPlanPanel({ plan, direction }: { plan: ExitPlan; direction: {plan.headline} -
    +
      {steps.map((step, i) => { const isLast = i === steps.length - 1; return ( -
    1. +
    2. {/* Spine column: continuous connector from this node into the next */}
      -
      -

      {step.title}

      +
      +

      {step.title}

      {step.primary && ( -

      +

      {step.primary}

      )} {step.detail && ( -

      {step.detail}

      +

      {step.detail}

      )}
    3. diff --git a/frontend/src/components/ticker/IndicatorSelector.tsx b/frontend/src/components/ticker/IndicatorSelector.tsx index 53991f2..d285c6a 100644 --- a/frontend/src/components/ticker/IndicatorSelector.tsx +++ b/frontend/src/components/ticker/IndicatorSelector.tsx @@ -79,25 +79,108 @@ function clusterLevels(nums: number[], refPrice?: number): { shown: string[]; mo return { shown, more: clusters.length - Math.min(clusters.length, MAX_CLUSTERS) }; } -/** The one-line human read of an indicator, where its meaning is standard. */ -function interpretation(result: IndicatorResult): { text: string; tone: string } | null { +/** + * One-line human read of an indicator — same slot as EMA cross's + * bullish/bearish/neutral. Vocab matches what the metric means (directional + * vs regime), not forced into bullish/bearish for everything. + */ +function interpretation( + result: IndicatorResult, + refPrice?: number, +): { text: string; tone: string } | null { const v = result.values as Record; const num = (k: string) => (typeof v[k] === 'number' ? (v[k] as number) : null); - switch (result.indicator_type) { - case 'RSI': { - const rsi = num('rsi') ?? num('RSI') ?? num('value'); + const type = result.indicator_type.toLowerCase(); + + switch (type) { + case 'rsi': { + const rsi = num('rsi') ?? num('value'); if (rsi == null) return null; if (rsi >= 70) return { text: 'overbought', tone: 'text-red-300' }; if (rsi <= 30) return { text: 'oversold', tone: 'text-emerald-300' }; - return { text: 'neutral zone', tone: 'text-gray-400' }; + if (rsi >= 55) return { text: 'bullish momentum', tone: 'text-emerald-300' }; + if (rsi <= 45) return { text: 'bearish momentum', tone: 'text-red-300' }; + return { text: 'neutral', tone: 'text-gray-400' }; } - case 'ADX': { - const adx = num('adx') ?? num('ADX') ?? num('value'); + + case 'adx': { + const adx = num('adx') ?? num('value'); + const plusDi = num('plus_di'); + const minusDi = num('minus_di'); if (adx == null) return null; - if (adx >= 40) return { text: 'strong trend', tone: 'text-emerald-300' }; - if (adx >= 25) return { text: 'trending', tone: 'text-gray-300' }; - return { text: 'weak / no trend', tone: 'text-gray-400' }; + if (adx < 20) return { text: 'no trend / range', tone: 'text-gray-400' }; + if (adx < 25) return { text: 'trend forming', tone: 'text-gray-300' }; + const bullish = plusDi != null && minusDi != null && plusDi > minusDi; + const bearish = plusDi != null && minusDi != null && minusDi > plusDi; + if (adx >= 40) { + if (bullish) return { text: 'strong bullish trend', tone: 'text-emerald-300' }; + if (bearish) return { text: 'strong bearish trend', tone: 'text-red-300' }; + return { text: 'strong trend', tone: 'text-emerald-300' }; + } + if (bullish) return { text: 'bullish trend', tone: 'text-emerald-300' }; + if (bearish) return { text: 'bearish trend', tone: 'text-red-300' }; + return { text: 'trending', tone: 'text-gray-300' }; } + + case 'ema': { + const ema = num('ema'); + const close = num('latest_close'); + if (ema == null || close == null || ema === 0) return null; + const pct = ((close - ema) / ema) * 100; + if (pct >= 0.5) return { text: 'above EMA · bullish', tone: 'text-emerald-300' }; + if (pct <= -0.5) return { text: 'below EMA · bearish', tone: 'text-red-300' }; + return { text: 'at EMA · neutral', tone: 'text-gray-400' }; + } + + case 'atr': { + const atrPct = num('atr_percent'); + if (atrPct == null) return null; + if (atrPct < 1.5) return { text: 'compressed', tone: 'text-emerald-300' }; + if (atrPct < 3) return { text: 'normal volatility', tone: 'text-gray-400' }; + if (atrPct < 5) return { text: 'elevated', tone: 'text-amber-300' }; + return { text: 'high volatility', tone: 'text-red-300' }; + } + + case 'volume_profile': { + const vaLow = num('value_area_low'); + const vaHigh = num('value_area_high'); + const poc = num('poc'); + const price = refPrice ?? num('latest_close'); + if (price == null || vaLow == null || vaHigh == null) return null; + if (price > vaHigh) return { text: 'above value · extended', tone: 'text-amber-300' }; + if (price < vaLow) return { text: 'below value · discounted', tone: 'text-emerald-300' }; + // Inside value area: near POC vs mid-range. + if (poc != null) { + const span = Math.max(vaHigh - vaLow, Math.abs(price) * 0.001); + if (Math.abs(price - poc) / span <= 0.15) { + return { text: 'at value · balanced', tone: 'text-gray-300' }; + } + } + return { text: 'in value area', tone: 'text-gray-400' }; + } + + case 'pivot_points': { + const price = refPrice; + if (price == null || !(price > 0)) return null; + const highs = parseNums(v.swing_highs) ?? []; + const lows = parseNums(v.swing_lows) ?? []; + // parseNums requires ≥2 elements; allow singletons for pivots. + const rawHighs = Array.isArray(v.swing_highs) + ? (v.swing_highs as unknown[]).filter((x): x is number => typeof x === 'number') + : highs; + const rawLows = Array.isArray(v.swing_lows) + ? (v.swing_lows as unknown[]).filter((x): x is number => typeof x === 'number') + : lows; + if (rawHighs.length === 0 && rawLows.length === 0) return null; + const near = (level: number) => Math.abs(level - price) / price <= 0.02; + const nearHigh = rawHighs.some(near); + const nearLow = rawLows.some(near); + if (nearHigh && nearLow) return { text: 'congested structure', tone: 'text-gray-400' }; + if (nearLow) return { text: 'near support pivot', tone: 'text-emerald-300' }; + if (nearHigh) return { text: 'near resistance pivot', tone: 'text-red-300' }; + return { text: 'between pivots', tone: 'text-gray-400' }; + } + default: return null; } @@ -164,7 +247,7 @@ function IndicatorCard({ symbol, type, refPrice }: { symbol: string; type: strin {query.data && ( <> {(() => { - const read = interpretation(query.data); + const read = interpretation(query.data, refPrice); return read ? (

      {read.text}

      ) : null; diff --git a/frontend/src/components/ticker/RecommendationPanel.tsx b/frontend/src/components/ticker/RecommendationPanel.tsx index 58a5971..74140d9 100644 --- a/frontend/src/components/ticker/RecommendationPanel.tsx +++ b/frontend/src/components/ticker/RecommendationPanel.tsx @@ -529,7 +529,7 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele ); } -const RISK_PRESETS = [0.5, 1, 2, 3]; +const RISK_PRESETS = [1, 2, 3, 5]; /** Compact, set-once sizing controls: a clean account field (no spinners) and a * segmented risk-% selector — risk is almost always one of a few values. */