diff --git a/frontend/src/components/ticker/IndicatorSelector.tsx b/frontend/src/components/ticker/IndicatorSelector.tsx index 0a8b2f4..53991f2 100644 --- a/frontend/src/components/ticker/IndicatorSelector.tsx +++ b/frontend/src/components/ticker/IndicatorSelector.tsx @@ -15,6 +15,8 @@ const INDICATOR_LABELS: Record = { interface IndicatorSelectorProps { symbol: string; + /** Used to sort level clusters (pivots, HVN/LVN) by distance to price. */ + currentPrice?: number; } const signalColors: Record = { @@ -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; @@ -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 ( +
+
{prettyKey(name)} · nearest first
+
+ {shown.map((r) => ( + {r} + ))} + {more > 0 && +{more} more} +
+
+ ); + } + return ( +
+
{prettyKey(name)}
+
{fmtVal(val as number | string)}
+
+ ); +} + /** 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 }) {

{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.

@@ -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 (
{INDICATOR_TYPES.map((type) => ( - + ))}
diff --git a/frontend/src/components/ticker/RecommendationPanel.tsx b/frontend/src/components/ticker/RecommendationPanel.tsx index 383d32a..cd05d13 100644 --- a/frontend/src/components/ticker/RecommendationPanel.tsx +++ b/frontend/src/components/ticker/RecommendationPanel.tsx @@ -100,14 +100,20 @@ function Chip({ children }: { children: React.ReactNode }) { ); } -function TargetTable({ setup }: { setup: TradeSetup }) { +type Target = NonNullable[number]; + +function TargetTable({ setup, selectedPrice, onSelect }: { + setup: TradeSetup; + selectedPrice: number; + onSelect: (target: Target) => void; +}) { if (!setup.targets || setup.targets.length === 0) { return

No target probabilities available.

; } return (
- +
@@ -118,21 +124,42 @@ function TargetTable({ setup }: { setup: TradeSetup }) { - {setup.targets.map((target) => ( - - - - - - - - ))} + {setup.targets.map((target) => { + const isSel = target.price === selectedPrice; + return ( + 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]' + }`} + > + + + + + + + ); + })}
Classification
- {target.is_primary && } - {target.classification} - {formatPrice(target.price)}{formatPercent((target.distance_from_entry / setup.entry_price) * 100)}{target.rr_ratio.toFixed(2)}{target.probability.toFixed(1)}%
+ {formatPrice(target.price)}{formatPercent((target.distance_from_entry / setup.entry_price) * 100)}{target.rr_ratio.toFixed(2)}{target.probability.toFixed(1)}%
@@ -167,6 +194,14 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad const [takeEntry, setTakeEntry] = useState(currentPrice ?? setup.entry_price); const [takeTarget, setTakeTarget] = useState(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(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 )} confidence {setup.confidence_score?.toFixed(0) ?? '—'}% - R:R {setup.rr_ratio.toFixed(1)}:1 - {prob != null && target prob {Math.round(prob)}%} + R:R {activeRR.toFixed(1)}:1 + {activeProb != null && target prob {Math.round(activeProb)}%} + {selected && !selected.is_primary && custom target} {/* Warnings — only when they apply */} @@ -246,18 +282,18 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad )} )} - {prob != null && prob < 15 && ( + {activeProb != null && activeProb < 15 && (

- ⚠ 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.

)} - {/* The setup, spatially — stop → entry → now → target */} + {/* The setup, spatially — stop → entry → now → the *selected* target */} @@ -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 )} - {/* 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 && ( -
+
- 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
- + { + setSelPrice(t.price); + setTakeTarget(t.price); + }} + />
)} @@ -444,22 +487,32 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric const body = (
- {/* One verdict line — the reasoning already contains the action label, - so it replaces it instead of repeating it. */} -
- {preferredInactive ? ( - - No current setup (last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} — {preferredInactive.invalidated ? 'invalidated' : 'played out'}) - - ) : summary?.reasoning ? ( - {summary.reasoning} - ) : ( - {recommendationActionLabel(action)} - )} - - Risk: {summary?.risk_level ?? '—'} - -
+ {/* Verdict: action loud, the signal detail as a quiet subtitle */} +
+
+ {preferredInactive ? ( + + No current setup (last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} — {preferredInactive.invalidated ? 'invalidated' : 'played out'}) + + ) : (() => { + 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 ( + <> +

+ {head} + + Risk: {summary?.risk_level ?? '—'} + +

+ {tail &&

{tail}

} + + ); + })()} +
+
diff --git a/frontend/src/pages/TickerDetailPage.tsx b/frontend/src/pages/TickerDetailPage.tsx index c8abe97..ede919b 100644 --- a/frontend/src/pages/TickerDetailPage.tsx +++ b/frontend/src/pages/TickerDetailPage.tsx @@ -273,6 +273,15 @@ export default function TickerDetailPage() {
{/* Drill-down header — identity + chips left, score fingerprint right */}
+ {/* Data freshness — the very first row: how fresh is what I'm looking at */} +
+ +
@@ -361,16 +370,6 @@ 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) => ( @@ -510,7 +509,7 @@ export default function TickerDetailPage() { {activeTab === 'Indicators' && (
- +
)}