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 <noreply@anthropic.com>
This commit is contained in:
@@ -137,7 +137,8 @@ export function RadarChart({
|
||||
const [hover, setHover] = useState<number | null>(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;
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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 }) {
|
||||
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 (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Type</span>
|
||||
<span className="text-gray-200">{result.indicator_type}</span>
|
||||
<div className="rounded-xl border border-white/[0.07] p-4">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<h4 className="num text-[10px] uppercase tracking-[0.16em] text-gray-500">
|
||||
{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 className="flex justify-between">
|
||||
<span className="text-gray-400">Normalized Score</span>
|
||||
<span className="text-gray-200">{result.score.toFixed(2)}</span>
|
||||
|
||||
{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>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Bars Used</span>
|
||||
<span className="text-gray-200">{result.bars_used}</span>
|
||||
</div>
|
||||
{Object.keys(result.values).length > 0 && (
|
||||
<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>
|
||||
{Object.entries(result.values).map(([key, val]) => (
|
||||
<div key={key} className="flex justify-between">
|
||||
<span className="text-gray-400">{key}</span>
|
||||
<span className="text-gray-200">{typeof val === 'number' ? val.toFixed(4) : String(val)}</span>
|
||||
)}
|
||||
{query.isError && (
|
||||
<p className="mt-3 text-xs text-gray-500">
|
||||
Not available{query.error instanceof Error && query.error.message ? ` — ${query.error.message}` : ''}.
|
||||
</p>
|
||||
)}
|
||||
{query.data && (
|
||||
<>
|
||||
{(() => {
|
||||
const read = interpretation(query.data);
|
||||
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>
|
||||
{Object.keys(query.data.values).length === 0 && (
|
||||
<p className="text-xs text-gray-500">No values.</p>
|
||||
)}
|
||||
</dl>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EMACrossDisplay({ result }: { result: EMACrossResult }) {
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
<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({
|
||||
/** 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),
|
||||
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 (
|
||||
<div className="glass p-5">
|
||||
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Indicators</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<Dropdown
|
||||
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 className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<EMACrossCard symbol={symbol} />
|
||||
{INDICATOR_TYPES.map((type) => (
|
||||
<IndicatorCard key={type} symbol={symbol} type={type} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -165,6 +165,7 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
|
||||
const [taking, setTaking] = useState(false);
|
||||
const [takeShares, setTakeShares] = useState<number>(sizing?.shares ?? 0);
|
||||
const [takeEntry, setTakeEntry] = useState<number>(currentPrice ?? setup.entry_price);
|
||||
const [takeTarget, setTakeTarget] = useState<number>(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
|
||||
)}
|
||||
</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 */}
|
||||
<PriceRail
|
||||
@@ -270,6 +276,7 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
|
||||
onClick={() => {
|
||||
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
|
||||
/>
|
||||
</label>
|
||||
</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">
|
||||
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>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@@ -421,11 +444,15 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
|
||||
|
||||
const body = (
|
||||
<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">
|
||||
{preferredInactive ? (
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
@@ -437,10 +464,6 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{summary?.reasoning && !preferredInactive && (
|
||||
<p className="text-sm leading-relaxed text-gray-300">{summary.reasoning}</p>
|
||||
)}
|
||||
|
||||
{earningsDays != null && earningsDays >= 0 && (
|
||||
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">
|
||||
|
||||
@@ -361,6 +361,16 @@ export default function TickerDetailPage() {
|
||||
</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 */}
|
||||
<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) => (
|
||||
@@ -558,15 +568,6 @@ export default function TickerDetailPage() {
|
||||
)}
|
||||
</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>
|
||||
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user