Freshness first row; level clustering; verdict typography;
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:
@@ -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,12 +124,32 @@ function TargetTable({ setup }: { setup: TradeSetup }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{setup.targets.map((target) => (
|
||||
{setup.targets.map((target) => {
|
||||
const isSel = target.price === selectedPrice;
|
||||
return (
|
||||
<tr
|
||||
key={`${setup.id}-${target.sr_level_id}-${target.price}`}
|
||||
className={`border-b border-white/[0.04] ${target.is_primary ? 'bg-blue-400/10' : ''}`}
|
||||
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>
|
||||
@@ -132,7 +158,8 @@ function TargetTable({ setup }: { setup: TradeSetup }) {
|
||||
<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">
|
||||
{/* 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>
|
||||
) : 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)}`}>
|
||||
) : (() => {
|
||||
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>
|
||||
<div className="ml-auto">
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user