Freshness first row; level clustering; verdict typography;
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 1m5s
Deploy / deploy (push) Successful in 34s

selectable targets drive the rail

- Data freshness is now the very first row of the ticker panel, above
  the symbol - data age is the first thing to check.
- Indicator cards: long price-level lists (pivot swing highs/lows,
  volume-profile HVN/LVN) no longer clip or overwhelm. Numeric lists
  are clustered into ranges (values within ~0.75% of price merge),
  sorted nearest-to-price first, capped at 4 chips with a "+n more"
  note; scalar values wrap instead of overflowing the card.
- Recommendation verdict: the action ("LONG (high confidence)") is set
  in the display face in accent cyan with the risk level beside it;
  the signal breakdown (technical/momentum/sentiment) drops to a small
  muted subtitle line.
- Target ladder is open by default and selectable: clicking a row
  previews that target on the price rail and updates the R:R and
  probability chips, the low-probability warning, and the prefilled
  target in Mark-as-taken (still overridable there). The scanner's
  primary stays the default; a non-primary choice is chipped as
  "custom target".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 12:36:39 +02:00
co-authored by Claude Fable 5
parent 03bdcbd745
commit 9f56a25f2d
3 changed files with 182 additions and 63 deletions
@@ -15,6 +15,8 @@ const INDICATOR_LABELS: Record<string, string> = {
interface IndicatorSelectorProps {
symbol: string;
/** Used to sort level clusters (pivots, HVN/LVN) by distance to price. */
currentPrice?: number;
}
const signalColors: Record<string, string> = {
@@ -34,6 +36,49 @@ function prettyKey(key: string): string {
return key.replace(/_/g, ' ');
}
/** Extract a numeric list from an array or a "[1.2, 3.4, …]"-ish string. */
function parseNums(val: unknown): number[] | null {
if (Array.isArray(val)) {
const ns = val.filter((x): x is number => typeof x === 'number' && Number.isFinite(x));
return ns.length >= 2 ? ns : null;
}
if (typeof val === 'string') {
const ns = (val.match(/-?\d+(?:\.\d+)?/g) ?? []).map(Number).filter(Number.isFinite);
return ns.length >= 2 ? ns : null;
}
return null;
}
const MAX_CLUSTERS = 4;
/**
* A long list of price levels (swing highs, HVN/LVN, pivots) is noise — merge
* values within ~0.75% of price into ranges and show only the clusters nearest
* the current price.
*/
function clusterLevels(nums: number[], refPrice?: number): { shown: string[]; more: number } {
const sorted = [...nums].sort((a, b) => a - b);
const mid = refPrice ?? (sorted[0] + sorted[sorted.length - 1]) / 2;
const tol = Math.max(Math.abs(mid) * 0.0075, (sorted[sorted.length - 1] - sorted[0]) / 40, 0.01);
const clusters: { lo: number; hi: number }[] = [];
for (const n of sorted) {
const last = clusters[clusters.length - 1];
if (last && n - last.hi <= tol) last.hi = n;
else clusters.push({ lo: n, hi: n });
}
if (refPrice != null) {
clusters.sort((a, b) => {
const da = Math.abs((a.lo + a.hi) / 2 - refPrice);
const db = Math.abs((b.lo + b.hi) / 2 - refPrice);
return da - db;
});
}
const shown = clusters.slice(0, MAX_CLUSTERS).map((c) =>
c.hi - c.lo < tol / 10 ? fmtVal(c.lo) : `${fmtVal(c.lo)}${fmtVal(c.hi)}`,
);
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 {
const v = result.values as Record<string, unknown>;
@@ -58,8 +103,33 @@ function interpretation(result: IndicatorResult): { text: string; tone: string }
}
}
/** One key/value line — level lists collapse to nearest-first range chips. */
function ValueRow({ name, val, refPrice }: { name: string; val: unknown; refPrice?: number }) {
const nums = parseNums(val);
if (nums) {
const { shown, more } = clusterLevels(nums, refPrice);
return (
<div className="text-xs">
<dt className="text-gray-500">{prettyKey(name)} <span className="text-gray-600">· nearest first</span></dt>
<dd className="num mt-1 flex flex-wrap gap-1.5 text-gray-200">
{shown.map((r) => (
<span key={r} className="rounded bg-white/[0.05] px-1.5 py-0.5">{r}</span>
))}
{more > 0 && <span className="self-center text-gray-600">+{more} more</span>}
</dd>
</div>
);
}
return (
<div className="flex items-baseline justify-between gap-3 text-xs">
<dt className="shrink-0 text-gray-500">{prettyKey(name)}</dt>
<dd className="num min-w-0 break-all text-right text-gray-200">{fmtVal(val as number | string)}</dd>
</div>
);
}
/** One indicator, fetched independently — a quiet outlined card. */
function IndicatorCard({ symbol, type }: { symbol: string; type: string }) {
function IndicatorCard({ symbol, type, refPrice }: { symbol: string; type: string; refPrice?: number }) {
const query = useQuery({
queryKey: ['indicator', symbol, type],
queryFn: () => getIndicator(symbol, type),
@@ -99,12 +169,9 @@ function IndicatorCard({ symbol, type }: { symbol: string; type: string }) {
<p className={`mt-1.5 text-sm font-semibold ${read.tone}`}>{read.text}</p>
) : null;
})()}
<dl className="mt-2.5 space-y-1">
<dl className="mt-2.5 space-y-1.5">
{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>
<ValueRow key={key} name={key} val={val} refPrice={refPrice} />
))}
{Object.keys(query.data.values).length === 0 && (
<p className="text-xs text-gray-500">No values.</p>
@@ -157,13 +224,13 @@ function EMACrossCard({ symbol }: { symbol: string }) {
}
/** All indicators at once — no dropdown, every value visible or one card away. */
export function IndicatorSelector({ symbol }: IndicatorSelectorProps) {
export function IndicatorSelector({ symbol, currentPrice }: IndicatorSelectorProps) {
return (
<div className="glass p-5">
<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} />
<IndicatorCard key={type} symbol={symbol} type={type} refPrice={currentPrice} />
))}
</div>
</div>
@@ -100,14 +100,20 @@ function Chip({ children }: { children: React.ReactNode }) {
);
}
function TargetTable({ setup }: { setup: TradeSetup }) {
type Target = NonNullable<TradeSetup['targets']>[number];
function TargetTable({ setup, selectedPrice, onSelect }: {
setup: TradeSetup;
selectedPrice: number;
onSelect: (target: Target) => void;
}) {
if (!setup.targets || setup.targets.length === 0) {
return <p className="text-xs text-gray-500">No target probabilities available.</p>;
}
return (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<table className="w-full text-xs" role="radiogroup" aria-label="Choose the target for the rail and paper trade">
<thead>
<tr className="text-left text-gray-500 border-b border-white/[0.06]">
<th className="py-2 pr-3">Classification</th>
@@ -118,21 +124,42 @@ function TargetTable({ setup }: { setup: TradeSetup }) {
</tr>
</thead>
<tbody>
{setup.targets.map((target) => (
<tr
key={`${setup.id}-${target.sr_level_id}-${target.price}`}
className={`border-b border-white/[0.04] ${target.is_primary ? 'bg-blue-400/10' : ''}`}
>
<td className="py-2 pr-3 text-gray-300">
{target.is_primary && <span className="mr-1 text-blue-300"></span>}
{target.classification}
</td>
<td className="py-2 pr-3 font-mono text-gray-200">{formatPrice(target.price)}</td>
<td className="py-2 pr-3 font-mono text-gray-200">{formatPercent((target.distance_from_entry / setup.entry_price) * 100)}</td>
<td className="py-2 pr-3 font-mono text-gray-200">{target.rr_ratio.toFixed(2)}</td>
<td className="py-2 font-mono text-gray-200">{target.probability.toFixed(1)}%</td>
</tr>
))}
{setup.targets.map((target) => {
const isSel = target.price === selectedPrice;
return (
<tr
key={`${setup.id}-${target.sr_level_id}-${target.price}`}
role="radio"
aria-checked={isSel}
tabIndex={0}
onClick={() => onSelect(target)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(target);
}
}}
className={`cursor-pointer border-b border-white/[0.04] transition-colors ${
isSel ? 'bg-blue-400/10' : 'hover:bg-white/[0.03]'
}`}
>
<td className="py-2 pr-3 text-gray-300">
<span
className={`mr-2 inline-block h-1.5 w-1.5 rounded-full align-middle ${
isSel ? 'bg-blue-400' : 'border border-gray-600'
}`}
aria-hidden="true"
/>
{target.is_primary && <span className="mr-1 text-blue-300"></span>}
{target.classification}
</td>
<td className="py-2 pr-3 font-mono text-gray-200">{formatPrice(target.price)}</td>
<td className="py-2 pr-3 font-mono text-gray-200">{formatPercent((target.distance_from_entry / setup.entry_price) * 100)}</td>
<td className="py-2 pr-3 font-mono text-gray-200">{target.rr_ratio.toFixed(2)}</td>
<td className="py-2 font-mono text-gray-200">{target.probability.toFixed(1)}%</td>
</tr>
);
})}
</tbody>
</table>
</div>
@@ -167,6 +194,14 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
const [takeEntry, setTakeEntry] = useState<number>(currentPrice ?? setup.entry_price);
const [takeTarget, setTakeTarget] = useState<number>(setup.target);
// Target choice from the ladder drives the rail, the chips, and the take
// flow — the scanner's primary is just the default.
const [selPrice, setSelPrice] = useState<number | null>(null);
const selected = selPrice != null ? (setup.targets ?? []).find((t) => t.price === selPrice) ?? null : null;
const activePrice = selected?.price ?? setup.target;
const activeRR = selected?.rr_ratio ?? setup.rr_ratio;
const activeProb = selected?.probability ?? prob;
const confirmTake = () => {
createTrade.mutate(
{
@@ -216,8 +251,9 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
</span>
)}
<Chip>confidence {setup.confidence_score?.toFixed(0) ?? '—'}%</Chip>
<Chip>R:R {setup.rr_ratio.toFixed(1)}:1</Chip>
{prob != null && <Chip>target prob {Math.round(prob)}%</Chip>}
<Chip>R:R {activeRR.toFixed(1)}:1</Chip>
{activeProb != null && <Chip>target prob {Math.round(activeProb)}%</Chip>}
{selected && !selected.is_primary && <Chip>custom target</Chip>}
</div>
{/* Warnings — only when they apply */}
@@ -246,18 +282,18 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
)}
</div>
)}
{prob != null && prob < 15 && (
{activeProb != null && activeProb < 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.
This target has only a {Math.round(activeProb)}% probability pick a nearer one from the target list below.
</p>
)}
{/* The setup, spatially — stop → entry → now → target */}
{/* The setup, spatially — stop → entry → now → the *selected* target */}
<PriceRail
direction={setup.direction}
entry={setup.entry_price}
stop={setup.stop_loss}
target={setup.target}
target={activePrice}
current={currentPrice ?? null}
/>
@@ -276,7 +312,7 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
onClick={() => {
setTakeShares(sizing?.shares ?? 0);
setTakeEntry(currentPrice ?? setup.entry_price);
setTakeTarget(setup.target);
setTakeTarget(activePrice);
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"
@@ -348,14 +384,21 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
</div>
)}
{/* Full target ladder — primary is already on the rail */}
{/* Target ladder — open by default; clicking a row previews it on the rail */}
{setup.targets && setup.targets.length > 0 && (
<details className="mt-3">
<details className="mt-3" open>
<summary className="cursor-pointer text-[11px] font-medium text-gray-500 transition-colors hover:text-gray-300">
All targets ({setup.targets.length}) · price / distance / R:R / probability
Targets ({setup.targets.length}) · select a row to preview it on the rail and use it when taking
</summary>
<div className="mt-2">
<TargetTable setup={setup} />
<TargetTable
setup={setup}
selectedPrice={activePrice}
onSelect={(t) => {
setSelPrice(t.price);
setTakeTarget(t.price);
}}
/>
</div>
</details>
)}
@@ -444,22 +487,32 @@ 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>
)}
<span className={`text-sm font-semibold ${riskClass(summary?.risk_level ?? null)}`}>
Risk: {summary?.risk_level ?? '—'}
</span>
<div className="ml-auto">
{/* Verdict: action loud, the signal detail as a quiet subtitle */}
<div className="flex flex-wrap items-start justify-between gap-x-6 gap-y-2">
<div className="min-w-0">
{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>
) : (() => {
const reasoning = summary?.reasoning ?? '';
const idx = reasoning.indexOf(':');
const head = idx > 0 ? reasoning.slice(0, idx) : recommendationActionLabel(action);
const tail = idx > 0 ? reasoning.slice(idx + 1).trim() : reasoning;
return (
<>
<p className="flex flex-wrap items-baseline gap-x-3">
<span className="font-display text-lg font-semibold tracking-tight text-blue-300">{head}</span>
<span className={`text-xs font-semibold ${riskClass(summary?.risk_level ?? null)}`}>
Risk: {summary?.risk_level ?? '—'}
</span>
</p>
{tail && <p className="mt-0.5 text-xs leading-relaxed text-gray-500">{tail}</p>}
</>
);
})()}
</div>
<div className="shrink-0">
<RiskControls risk={risk} update={updateRisk} />
</div>
</div>
+10 -11
View File
@@ -273,6 +273,15 @@ export default function TickerDetailPage() {
<div className="space-y-6 animate-slide-up">
{/* Drill-down header — identity + chips left, score fingerprint right */}
<section className="glass overflow-hidden">
{/* Data freshness — the very first row: how fresh is what I'm looking at */}
<div className="border-b border-white/[0.06] px-6 py-2.5 sm:px-7">
<DataFreshnessBar
items={dataStatus}
onRefresh={handleRefresh}
pendingLabel={refreshingLabel}
busy={ingestion.isPending}
/>
</div>
<div className="p-6 pb-5 sm:p-7 sm:pb-5">
<div className="flex flex-wrap items-start justify-between gap-x-8 gap-y-5">
<div className="min-w-0">
@@ -361,16 +370,6 @@ 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) => (
@@ -510,7 +509,7 @@ export default function TickerDetailPage() {
{activeTab === 'Indicators' && (
<div className="hz-frameless animate-fade-in">
<IndicatorSelector symbol={symbol} />
<IndicatorSelector symbol={symbol} currentPrice={priceInfo?.price} />
</div>
)}