Ticker panel polish: freshness up top, radar pad, indicator grid,
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 1m4s
Deploy / deploy (push) Successful in 35s

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 12:20:23 +02:00
co-authored by Claude Fable 5
parent d806ede07e
commit 03bdcbd745
4 changed files with 180 additions and 114 deletions
+2 -1
View File
@@ -137,7 +137,8 @@ export function RadarChart({
const [hover, setHover] = useState<number | null>(null); const [hover, setHover] = useState<number | null>(null);
const n = axes.length; const n = axes.length;
if (n < 3) return null; 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 vb = size + pad * 2;
const c = vb / 2; const c = vb / 2;
const r = size / 2; const r = size / 2;
@@ -1,129 +1,170 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { getIndicator, getEMACross } from '../../api/indicators'; import { getIndicator, getEMACross } from '../../api/indicators';
import { Dropdown } from '../ui/Dropdown'; import type { IndicatorResult } from '../../lib/types';
import type { IndicatorResult, EMACrossResult } 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<string, string> = {
RSI: 'RSI',
ADX: 'ADX · trend strength',
EMA: 'EMA',
ATR: 'ATR · volatility',
volume_profile: 'Volume profile',
pivot_points: 'Pivot points',
};
interface IndicatorSelectorProps { interface IndicatorSelectorProps {
symbol: string; symbol: string;
} }
const signalColors: Record<string, string> = { const signalColors: Record<string, string> = {
bullish: 'text-emerald-400', bullish: 'text-emerald-300',
bearish: 'text-red-400', bearish: 'text-red-300',
neutral: 'text-gray-300', neutral: 'text-gray-300',
}; };
function IndicatorResultDisplay({ result }: { result: IndicatorResult }) { 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 prettyKey(key: string): string {
return key.replace(/_/g, ' ');
}
/** 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<string, unknown>;
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;
}
}
/** 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 ( return (
<div className="space-y-2 text-sm"> <div className="rounded-xl border border-white/[0.07] p-4">
<div className="flex justify-between"> <div className="flex items-baseline justify-between gap-2">
<span className="text-gray-400">Type</span> <h4 className="num text-[10px] uppercase tracking-[0.16em] text-gray-500">
<span className="text-gray-200">{result.indicator_type}</span> {INDICATOR_LABELS[type] ?? type}
</h4>
{query.data && (
<span className="num text-[10px] text-gray-600" title={`${query.data.bars_used} bars used · normalized score`}>
score {query.data.score.toFixed(2)}
</span>
)}
</div> </div>
<div className="flex justify-between">
<span className="text-gray-400">Normalized Score</span> {query.isLoading && (
<span className="text-gray-200">{result.score.toFixed(2)}</span> <div className="mt-3 animate-pulse space-y-2">
<div className="h-3.5 w-3/4 rounded bg-white/[0.05]" />
<div className="h-3.5 w-1/2 rounded bg-white/[0.05]" />
</div> </div>
<div className="flex justify-between"> )}
<span className="text-gray-400">Bars Used</span> {query.isError && (
<span className="text-gray-200">{result.bars_used}</span> <p className="mt-3 text-xs text-gray-500">
</div> Not available{query.error instanceof Error && query.error.message ? `${query.error.message}` : ''}.
{Object.keys(result.values).length > 0 && ( </p>
<div className="mt-2 border-t border-white/[0.06] pt-2"> )}
<p className="mb-1 text-[10px] font-medium uppercase tracking-widest text-gray-500">Values</p> {query.data && (
{Object.entries(result.values).map(([key, val]) => ( <>
<div key={key} className="flex justify-between"> {(() => {
<span className="text-gray-400">{key}</span> const read = interpretation(query.data);
<span className="text-gray-200">{typeof val === 'number' ? val.toFixed(4) : String(val)}</span> return read ? (
<p className={`mt-1.5 text-sm font-semibold ${read.tone}`}>{read.text}</p>
) : null;
})()}
<dl className="mt-2.5 space-y-1">
{Object.entries(query.data.values).map(([key, val]) => (
<div key={key} className="flex items-baseline justify-between gap-3 text-xs">
<dt className="text-gray-500">{prettyKey(key)}</dt>
<dd className="num text-gray-200">{fmtVal(val as number | string)}</dd>
</div> </div>
))} ))}
</div> {Object.keys(query.data.values).length === 0 && (
<p className="text-xs text-gray-500">No values.</p>
)}
</dl>
</>
)} )}
</div> </div>
); );
} }
function EMACrossDisplay({ result }: { result: EMACrossResult }) { /** EMA cross gets its own card — it carries a directional signal. */
return ( function EMACrossCard({ symbol }: { symbol: string }) {
<div className="space-y-2 text-sm"> const query = useQuery({
<div className="flex justify-between">
<span className="text-gray-400">Signal</span>
<span className={signalColors[result.signal] ?? 'text-gray-300'}>{result.signal}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Short EMA ({result.short_period})</span>
<span className="text-gray-200">{result.short_ema.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-400">Long EMA ({result.long_period})</span>
<span className="text-gray-200">{result.long_ema.toFixed(2)}</span>
</div>
</div>
);
}
export function IndicatorSelector({ symbol }: IndicatorSelectorProps) {
const [selectedType, setSelectedType] = useState<string>('');
const [showEMACross, setShowEMACross] = useState(false);
const indicatorQuery = useQuery({
queryKey: ['indicator', symbol, selectedType],
queryFn: () => getIndicator(symbol, selectedType),
enabled: !!symbol && !!selectedType,
});
const emaCrossQuery = useQuery({
queryKey: ['ema-cross', symbol], queryKey: ['ema-cross', symbol],
queryFn: () => getEMACross(symbol), queryFn: () => getEMACross(symbol),
enabled: !!symbol && showEMACross, staleTime: 5 * 60_000,
retry: 1,
}); });
return (
<div className="rounded-xl border border-white/[0.07] p-4">
<h4 className="num text-[10px] uppercase tracking-[0.16em] text-gray-500">EMA cross</h4>
{query.isLoading && (
<div className="mt-3 animate-pulse space-y-2">
<div className="h-3.5 w-3/4 rounded bg-white/[0.05]" />
<div className="h-3.5 w-1/2 rounded bg-white/[0.05]" />
</div>
)}
{query.isError && <p className="mt-3 text-xs text-gray-500">Not available.</p>}
{query.data && (
<>
<p className={`mt-1.5 text-sm font-semibold ${signalColors[query.data.signal] ?? 'text-gray-300'}`}>
{query.data.signal}
</p>
<dl className="mt-2.5 space-y-1">
<div className="flex items-baseline justify-between gap-3 text-xs">
<dt className="text-gray-500">short EMA ({query.data.short_period})</dt>
<dd className="num text-gray-200">{query.data.short_ema.toFixed(2)}</dd>
</div>
<div className="flex items-baseline justify-between gap-3 text-xs">
<dt className="text-gray-500">long EMA ({query.data.long_period})</dt>
<dd className="num text-gray-200">{query.data.long_ema.toFixed(2)}</dd>
</div>
</dl>
</>
)}
</div>
);
}
/** All indicators at once — no dropdown, every value visible or one card away. */
export function IndicatorSelector({ symbol }: IndicatorSelectorProps) {
return ( return (
<div className="glass p-5"> <div className="glass p-5">
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Indicators</h3> <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<EMACrossCard symbol={symbol} />
<div className="mb-4"> {INDICATOR_TYPES.map((type) => (
<Dropdown <IndicatorCard key={type} symbol={symbol} type={type} />
value={selectedType} ))}
onChange={setSelectedType}
placeholder="Select indicator…"
options={INDICATOR_TYPES.map((type) => ({ value: type, label: type }))}
/>
</div>
{selectedType && indicatorQuery.isLoading && (
<div className="animate-pulse space-y-2">
<div className="h-4 w-3/4 rounded bg-white/[0.05]" />
<div className="h-4 w-1/2 rounded bg-white/[0.05]" />
<div className="h-4 w-2/3 rounded bg-white/[0.05]" />
</div>
)}
{selectedType && indicatorQuery.isError && (
<p className="text-sm text-red-400">
{indicatorQuery.error instanceof Error ? indicatorQuery.error.message : 'Failed to load indicator'}
</p>
)}
{indicatorQuery.data && <IndicatorResultDisplay result={indicatorQuery.data} />}
<div className="mt-4 border-t border-white/[0.06] pt-4">
<button
onClick={() => setShowEMACross(true)}
disabled={showEMACross && emaCrossQuery.isLoading}
className="w-full rounded-lg border border-white/[0.08] bg-white/[0.04] px-3 py-2.5 text-sm text-gray-200 transition-all duration-200 hover:bg-white/[0.07] disabled:opacity-50"
>
{emaCrossQuery.isLoading ? 'Loading EMA Cross…' : 'Show EMA Cross Signal'}
</button>
{emaCrossQuery.isError && (
<p className="mt-2 text-sm text-red-400">
{emaCrossQuery.error instanceof Error ? emaCrossQuery.error.message : 'Failed to load EMA cross'}
</p>
)}
{emaCrossQuery.data && (
<div className="mt-3"><EMACrossDisplay result={emaCrossQuery.data} /></div>
)}
</div> </div>
</div> </div>
); );
@@ -165,6 +165,7 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
const [taking, setTaking] = useState(false); const [taking, setTaking] = useState(false);
const [takeShares, setTakeShares] = useState<number>(sizing?.shares ?? 0); const [takeShares, setTakeShares] = useState<number>(sizing?.shares ?? 0);
const [takeEntry, setTakeEntry] = useState<number>(currentPrice ?? setup.entry_price); const [takeEntry, setTakeEntry] = useState<number>(currentPrice ?? setup.entry_price);
const [takeTarget, setTakeTarget] = useState<number>(setup.target);
const confirmTake = () => { const confirmTake = () => {
createTrade.mutate( createTrade.mutate(
@@ -174,7 +175,7 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
entry_price: takeEntry, entry_price: takeEntry,
shares: takeShares, shares: takeShares,
stop_loss: setup.stop_loss, stop_loss: setup.stop_loss,
target: setup.target, target: takeTarget,
}, },
{ onSuccess: () => setTaking(false) }, { onSuccess: () => setTaking(false) },
); );
@@ -245,6 +246,11 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
)} )}
</div> </div>
)} )}
{prob != null && prob < 15 && (
<p className="mt-2.5 text-[11px] text-amber-400">
The primary target has only a {Math.round(prob)}% probability pick a nearer target from the list when taking the trade.
</p>
)}
{/* The setup, spatially — stop → entry → now → target */} {/* The setup, spatially — stop → entry → now → target */}
<PriceRail <PriceRail
@@ -270,6 +276,7 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
onClick={() => { onClick={() => {
setTakeShares(sizing?.shares ?? 0); setTakeShares(sizing?.shares ?? 0);
setTakeEntry(currentPrice ?? setup.entry_price); setTakeEntry(currentPrice ?? setup.entry_price);
setTakeTarget(setup.target);
setTaking(true); 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" 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
/> />
</label> </label>
</div> </div>
{setup.targets && setup.targets.length > 1 ? (
<label className="block space-y-1">
<span className="num text-[10px] uppercase tracking-wider text-gray-500">Target</span>
<select
value={takeTarget}
onChange={(e) => setTakeTarget(Number(e.target.value))}
className="input-glass w-full px-2 py-1.5 text-sm num"
>
{setup.targets.map((t) => (
<option key={`${t.sr_level_id}-${t.price}`} value={t.price} className="bg-[#14161f]">
{formatPrice(t.price)} · {t.probability.toFixed(0)}% · {t.classification}{t.is_primary ? ' · primary' : ''}
</option>
))}
</select>
</label>
) : null}
<p className="num text-[10px] text-gray-500"> <p className="num text-[10px] text-gray-500">
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
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
@@ -421,11 +444,15 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
const body = ( const body = (
<div className="space-y-4"> <div className="space-y-4">
{/* One verdict line — the reasoning already contains the action label,
so it replaces it instead of repeating it. */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2"> <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
{preferredInactive ? ( {preferredInactive ? (
<span className="text-sm font-semibold text-gray-400"> <span className="text-sm font-semibold text-gray-400">
No current setup <span className="font-normal text-gray-500">(last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} {preferredInactive.invalidated ? 'invalidated' : 'played out'})</span> No current setup <span className="font-normal text-gray-500">(last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} {preferredInactive.invalidated ? 'invalidated' : 'played out'})</span>
</span> </span>
) : summary?.reasoning ? (
<span className="text-sm leading-relaxed text-gray-200">{summary.reasoning}</span>
) : ( ) : (
<span className="text-sm font-semibold text-blue-300">{recommendationActionLabel(action)}</span> <span className="text-sm font-semibold text-blue-300">{recommendationActionLabel(action)}</span>
)} )}
@@ -437,10 +464,6 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
</div> </div>
</div> </div>
{summary?.reasoning && !preferredInactive && (
<p className="text-sm leading-relaxed text-gray-300">{summary.reasoning}</p>
)}
{earningsDays != null && earningsDays >= 0 && ( {earningsDays != null && earningsDays >= 0 && (
earningsDays <= EARNINGS_HORIZON_DAYS ? ( earningsDays <= EARNINGS_HORIZON_DAYS ? (
<p className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-300"> <p className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-300">
+10 -9
View File
@@ -361,6 +361,16 @@ export default function TickerDetailPage() {
</div> </div>
</div> </div>
{/* Data freshness — up top: per-source age + refresh, always in view */}
<div className="border-t border-white/[0.06] px-6 py-2.5 sm:px-7">
<DataFreshnessBar
items={dataStatus}
onRefresh={handleRefresh}
pendingLabel={refreshingLabel}
busy={ingestion.isPending}
/>
</div>
{/* Tab row — inline, hairline, overlay pills ride along on Analysis */} {/* Tab row — inline, hairline, overlay pills ride along on Analysis */}
<div className="flex flex-wrap items-center gap-x-6 gap-y-1 border-b border-white/[0.06] px-6 sm:px-7" role="tablist" aria-label="Ticker sections"> <div className="flex flex-wrap items-center gap-x-6 gap-y-1 border-b border-white/[0.06] px-6 sm:px-7" role="tablist" aria-label="Ticker sections">
{detailTabs.map((t) => ( {detailTabs.map((t) => (
@@ -558,15 +568,6 @@ export default function TickerDetailPage() {
)} )}
</div> </div>
{/* Panel foot — per-source data age + refresh */}
<div className="border-t border-white/[0.06] px-6 py-3 sm:px-7">
<DataFreshnessBar
items={dataStatus}
onRefresh={handleRefresh}
pendingLabel={refreshingLabel}
busy={ingestion.isPending}
/>
</div>
</section> </section>
</div> </div>