Fix manual refresh dropping qualified ranks and clarify trade UI.
Single-ticker fetch now attaches residual-momentum ranks so setups do not silently fail the activation gate. Exit plan is a timeline, chart labels move left of the price scale, and missing ranks surface explicitly.
This commit is contained in:
@@ -148,8 +148,17 @@ export function CandlestickChart({
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
// Margins
|
||||
const ml = 12, mr = 70, mt = 12, mb = 32;
|
||||
// Current price = explicit prop, else latest close
|
||||
const livePrice = currentPrice ?? visibleData[visibleData.length - 1].close;
|
||||
// Only the nearest support/resistance are drawn — keep the chart legible
|
||||
const markers = nearestSRMarkers(srLevels, zones, livePrice);
|
||||
|
||||
// Margins: line labels (Entry / Stop / Support…) on the LEFT, pure price
|
||||
// ticks on the RIGHT. Packing both on the right made trade overlays unreadable.
|
||||
const hasLineLabels = Boolean(tradeSetup) || markers.length > 0;
|
||||
const ml = hasLineLabels ? 78 : 16;
|
||||
const mr = 58;
|
||||
const mt = 12, mb = 32;
|
||||
const cw = W - ml - mr;
|
||||
const volumeH = VOLUME_PANE_HEIGHT;
|
||||
const ch = H - mt - mb - volumeH - PANE_GAP;
|
||||
@@ -157,11 +166,6 @@ export function CandlestickChart({
|
||||
const volumeTop = priceBottom + PANE_GAP;
|
||||
const volumeBottom = volumeTop + volumeH;
|
||||
|
||||
// Current price = explicit prop, else latest close
|
||||
const livePrice = currentPrice ?? visibleData[visibleData.length - 1].close;
|
||||
// Only the nearest support/resistance are drawn — keep the chart legible
|
||||
const markers = nearestSRMarkers(srLevels, zones, livePrice);
|
||||
|
||||
// Price range from visible data
|
||||
const allPrices = visibleData.flatMap((b) => [b.high, b.low]);
|
||||
const srPrices = markers.flatMap((m) => [m.low, m.high]);
|
||||
@@ -180,13 +184,13 @@ export function CandlestickChart({
|
||||
const maxVolume = Math.max(...visibleData.map((b) => Math.max(0, b.volume)), 1);
|
||||
const volumeScale = (v: number) => volumeTop + volumeH - (Math.max(0, v) / maxVolume) * volumeH;
|
||||
|
||||
// Grid lines (horizontal)
|
||||
// Grid lines (horizontal) + pure price scale on the RIGHT
|
||||
const nTicks = 6;
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.fillStyle = '#6e7484';
|
||||
ctx.font = '11px "IBM Plex Mono", ui-monospace, monospace';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textAlign = 'left';
|
||||
for (let i = 0; i <= nTicks; i++) {
|
||||
const v = lo + ((hi - lo) * i) / nTicks;
|
||||
const y = yScale(v);
|
||||
@@ -194,7 +198,7 @@ export function CandlestickChart({
|
||||
ctx.moveTo(ml, y);
|
||||
ctx.lineTo(ml + cw, y);
|
||||
ctx.stroke();
|
||||
ctx.fillText(formatPrice(v), W - 8, y + 4);
|
||||
ctx.fillText(formatPrice(v), ml + cw + 6, y + 4);
|
||||
}
|
||||
|
||||
// X-axis labels
|
||||
@@ -285,6 +289,10 @@ export function CandlestickChart({
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Collect left-side line labels; prices live on the right axis only.
|
||||
type LineLabel = { y: number; text: string; color: string; weight?: 'normal' | 'bold' };
|
||||
const lineLabels: LineLabel[] = [];
|
||||
|
||||
// Nearest support/resistance only (band if it came from a zone)
|
||||
markers.forEach((m) => {
|
||||
const isSupport = m.role === 'support';
|
||||
@@ -311,14 +319,11 @@ export function CandlestickChart({
|
||||
ctx.setLineDash([]);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(
|
||||
`${isSupport ? 'Support' : 'Resistance'} ${formatPrice(m.price)} (${m.strength})`,
|
||||
ml + cw + 4,
|
||||
yMid + 3,
|
||||
);
|
||||
lineLabels.push({
|
||||
y: yMid,
|
||||
text: isSupport ? 'Support' : 'Resist',
|
||||
color,
|
||||
});
|
||||
});
|
||||
|
||||
// Trade setup overlay (drawn before candles so candles render on top)
|
||||
@@ -363,15 +368,11 @@ export function CandlestickChart({
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Labels on right side
|
||||
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = 'rgba(154, 160, 176, 0.95)';
|
||||
ctx.fillText(`Entry ${formatPrice(tradeSetup.entry_price)}`, ml + cw + 4, entryY + 3);
|
||||
ctx.fillStyle = 'rgba(239, 145, 130, 0.9)';
|
||||
ctx.fillText(`SL ${formatPrice(tradeSetup.stop_loss)}`, ml + cw + 4, stopY + 3);
|
||||
ctx.fillStyle = 'rgba(196, 181, 253, 0.95)';
|
||||
ctx.fillText(`Gate ${formatPrice(tradeSetup.target)}`, ml + cw + 4, targetY + 3);
|
||||
lineLabels.push(
|
||||
{ y: entryY, text: 'Entry', color: 'rgba(154, 160, 176, 0.95)', weight: 'bold' },
|
||||
{ y: stopY, text: 'Stop', color: 'rgba(239, 145, 130, 0.95)', weight: 'bold' },
|
||||
{ y: targetY, text: 'Gate', color: 'rgba(196, 181, 253, 0.95)', weight: 'bold' },
|
||||
);
|
||||
}
|
||||
|
||||
// Current price line — the anchor for everything else (drawn on top)
|
||||
@@ -384,15 +385,58 @@ export function CandlestickChart({
|
||||
ctx.lineTo(ml + cw, py);
|
||||
ctx.stroke();
|
||||
|
||||
const label = `Now ${formatPrice(livePrice)}`;
|
||||
lineLabels.push({
|
||||
y: py,
|
||||
text: 'Now',
|
||||
color: 'rgba(226, 232, 240, 0.95)',
|
||||
weight: 'bold',
|
||||
});
|
||||
}
|
||||
|
||||
// Left-side role labels (no prices — those sit on the right axis). Spread
|
||||
// stacked labels so Entry/Stop/Gate don't paint over each other when close.
|
||||
if (lineLabels.length > 0) {
|
||||
const ordered = [...lineLabels].sort((a, b) => a.y - b.y);
|
||||
const minGap = 13;
|
||||
for (let i = 1; i < ordered.length; i++) {
|
||||
if (ordered[i].y - ordered[i - 1].y < minGap) {
|
||||
ordered[i].y = ordered[i - 1].y + minGap;
|
||||
}
|
||||
}
|
||||
// If we overflow the pane bottom, pull the stack back up.
|
||||
const maxY = mt + ch - 4;
|
||||
if (ordered.length > 0 && ordered[ordered.length - 1].y > maxY) {
|
||||
let shift = ordered[ordered.length - 1].y - maxY;
|
||||
for (let i = ordered.length - 1; i >= 0 && shift > 0; i--) {
|
||||
const prevFloor = i === 0 ? mt + 4 : ordered[i - 1].y + minGap;
|
||||
const room = ordered[i].y - prevFloor;
|
||||
const pull = Math.min(shift, Math.max(0, room));
|
||||
ordered[i].y -= pull;
|
||||
shift -= pull;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
||||
const tw = ctx.measureText(label).width;
|
||||
ctx.fillStyle = 'rgba(226, 232, 240, 0.95)';
|
||||
ctx.fillRect(ml + 2, py - 8, tw + 8, 16);
|
||||
ctx.fillStyle = '#0a0b11';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, ml + 6, py);
|
||||
for (const label of ordered) {
|
||||
ctx.fillStyle = label.color;
|
||||
if (label.weight === 'bold') {
|
||||
ctx.font = '600 10px "IBM Plex Mono", ui-monospace, monospace';
|
||||
} else {
|
||||
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
||||
}
|
||||
// Short connector tick from label into the plot edge
|
||||
ctx.strokeStyle = label.color;
|
||||
ctx.globalAlpha = 0.35;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ml - 4, label.y);
|
||||
ctx.lineTo(ml, label.y);
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillText(label.text, ml - 8, label.y);
|
||||
}
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
}
|
||||
|
||||
|
||||
@@ -7,56 +7,119 @@ import { formatPrice } from '../../lib/format';
|
||||
* Deliberately sits *above* the levels ladder: the levels are context, this is
|
||||
* the plan. Before this existed the card showed a "Target" with the same visual
|
||||
* weight as the entry, implying a take-profit that the live exit never fires.
|
||||
*
|
||||
* Laid out as a vertical timeline (not label↔value rows) so the sequence —
|
||||
* stop protects → trail arms → trail rides → time stop — reads top to bottom.
|
||||
*/
|
||||
export function ExitPlanPanel({ plan, direction }: { plan: ExitPlan; direction: string }) {
|
||||
const isLong = direction === 'long';
|
||||
const steps = buildSteps(plan, isLong);
|
||||
|
||||
return (
|
||||
<div className="mt-3 rounded-xl border border-white/[0.07] bg-white/[0.02] p-3">
|
||||
<div className="mt-5 rounded-xl border border-white/[0.07] bg-white/[0.02] p-4">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<span className="num text-[10px] uppercase tracking-[0.16em] text-gray-500">how this exits</span>
|
||||
<span className="text-[11.5px] font-medium text-gray-300">{plan.headline}</span>
|
||||
</div>
|
||||
|
||||
<dl className="mt-2.5 grid gap-x-4 gap-y-1.5 text-[11.5px] sm:grid-cols-2">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<dt className="text-gray-500">Initial stop</dt>
|
||||
<dd className="num text-gray-200">
|
||||
{formatPrice(plan.initialStop)}{' '}
|
||||
<span className="text-gray-600">(1R = {formatPrice(plan.riskPerShare)}/sh)</span>
|
||||
</dd>
|
||||
</div>
|
||||
<ol className="relative mt-4 space-y-0">
|
||||
{steps.map((step, i) => {
|
||||
const isLast = i === steps.length - 1;
|
||||
return (
|
||||
<li key={step.title} className="relative flex gap-3 pb-4 last:pb-0">
|
||||
{/* Spine */}
|
||||
<div className="flex w-5 shrink-0 flex-col items-center">
|
||||
<span
|
||||
className={`num mt-0.5 flex h-5 w-5 items-center justify-center rounded-full border text-[10px] font-semibold ${
|
||||
step.tone === 'muted'
|
||||
? 'border-white/[0.12] text-gray-500'
|
||||
: 'border-blue-400/35 bg-blue-400/10 text-blue-200'
|
||||
}`}
|
||||
aria-hidden
|
||||
>
|
||||
{step.icon ?? i + 1}
|
||||
</span>
|
||||
{!isLast && (
|
||||
<span className="mt-1 w-px flex-1 min-h-[12px] bg-white/[0.08]" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{plan.mode === 'atr_trailing' && plan.trailTakesOverAt != null && plan.trailWidthR != null && (
|
||||
<>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<dt className="text-gray-500">Trail takes over</dt>
|
||||
<dd className="num text-gray-200">
|
||||
{isLong ? 'above' : 'below'} {formatPrice(plan.trailTakesOverAt)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-2 sm:col-span-2">
|
||||
<dt className="text-gray-500">Then it trails</dt>
|
||||
<dd className="num text-gray-200">
|
||||
{formatPrice(plan.trailWidth ?? 0)} ({plan.trailWidthR.toFixed(1)}R) below the highest close
|
||||
</dd>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<dt className="text-gray-500">Max hold</dt>
|
||||
<dd className="num text-gray-200">{plan.maxHoldDays} trading days</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
<p className="text-[12px] font-medium text-gray-200">{step.title}</p>
|
||||
{step.primary && (
|
||||
<p className="num mt-0.5 text-[13px] font-semibold tracking-tight text-gray-100">
|
||||
{step.primary}
|
||||
</p>
|
||||
)}
|
||||
{step.detail && (
|
||||
<p className="mt-0.5 text-[11.5px] leading-snug text-gray-500">{step.detail}</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{!plan.honorsTarget && (
|
||||
<p className="mt-2.5 border-t border-white/[0.05] pt-2 text-[11px] leading-relaxed text-gray-500">
|
||||
There is <span className="text-gray-400">no take-profit</span>. Winners are ridden until the
|
||||
trailing stop is hit — that’s where the strategy’s edge comes from, so hitting a level
|
||||
below is not a reason to sell. The levels shown below are screening context, not exits.
|
||||
<p className="mt-1 border-t border-white/[0.05] pt-3 text-[11px] leading-relaxed text-gray-500">
|
||||
Levels on the chart are screening context, not sell targets.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExitStep {
|
||||
title: string;
|
||||
primary?: string;
|
||||
detail?: string;
|
||||
/** Optional glyph instead of a step number (e.g. clock for time stop). */
|
||||
icon?: string;
|
||||
tone?: 'default' | 'muted';
|
||||
}
|
||||
|
||||
function buildSteps(plan: ExitPlan, isLong: boolean): ExitStep[] {
|
||||
const steps: ExitStep[] = [
|
||||
{
|
||||
title: 'Protected by stop',
|
||||
primary: formatPrice(plan.initialStop),
|
||||
detail: `Risk 1R = ${formatPrice(plan.riskPerShare)} per share until the trail is live.`,
|
||||
},
|
||||
];
|
||||
|
||||
if (plan.mode === 'atr_trailing' && plan.trailTakesOverAt != null && plan.trailWidthR != null) {
|
||||
steps.push({
|
||||
title: 'Trail arms',
|
||||
primary: `${isLong ? 'above' : 'below'} ${formatPrice(plan.trailTakesOverAt)}`,
|
||||
detail: 'When the trail would sit beyond the initial stop, it takes over as the floor.',
|
||||
});
|
||||
steps.push({
|
||||
title: 'Then rides the trail',
|
||||
primary: `${formatPrice(plan.trailWidth ?? 0)} · ${plan.trailWidthR.toFixed(1)}R give-back`,
|
||||
detail: isLong
|
||||
? 'Stop = highest close minus trail width. Winners run — no take-profit.'
|
||||
: 'Stop = lowest close plus trail width. Winners run — no take-profit.',
|
||||
});
|
||||
} else if (plan.mode === 'trailing' && plan.trailWidth != null && plan.trailWidthR != null) {
|
||||
steps.push({
|
||||
title: 'Trailing stop',
|
||||
primary: `${formatPrice(plan.trailWidth)} · ${plan.trailWidthR.toFixed(1)}R give-back`,
|
||||
detail: 'Follows price; no fixed take-profit.',
|
||||
});
|
||||
} else if (plan.mode === 'target') {
|
||||
steps.push({
|
||||
title: 'Take profit',
|
||||
primary: 'At the selected level',
|
||||
detail: 'Or exit at the stop if price reverses first.',
|
||||
});
|
||||
}
|
||||
|
||||
steps.push({
|
||||
title: 'Time stop',
|
||||
primary: `${plan.maxHoldDays} trading days`,
|
||||
detail: 'Flat if still open after the max hold.',
|
||||
tone: 'muted',
|
||||
});
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
@@ -70,19 +70,31 @@ export function ProductionRankStrip({ setup, momentumGate }: ProductionRankStrip
|
||||
: null;
|
||||
const rank = storedRank ?? computedBlend ?? momentum;
|
||||
|
||||
if (rank == null && momentum == null && volatility == null) return null;
|
||||
// Always render when a setup exists. Previously we returned null when ranks
|
||||
// were missing, which hid the entire strip after a single-ticker rescan that
|
||||
// forgot to attach percentiles — leaving no clue why the name dropped out of
|
||||
// qualified trades.
|
||||
if (!setup) return null;
|
||||
|
||||
const ranksMissing = rank == null && momentum == null && volatility == null;
|
||||
const normalizedRank = clampPercent(rank ?? 0);
|
||||
const momentumContribution = hasBlend ? clampPercent(momentum) * MOMENTUM_WEIGHT : normalizedRank;
|
||||
const momentumContribution = hasBlend ? clampPercent(momentum) * MOMENTUM_WEIGHT : (rank != null ? normalizedRank : 0);
|
||||
const volatilityContribution = hasBlend ? clampPercent(volatility) * VOLATILITY_WEIGHT : 0;
|
||||
const gateEnabled = momentumGate > 0;
|
||||
const gatePassed = momentum != null && (!gateEnabled || momentum >= momentumGate);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="mt-4 border-y border-white/[0.07] py-4"
|
||||
className="mt-6 border-y border-white/[0.07] py-4"
|
||||
aria-label="Production ranking snapshot"
|
||||
>
|
||||
{ranksMissing && (
|
||||
<p className="mb-3 rounded-lg border border-amber-400/25 bg-amber-400/10 px-3 py-2 text-[12px] leading-snug text-amber-100">
|
||||
Residual momentum rank missing on this setup — the activation gate
|
||||
cannot qualify it until ranks are attached. Refresh the ticker (or wait
|
||||
for the daily scan) to recompute universe ranks.
|
||||
</p>
|
||||
)}
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(170px,0.55fr)_minmax(0,1.8fr)] lg:gap-8">
|
||||
<div className="flex items-end justify-between gap-4 lg:block">
|
||||
<div>
|
||||
@@ -102,10 +114,16 @@ export function ProductionRankStrip({ setup, momentumGate }: ProductionRankStrip
|
||||
{rank != null && (
|
||||
<p className="text-[11px] text-gray-400">top {topShare(normalizedRank)} of the universe</p>
|
||||
)}
|
||||
{momentum != null && gateEnabled && (
|
||||
<p className={`mt-0.5 text-[10px] ${gatePassed ? 'text-blue-300' : 'text-red-300'}`}>
|
||||
momentum gate {gatePassed ? 'passed' : 'not passed'}
|
||||
{momentum == null && gateEnabled ? (
|
||||
<p className="mt-0.5 text-[10px] text-amber-300">
|
||||
momentum gate: no residual rank
|
||||
</p>
|
||||
) : (
|
||||
momentum != null && gateEnabled && (
|
||||
<p className={`mt-0.5 text-[10px] ${gatePassed ? 'text-blue-300' : 'text-red-300'}`}>
|
||||
momentum gate {gatePassed ? 'passed' : 'not passed'}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -302,13 +302,20 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele
|
||||
preferred
|
||||
</span>
|
||||
)}
|
||||
{setup.momentum_percentile != null && (
|
||||
{setup.momentum_percentile != null ? (
|
||||
<span
|
||||
className="num rounded-full border border-blue-400/25 bg-blue-400/10 px-2.5 py-0.5 text-[11px] text-blue-200"
|
||||
title="Residual 12-1 month momentum percentile across the universe. This is the actual signal — the reason the ticker was selected at all."
|
||||
>
|
||||
momentum top {Math.max(1, Math.round(100 - setup.momentum_percentile))}%
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="num rounded-full border border-amber-400/30 bg-amber-400/10 px-2.5 py-0.5 text-[11px] text-amber-200"
|
||||
title="No residual 12-1 momentum percentile on this setup. Missing ranks do not clear the activation gate."
|
||||
>
|
||||
no residual rank
|
||||
</span>
|
||||
)}
|
||||
<Chip>confidence {setup.confidence_score?.toFixed(0) ?? '—'}%</Chip>
|
||||
<span
|
||||
|
||||
@@ -192,7 +192,9 @@ export default function StandingMatrix({
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
No active setup, so this ticker isn’t ranked on the momentum axis yet. Run the scanner to place it.
|
||||
{composite != null && momentum == null
|
||||
? 'Setup is present, but residual momentum rank is missing — the activation gate treats unranked setups as not qualified. Refresh this ticker (or wait for the daily scan) so universe ranks are attached.'
|
||||
: 'No active setup, so this ticker isn’t ranked on the momentum axis yet. Run the scanner to place it.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -171,21 +171,28 @@ function FocusCard({ setup, name, badge, badgeTone, footNote, onReset }: {
|
||||
R:R and reach probability are gate inputs computed from a GTL
|
||||
proposal the trade never exits at — they get quiet treatment. */}
|
||||
<div className="flex items-start gap-10 text-right">
|
||||
{setup.momentum_percentile != null && (
|
||||
<div title="Residual 12-1 month momentum percentile across the universe. This is why the ticker was selected.">
|
||||
<p className="section-index">residual momentum</p>
|
||||
<p className="font-display mt-1 text-3xl font-semibold text-gray-100">
|
||||
top {Math.max(1, Math.round(100 - setup.momentum_percentile))}
|
||||
<span className="text-lg text-gray-400">%</span>
|
||||
</p>
|
||||
<div className="ml-auto mt-2 h-1 w-28 rounded-full bg-blue-500/20">
|
||||
<span
|
||||
className="block h-full rounded-full bg-blue-500"
|
||||
style={{ width: `${Math.min(100, Math.round(setup.momentum_percentile))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div title="Residual 12-1 month momentum percentile across the universe. This is why the ticker was selected.">
|
||||
<p className="section-index">residual momentum</p>
|
||||
{setup.momentum_percentile != null ? (
|
||||
<>
|
||||
<p className="font-display mt-1 text-3xl font-semibold text-gray-100">
|
||||
top {Math.max(1, Math.round(100 - setup.momentum_percentile))}
|
||||
<span className="text-lg text-gray-400">%</span>
|
||||
</p>
|
||||
<div className="ml-auto mt-2 h-1 w-28 rounded-full bg-blue-500/20">
|
||||
<span
|
||||
className="block h-full rounded-full bg-blue-500"
|
||||
style={{ width: `${Math.min(100, Math.round(setup.momentum_percentile))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-display mt-1 text-2xl font-semibold text-amber-200">missing</p>
|
||||
<p className="mt-1 text-[11px] text-gray-500">no residual rank · cannot qualify</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="max-w-[13rem]"
|
||||
title="Gate metrics. The reward/risk and reach probability of the headline Gate Target Ladder proposal are what admitted this setup. The trade does NOT exit there — it exits on the trailing stop."
|
||||
|
||||
@@ -469,7 +469,7 @@ export default function TickerDetailPage() {
|
||||
</>
|
||||
)}
|
||||
{(longSetup || shortSetup) && (
|
||||
<div className="mt-6 border-t border-white/[0.06] pt-5">
|
||||
<div className="mt-8 border-t border-white/[0.06] pt-7">
|
||||
<RecommendationPanel
|
||||
frameless
|
||||
symbol={symbol}
|
||||
|
||||
Reference in New Issue
Block a user