Target selection syncs to chart; stable rail scale; sizing top-right
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 1m4s
Deploy / deploy (push) Successful in 35s

- The ladder target choice is lifted to the ticker page (per
  direction), so selecting a target updates the candlestick overlay's
  target line and zone too, with the R:R for the tooltip taken from
  the ladder row.
- Price rail: the scale now always spans the entire target ladder, so
  stop / entry / now hold their positions and only the target marker
  moves when a different target is selected. The furthest target sits
  at the right edge.
- Setup card layout: position sizing (shares / value / max loss,
  details in the tooltip) and the Mark-as-taken button move to the top
  right of the card header row, next to the stats they belong with -
  the dangling bottom row is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 12:52:18 +02:00
co-authored by Claude Fable 5
parent 9f56a25f2d
commit 16121042cd
3 changed files with 89 additions and 39 deletions
+5 -2
View File
@@ -43,15 +43,18 @@ export function RBar({ r, max = 1.6 }: { r: number | null; max?: number }) {
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
export function PriceRail({ export function PriceRail({
direction, entry, stop, target, current, direction, entry, stop, target, current, scaleTo,
}: { }: {
direction: string; direction: string;
entry: number; entry: number;
stop: number; stop: number;
target: number; target: number;
current: number | null; current: number | null;
/** Extra prices the scale must span (e.g. every target in the ladder), so
* switching targets moves the target marker, not stop/entry/now. */
scaleTo?: number[];
}) { }) {
const points = [entry, stop, target, ...(current != null ? [current] : [])]; const points = [entry, stop, target, ...(current != null ? [current] : []), ...(scaleTo ?? [])];
const span = Math.max(...points) - Math.min(...points) || 1; const span = Math.max(...points) - Math.min(...points) || 1;
const lo = Math.min(...points) - span * 0.06; const lo = Math.min(...points) - span * 0.06;
const hi = Math.max(...points) + span * 0.06; const hi = Math.max(...points) + span * 0.06;
@@ -19,6 +19,9 @@ interface RecommendationPanelProps {
nextEarningsDate?: string | null; nextEarningsDate?: string | null;
/** Render without the section/glass wrapper (inside the unified ticker panel). */ /** Render without the section/glass wrapper (inside the unified ticker panel). */
frameless?: boolean; frameless?: boolean;
/** Lifted target selection per direction, so the candlestick overlay follows. */
selectedTargets?: { long: number | null; short: number | null };
onSelectTarget?: (direction: 'long' | 'short', price: number) => void;
} }
/** Whole days from today until an ISO date (negative if past). */ /** Whole days from today until an ISO date (negative if past). */
@@ -166,7 +169,16 @@ function TargetTable({ setup, selectedPrice, onSelect }: {
); );
} }
function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: TradeSetup; action?: TradeSetup['recommended_action']; currentPrice?: number; risk: RiskSettings; regime?: MarketRegime }) { function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, onSelectPrice }: {
setup?: TradeSetup;
action?: TradeSetup['recommended_action'];
currentPrice?: number;
risk: RiskSettings;
regime?: MarketRegime;
/** Controlled target selection (lifted so the candlestick chart can follow). */
selectedPrice?: number | null;
onSelectPrice?: (price: number) => void;
}) {
if (!setup) { if (!setup) {
return ( return (
<div className="rounded-xl border border-white/[0.07] p-4 text-xs text-gray-500"> <div className="rounded-xl border border-white/[0.07] p-4 text-xs text-gray-500">
@@ -195,8 +207,14 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
const [takeTarget, setTakeTarget] = useState<number>(setup.target); const [takeTarget, setTakeTarget] = useState<number>(setup.target);
// Target choice from the ladder drives the rail, the chips, and the take // Target choice from the ladder drives the rail, the chips, and the take
// flow — the scanner's primary is just the default. // flow — the scanner's primary is just the default. Controlled by the page
const [selPrice, setSelPrice] = useState<number | null>(null); // when provided (so the candlestick overlay follows), else local.
const [internalSel, setInternalSel] = useState<number | null>(null);
const selPrice = selectedPrice !== undefined ? selectedPrice : internalSel;
const selectTargetPrice = (p: number) => {
if (onSelectPrice) onSelectPrice(p);
else setInternalSel(p);
};
const selected = selPrice != null ? (setup.targets ?? []).find((t) => t.price === selPrice) ?? null : null; const selected = selPrice != null ? (setup.targets ?? []).find((t) => t.price === selPrice) ?? null : null;
const activePrice = selected?.price ?? setup.target; const activePrice = selected?.price ?? setup.target;
const activeRR = selected?.rr_ratio ?? setup.rr_ratio; const activeRR = selected?.rr_ratio ?? setup.rr_ratio;
@@ -242,7 +260,7 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
data-direction={setup.direction} data-direction={setup.direction}
className={`rounded-xl border p-4 ${recommended ? 'border-blue-400/25' : 'border-white/[0.07] opacity-80'}`} className={`rounded-xl border p-4 ${recommended ? 'border-blue-400/25' : 'border-white/[0.07] opacity-80'}`}
> >
{/* Identity + key stats in one quiet row */} {/* Identity + key stats left, sizing + take top right */}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<DirTag direction={setup.direction} /> <DirTag direction={setup.direction} />
{recommended && ( {recommended && (
@@ -254,6 +272,32 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
<Chip>R:R {activeRR.toFixed(1)}:1</Chip> <Chip>R:R {activeRR.toFixed(1)}:1</Chip>
{activeProb != null && <Chip>target prob {Math.round(activeProb)}%</Chip>} {activeProb != null && <Chip>target prob {Math.round(activeProb)}%</Chip>}
{selected && !selected.is_primary && <Chip>custom target</Chip>} {selected && !selected.is_primary && <Chip>custom target</Chip>}
<span className="ml-auto flex flex-wrap items-center gap-3">
{sizing ? (
<span
className="num text-xs text-gray-400"
title={`Position size at ${risk.riskPct}% of ${formatPrice(risk.accountSize)}: ${sizing.shares} shares · ${formatPrice(sizing.positionValue)} position value · ${formatPrice(sizing.dollarRisk)} max loss at the stop`}
>
{sizing.shares} sh · {formatPrice(sizing.positionValue)} · risk {formatPrice(sizing.dollarRisk)}
{sizing.exceedsAccount && <span className="ml-1.5 text-amber-400" title="Position exceeds account — needs margin"></span>}
</span>
) : (
<span className="text-[11px] text-gray-600">set account size to size this</span>
)}
{!taking && (
<button
onClick={() => {
setTakeShares(sizing?.shares ?? 0);
setTakeEntry(currentPrice ?? setup.entry_price);
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"
>
Mark as taken
</button>
)}
</span>
</div> </div>
{/* Warnings — only when they apply */} {/* Warnings — only when they apply */}
@@ -288,40 +332,17 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
</p> </p>
)} )}
{/* The setup, spatially — stopentrynow the *selected* target */} {/* The setup, spatially — stop/entry/now hold still, the *selected*
target moves along a scale that always spans the whole ladder */}
<PriceRail <PriceRail
direction={setup.direction} direction={setup.direction}
entry={setup.entry_price} entry={setup.entry_price}
stop={setup.stop_loss} stop={setup.stop_loss}
target={activePrice} target={activePrice}
current={currentPrice ?? null} current={currentPrice ?? null}
scaleTo={(setup.targets ?? []).map((t) => t.price)}
/> />
{/* Sizing + take, one line */}
<div className="mt-1 flex flex-wrap items-center justify-between gap-3 border-t border-white/[0.06] pt-3">
{sizing ? (
<span className="num text-xs text-gray-400">
{sizing.shares} shares · {formatPrice(sizing.positionValue)} position · max loss {formatPrice(sizing.dollarRisk)}
{sizing.exceedsAccount && <span className="ml-2 text-amber-400">exceeds account needs margin</span>}
</span>
) : (
<span className="text-[11px] text-gray-600">Set account size above to size this trade.</span>
)}
{!taking && (
<button
onClick={() => {
setTakeShares(sizing?.shares ?? 0);
setTakeEntry(currentPrice ?? setup.entry_price);
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"
>
Mark as taken
</button>
)}
</div>
{taking && ( {taking && (
<div className="mt-3 rounded-lg border border-white/[0.08] p-3 space-y-2.5"> <div className="mt-3 rounded-lg border border-white/[0.08] p-3 space-y-2.5">
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
@@ -395,7 +416,7 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
setup={setup} setup={setup}
selectedPrice={activePrice} selectedPrice={activePrice}
onSelect={(t) => { onSelect={(t) => {
setSelPrice(t.price); selectTargetPrice(t.price);
setTakeTarget(t.price); setTakeTarget(t.price);
}} }}
/> />
@@ -454,7 +475,13 @@ function RiskControls({ risk, update }: { risk: RiskSettings; update: (p: Partia
); );
} }
export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPrice, nextEarningsDate, frameless = false }: RecommendationPanelProps) { export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPrice, nextEarningsDate, frameless = false, selectedTargets, onSelectTarget }: RecommendationPanelProps) {
const selFor = (setup?: TradeSetup) =>
setup && selectedTargets ? selectedTargets[setup.direction as 'long' | 'short'] : undefined;
const onSelFor = (setup?: TradeSetup) =>
setup && onSelectTarget
? (price: number) => onSelectTarget(setup.direction as 'long' | 'short', price)
: undefined;
const { settings: risk, update: updateRisk } = useRiskSettings(); const { settings: risk, update: updateRisk } = useRiskSettings();
const regime = useMarketRegime().data; const regime = useMarketRegime().data;
const summary = longSetup?.recommendation_summary ?? shortSetup?.recommendation_summary; const summary = longSetup?.recommendation_summary ?? shortSetup?.recommendation_summary;
@@ -530,7 +557,7 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
{preferredDirection !== 'neutral' && preferredSetup ? ( {preferredDirection !== 'neutral' && preferredSetup ? (
<div className="space-y-3"> <div className="space-y-3">
<SetupCard setup={preferredSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} /> <SetupCard setup={preferredSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} selectedPrice={selFor(preferredSetup)} onSelectPrice={onSelFor(preferredSetup)} />
{alternativeSetup && ( {alternativeSetup && (
<details> <details>
@@ -538,15 +565,15 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
Alternative scenario ({alternativeSetup.direction.toUpperCase()}) Alternative scenario ({alternativeSetup.direction.toUpperCase()})
</summary> </summary>
<div className="mt-3"> <div className="mt-3">
<SetupCard setup={alternativeSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} /> <SetupCard setup={alternativeSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} selectedPrice={selFor(alternativeSetup)} onSelectPrice={onSelFor(alternativeSetup)} />
</div> </div>
</details> </details>
)} )}
</div> </div>
) : ( ) : (
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
<SetupCard setup={longSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} /> <SetupCard setup={longSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} selectedPrice={selFor(longSetup)} onSelectPrice={onSelFor(longSetup)} />
<SetupCard setup={shortSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} /> <SetupCard setup={shortSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} selectedPrice={selFor(shortSetup)} onSelectPrice={onSelFor(shortSetup)} />
</div> </div>
)} )}
</div> </div>
+21 -1
View File
@@ -251,6 +251,13 @@ export default function TickerDetailPage() {
// Which setup the chart overlays. 'auto' = the ticker's preferred direction. // Which setup the chart overlays. 'auto' = the ticker's preferred direction.
const [overlayChoice, setOverlayChoice] = useState<'auto' | 'long' | 'short' | 'none'>('auto'); const [overlayChoice, setOverlayChoice] = useState<'auto' | 'long' | 'short' | 'none'>('auto');
// Target chosen in the recommendation ladder, per direction — the chart
// overlay follows it.
const [chosenTargets, setChosenTargets] = useState<{ long: number | null; short: number | null }>({
long: null,
short: null,
});
const action = (longSetup ?? shortSetup)?.recommended_action ?? null; const action = (longSetup ?? shortSetup)?.recommended_action ?? null;
const overlaySetup: TradeSetup | undefined = useMemo(() => { const overlaySetup: TradeSetup | undefined = useMemo(() => {
if (overlayChoice === 'none') return undefined; if (overlayChoice === 'none') return undefined;
@@ -263,6 +270,15 @@ export default function TickerDetailPage() {
return candidates.sort((a, b) => (b.confidence_score ?? 0) - (a.confidence_score ?? 0))[0]; return candidates.sort((a, b) => (b.confidence_score ?? 0) - (a.confidence_score ?? 0))[0];
}, [overlayChoice, longSetup, shortSetup, action]); }, [overlayChoice, longSetup, shortSetup, action]);
// Chart overlay with the ladder-selected target applied (R:R from the ladder).
const overlayWithTarget: TradeSetup | undefined = useMemo(() => {
if (!overlaySetup) return undefined;
const chosen = chosenTargets[overlaySetup.direction as 'long' | 'short'];
if (chosen == null || chosen === overlaySetup.target) return overlaySetup;
const ladder = overlaySetup.targets?.find((t) => t.price === chosen);
return { ...overlaySetup, target: chosen, rr_ratio: ladder?.rr_ratio ?? overlaySetup.rr_ratio };
}, [overlaySetup, chosenTargets]);
// Sort visible S/R levels by strength for the table (only levels within chart zones) // Sort visible S/R levels by strength for the table (only levels within chart zones)
const sortedLevels = useMemo(() => { const sortedLevels = useMemo(() => {
if (!srLevels.data?.visible_levels) return []; if (!srLevels.data?.visible_levels) return [];
@@ -429,7 +445,7 @@ export default function TickerDetailPage() {
data={ohlcv.data} data={ohlcv.data}
srLevels={srLevels.data?.levels} srLevels={srLevels.data?.levels}
zones={srLevels.data?.zones} zones={srLevels.data?.zones}
tradeSetup={overlaySetup} tradeSetup={overlayWithTarget}
currentPrice={priceInfo?.price} currentPrice={priceInfo?.price}
/> />
<p className="mt-2 text-[11px] text-gray-500"> <p className="mt-2 text-[11px] text-gray-500">
@@ -447,6 +463,10 @@ export default function TickerDetailPage() {
shortSetup={shortSetup} shortSetup={shortSetup}
currentPrice={priceInfo?.price} currentPrice={priceInfo?.price}
nextEarningsDate={fundamentals.data?.next_earnings_date} nextEarningsDate={fundamentals.data?.next_earnings_date}
selectedTargets={chosenTargets}
onSelectTarget={(direction, price) =>
setChosenTargets((s) => ({ ...s, [direction]: price }))
}
/> />
</div> </div>
)} )}