From 03bdcbd74586ffbc6e15f6e9d35e993a35bf4e2e Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Fri, 10 Jul 2026 12:20:23 +0200 Subject: [PATCH] Ticker panel polish: freshness up top, radar pad, indicator grid, verdict de-dup, target picker on take Five fixes from prod review: 1. Data freshness row moves from the panel foot to directly under the header - data age is a first-look concern. 2. Radar fingerprint labels were clipped left ("Momentum") and right (value digits): label padding widened to fit "momentum 100" on both anchored sides. 3. Indicators tab: the one-at-a-time dropdown (which also clipped) is gone; all indicators load in parallel as quiet outlined cards - EMA cross with its colored signal, RSI/ADX with a one-line human read (overbought / trending / ...), values formatted to 2 decimals instead of 4, normalized score demoted to a corner note. 4. Recommendation verdict line de-duplicated: the backend reasoning already starts with the action label, so it now replaces the headline instead of repeating under it. 5. Mark-as-taken: the take form gains a target selector listing every detected target with price / probability / classification (primary preselected), and the card warns when the primary target sits below 15% probability. Why the scanner promotes such a target to primary is a separate (backend) question. Co-Authored-By: Claude Fable 5 --- frontend/src/components/charts/horizon.tsx | 3 +- .../components/ticker/IndicatorSelector.tsx | 237 ++++++++++-------- .../components/ticker/RecommendationPanel.tsx | 35 ++- frontend/src/pages/TickerDetailPage.tsx | 19 +- 4 files changed, 180 insertions(+), 114 deletions(-) diff --git a/frontend/src/components/charts/horizon.tsx b/frontend/src/components/charts/horizon.tsx index 3fbcf72..bc33274 100644 --- a/frontend/src/components/charts/horizon.tsx +++ b/frontend/src/components/charts/horizon.tsx @@ -137,7 +137,8 @@ export function RadarChart({ const [hover, setHover] = useState(null); const n = axes.length; if (n < 3) return null; - const pad = labels ? 74 : 10; + // Label pad must fit "momentum 100" fully outside the left/right vertices. + const pad = labels ? 96 : 10; const vb = size + pad * 2; const c = vb / 2; const r = size / 2; diff --git a/frontend/src/components/ticker/IndicatorSelector.tsx b/frontend/src/components/ticker/IndicatorSelector.tsx index a33120b..0a8b2f4 100644 --- a/frontend/src/components/ticker/IndicatorSelector.tsx +++ b/frontend/src/components/ticker/IndicatorSelector.tsx @@ -1,129 +1,170 @@ -import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { getIndicator, getEMACross } from '../../api/indicators'; -import { Dropdown } from '../ui/Dropdown'; -import type { IndicatorResult, EMACrossResult } from '../../lib/types'; +import type { IndicatorResult } from '../../lib/types'; -const INDICATOR_TYPES = ['ADX', 'EMA', 'RSI', 'ATR', 'volume_profile', 'pivot_points'] as const; +const INDICATOR_TYPES = ['RSI', 'ADX', 'EMA', 'ATR', 'volume_profile', 'pivot_points'] as const; + +const INDICATOR_LABELS: Record = { + RSI: 'RSI', + ADX: 'ADX · trend strength', + EMA: 'EMA', + ATR: 'ATR · volatility', + volume_profile: 'Volume profile', + pivot_points: 'Pivot points', +}; interface IndicatorSelectorProps { symbol: string; } const signalColors: Record = { - bullish: 'text-emerald-400', - bearish: 'text-red-400', + bullish: 'text-emerald-300', + bearish: 'text-red-300', neutral: 'text-gray-300', }; -function IndicatorResultDisplay({ result }: { result: IndicatorResult }) { - return ( -
-
- Type - {result.indicator_type} -
-
- Normalized Score - {result.score.toFixed(2)} -
-
- Bars Used - {result.bars_used} -
- {Object.keys(result.values).length > 0 && ( -
-

Values

- {Object.entries(result.values).map(([key, val]) => ( -
- {key} - {typeof val === 'number' ? val.toFixed(4) : String(val)} -
- ))} -
- )} -
- ); +function fmtVal(val: number | string): string { + if (typeof val !== 'number') return String(val); + if (!Number.isFinite(val)) return '—'; + if (Math.abs(val) >= 1000) return val.toLocaleString('en-US', { maximumFractionDigits: 0 }); + return val.toFixed(2); } -function EMACrossDisplay({ result }: { result: EMACrossResult }) { - return ( -
-
- Signal - {result.signal} -
-
- Short EMA ({result.short_period}) - {result.short_ema.toFixed(2)} -
-
- Long EMA ({result.long_period}) - {result.long_ema.toFixed(2)} -
-
- ); +function prettyKey(key: string): string { + return key.replace(/_/g, ' '); } -export function IndicatorSelector({ symbol }: IndicatorSelectorProps) { - const [selectedType, setSelectedType] = useState(''); - const [showEMACross, setShowEMACross] = useState(false); +/** The one-line human read of an indicator, where its meaning is standard. */ +function interpretation(result: IndicatorResult): { 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'); + 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' }; + } + case 'ADX': { + const adx = num('adx') ?? num('ADX') ?? num('value'); + 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' }; + } + default: + return null; + } +} - const indicatorQuery = useQuery({ - queryKey: ['indicator', symbol, selectedType], - queryFn: () => getIndicator(symbol, selectedType), - enabled: !!symbol && !!selectedType, - }); - - const emaCrossQuery = useQuery({ - queryKey: ['ema-cross', symbol], - queryFn: () => getEMACross(symbol), - enabled: !!symbol && showEMACross, +/** One indicator, fetched independently — a quiet outlined card. */ +function IndicatorCard({ symbol, type }: { symbol: string; type: string }) { + const query = useQuery({ + queryKey: ['indicator', symbol, type], + queryFn: () => getIndicator(symbol, type), + staleTime: 5 * 60_000, + retry: 1, }); return ( -
-

Indicators

- -
- ({ value: type, label: type }))} - /> +
+
+

+ {INDICATOR_LABELS[type] ?? type} +

+ {query.data && ( + + score {query.data.score.toFixed(2)} + + )}
- {selectedType && indicatorQuery.isLoading && ( -
-
-
-
+ {query.isLoading && ( +
+
+
)} - {selectedType && indicatorQuery.isError && ( -

- {indicatorQuery.error instanceof Error ? indicatorQuery.error.message : 'Failed to load indicator'} + {query.isError && ( +

+ Not available{query.error instanceof Error && query.error.message ? ` — ${query.error.message}` : ''}.

)} - {indicatorQuery.data && } + {query.data && ( + <> + {(() => { + const read = interpretation(query.data); + return read ? ( +

{read.text}

+ ) : null; + })()} +
+ {Object.entries(query.data.values).map(([key, val]) => ( +
+
{prettyKey(key)}
+
{fmtVal(val as number | string)}
+
+ ))} + {Object.keys(query.data.values).length === 0 && ( +

No values.

+ )} +
+ + )} +
+ ); +} -
- - {emaCrossQuery.isError && ( -

- {emaCrossQuery.error instanceof Error ? emaCrossQuery.error.message : 'Failed to load EMA cross'} +/** EMA cross gets its own card — it carries a directional signal. */ +function EMACrossCard({ symbol }: { symbol: string }) { + const query = useQuery({ + queryKey: ['ema-cross', symbol], + queryFn: () => getEMACross(symbol), + staleTime: 5 * 60_000, + retry: 1, + }); + + return ( +

+

EMA cross

+ {query.isLoading && ( +
+
+
+
+ )} + {query.isError &&

Not available.

} + {query.data && ( + <> +

+ {query.data.signal}

- )} - {emaCrossQuery.data && ( -
- )} +
+
+
short EMA ({query.data.short_period})
+
{query.data.short_ema.toFixed(2)}
+
+
+
long EMA ({query.data.long_period})
+
{query.data.long_ema.toFixed(2)}
+
+
+ + )} +
+ ); +} + +/** All indicators at once — no dropdown, every value visible or one card away. */ +export function IndicatorSelector({ symbol }: IndicatorSelectorProps) { + return ( +
+
+ + {INDICATOR_TYPES.map((type) => ( + + ))}
); diff --git a/frontend/src/components/ticker/RecommendationPanel.tsx b/frontend/src/components/ticker/RecommendationPanel.tsx index 575eb2c..383d32a 100644 --- a/frontend/src/components/ticker/RecommendationPanel.tsx +++ b/frontend/src/components/ticker/RecommendationPanel.tsx @@ -165,6 +165,7 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad const [taking, setTaking] = useState(false); const [takeShares, setTakeShares] = useState(sizing?.shares ?? 0); const [takeEntry, setTakeEntry] = useState(currentPrice ?? setup.entry_price); + const [takeTarget, setTakeTarget] = useState(setup.target); const confirmTake = () => { createTrade.mutate( @@ -174,7 +175,7 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad entry_price: takeEntry, shares: takeShares, stop_loss: setup.stop_loss, - target: setup.target, + target: takeTarget, }, { onSuccess: () => setTaking(false) }, ); @@ -245,6 +246,11 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad )}
)} + {prob != null && prob < 15 && ( +

+ ⚠ The primary target has only a {Math.round(prob)}% probability — pick a nearer target from the list when taking the trade. +

+ )} {/* The setup, spatially — stop → entry → now → target */} { setTakeShares(sizing?.shares ?? 0); setTakeEntry(currentPrice ?? setup.entry_price); + setTakeTarget(setup.target); setTaking(true); }} className="rounded-lg border border-blue-500/35 bg-blue-500/15 px-3.5 py-1.5 text-xs font-semibold text-blue-300 transition-colors hover:bg-blue-500/25" @@ -304,8 +311,24 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad />
+ {setup.targets && setup.targets.length > 1 ? ( + + ) : null}

- Stop {formatPrice(setup.stop_loss)} · Target {formatPrice(setup.target)} · {setup.direction.toUpperCase()} paper trade + Stop {formatPrice(setup.stop_loss)} · Target {formatPrice(takeTarget)} · {setup.direction.toUpperCase()} paper trade

- {summary?.reasoning && !preferredInactive && ( -

{summary.reasoning}

- )} - {earningsDays != null && earningsDays >= 0 && ( earningsDays <= EARNINGS_HORIZON_DAYS ? (

diff --git a/frontend/src/pages/TickerDetailPage.tsx b/frontend/src/pages/TickerDetailPage.tsx index 9ed4554..c8abe97 100644 --- a/frontend/src/pages/TickerDetailPage.tsx +++ b/frontend/src/pages/TickerDetailPage.tsx @@ -361,6 +361,16 @@ export default function TickerDetailPage() {

+ {/* Data freshness — up top: per-source age + refresh, always in view */} +
+ +
+ {/* Tab row — inline, hairline, overlay pills ride along on Analysis */}
{detailTabs.map((t) => ( @@ -558,15 +568,6 @@ export default function TickerDetailPage() { )}
- {/* Panel foot — per-source data age + refresh */} -
- -