The UI told a swing-trade story (entry -> target -> stop) while the engine runs a momentum portfolio (buy strength, trail out, re-rank). The selection was honest; everything around it was borrowed from a strategy we don't run. The target is never an exit under `atr_trailing`: `_atr_trailing_close()` does not even take it as a parameter. It exists only to compute the R:R and touch odds that admit a setup through the activation gate. Backtested exit reasons for the production strategy: 144 initial stop, 98 trailing stop, 78 max hold — target 0. See docs/research/sr-levels-and-exits.md. What changed: - New ExitPlanPanel on every setup card states the rules that actually close the trade: initial stop (1R), the price at which the 3x ATR trail takes over from it, the trail width in R, and the max hold. Derived in lib/exitPlan.ts from the live exit policy, so it follows Admin rather than hardcoding the default. - New BaseRatesPanel replaces per-target "probability" as the answer to "what usually happens": win rate, average hold, best/worst R, and how trades actually ended — measured under the real exit, from the backtest report. - "Target"/"target probability" relabelled to "level"/"touch odds" and grouped as gate metrics, with the R:R. On the dashboard focus card, residual momentum (the actual signal) takes the headline stat those two used to occupy. - The take-trade dialog no longer offers a target dropdown whose value the exit ignores; it states the trailing plan instead. The picker returns only when the live policy is mode='target', where the choice is real. The stored target is now the setup's own, not whichever row was last clicked while exploring. - "Played out" is gone. A setup was declared dead once price reached the target — backwards under a trailing exit, where reaching a level is the good case and the trade keeps running. Only the stop invalidates a setup now; running past the entry is an "extended" warning, measured in R (you'd be chasing). The levels ladder, the price rail and the chart overlay all stay fully explorable — clicking a level still drives them. It is framed as overhead structure, which is what it is, rather than a menu of exits. Adds a parity guard: the UI recovers ATR as |entry - stop| / 1.5, so the test fails if the scanner's stop width ever moves. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
693 lines
31 KiB
TypeScript
693 lines
31 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import type { TradeSetup } from '../../lib/types';
|
|
import { formatPrice, formatPercent } from '../../lib/format';
|
|
import { useCreatePaperTrade } from '../../hooks/usePaperTrades';
|
|
import { recommendationActionDirection, recommendationActionLabel } from '../../lib/recommendation';
|
|
import { useRiskSettings, type RiskSettings } from '../../hooks/useRiskSettings';
|
|
import { positionSize } from '../../lib/position';
|
|
import { useMarketRegime } from '../../hooks/useMarketRegime';
|
|
import { isCounterTrend } from '../../lib/regime';
|
|
import { primaryTargetProbability } from '../../lib/qualification';
|
|
import { PriceRail } from '../charts/horizon';
|
|
import type { ExitPolicy, MarketRegime } from '../../lib/types';
|
|
import { deriveExitPlan, driftInR } from '../../lib/exitPlan';
|
|
import { productionBaseRates } from '../../lib/baseRates';
|
|
import { useExitPolicy } from '../../hooks/usePaperTrades';
|
|
import { useBacktestReport } from '../../hooks/useMarketRegime';
|
|
import { ExitPlanPanel } from './ExitPlanPanel';
|
|
import { BaseRatesPanel } from './BaseRatesPanel';
|
|
|
|
interface RecommendationPanelProps {
|
|
symbol: string;
|
|
longSetup?: TradeSetup;
|
|
shortSetup?: TradeSetup;
|
|
currentPrice?: number;
|
|
nextEarningsDate?: string | null;
|
|
/** Render without the section/glass wrapper (inside the unified ticker panel). */
|
|
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). */
|
|
function daysUntil(iso: string): number | null {
|
|
const t = new Date(iso).getTime();
|
|
if (Number.isNaN(t)) return null;
|
|
return Math.ceil((t - Date.now()) / 86_400_000);
|
|
}
|
|
|
|
/** Earnings within the ~30-day hold horizon can gap price through the stop. */
|
|
const EARNINGS_HORIZON_DAYS = 30;
|
|
|
|
/**
|
|
* How far price has drifted from the scan entry, measured in R (the initial risk
|
|
* distance). R is the right unit: the stop sits 1R away and the trailing exit is
|
|
* denominated in R too.
|
|
*
|
|
* This used to judge staleness by progress toward the *target*, and declared a
|
|
* setup "played out" once price reached it. Under the live trailing exit that is
|
|
* backwards — reaching a level is the good case and the trade keeps running. The
|
|
* only thing that invalidates a setup is price through the stop; running past the
|
|
* entry just means you'd be chasing (a wider effective stop), which is a warning,
|
|
* not a death sentence.
|
|
*/
|
|
function entryDrift(setup: TradeSetup, currentPrice?: number) {
|
|
if (currentPrice == null || !setup.entry_price) return null;
|
|
const pct = ((currentPrice - setup.entry_price) / setup.entry_price) * 100;
|
|
const r = driftInR(setup, currentPrice);
|
|
const beyondStop =
|
|
setup.direction === 'long' ? currentPrice <= setup.stop_loss : currentPrice >= setup.stop_loss;
|
|
let status: 'fresh' | 'extended' | 'invalidated' = 'fresh';
|
|
if (beyondStop) status = 'invalidated';
|
|
else if (r != null && r >= 1) status = 'extended';
|
|
else if (r != null && r <= -0.5) status = 'extended';
|
|
return { pct, r, status };
|
|
}
|
|
|
|
/**
|
|
* The only state with no tradeable setup left: price has gone through the stop.
|
|
* Returns null when there's no live price.
|
|
*/
|
|
function notActionableState(setup: TradeSetup, currentPrice?: number) {
|
|
if (currentPrice == null) return null;
|
|
if (entryDrift(setup, currentPrice)?.status !== 'invalidated') return null;
|
|
return { invalidated: true };
|
|
}
|
|
|
|
function riskClass(risk: TradeSetup['risk_level']) {
|
|
if (risk === 'Low') return 'text-emerald-300';
|
|
if (risk === 'Medium') return 'text-amber-400';
|
|
if (risk === 'High') return 'text-red-300';
|
|
return 'text-gray-400';
|
|
}
|
|
|
|
function isRecommended(setup: TradeSetup | undefined, action: TradeSetup['recommended_action'] | undefined) {
|
|
if (!setup || !action) return false;
|
|
if (setup.direction === 'long') return action.startsWith('LONG');
|
|
return action.startsWith('SHORT');
|
|
}
|
|
|
|
function DirTag({ direction }: { direction: string }) {
|
|
const isLong = direction === 'long';
|
|
return (
|
|
<span className={`num inline-block rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${
|
|
isLong ? 'bg-emerald-500/15 text-emerald-300' : 'bg-red-500/15 text-red-300'
|
|
}`}>
|
|
{direction}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function Chip({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<span className="rounded-full border border-white/[0.09] px-2.5 py-0.5 text-[11px] text-gray-400">
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
type Target = NonNullable<TradeSetup['targets']>[number];
|
|
|
|
function TargetTable({ setup, selectedPrice, onSelect, honorsTarget }: {
|
|
setup: TradeSetup;
|
|
selectedPrice: number;
|
|
onSelect: (target: Target) => void;
|
|
/** True only when the live exit policy actually takes profit at a level. */
|
|
honorsTarget: boolean;
|
|
}) {
|
|
if (!setup.targets || setup.targets.length === 0) {
|
|
return <p className="text-xs text-gray-500">No overhead levels detected.</p>;
|
|
}
|
|
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
<table
|
|
className="w-full text-xs"
|
|
role="radiogroup"
|
|
aria-label={
|
|
honorsTarget
|
|
? 'Choose the take-profit level for the rail and paper trade'
|
|
: 'Choose a level to preview on the rail (does not affect the exit)'
|
|
}
|
|
>
|
|
<thead>
|
|
<tr className="text-left text-gray-500 border-b border-white/[0.06]">
|
|
<th className="py-2 pr-3">Band</th>
|
|
<th className="py-2 pr-3">Level</th>
|
|
<th className="py-2 pr-3">Distance</th>
|
|
<th className="py-2 pr-3" title="Reward-to-risk if the trade were exited at this level. Used by the activation gate — not an exit.">
|
|
Gate R:R
|
|
</th>
|
|
<th className="py-2" title="Modelled odds of price TOUCHING this level within ~30 days. Not the odds of the trade winning — the trade does not exit here.">
|
|
Touch odds
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{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>
|
|
);
|
|
}
|
|
|
|
function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, selectedPrice, onSelectPrice }: {
|
|
setup?: TradeSetup;
|
|
action?: TradeSetup['recommended_action'];
|
|
currentPrice?: number;
|
|
risk: RiskSettings;
|
|
regime?: MarketRegime;
|
|
exitPolicy?: ExitPolicy;
|
|
/** Controlled level selection (lifted so the candlestick chart can follow). */
|
|
selectedPrice?: number | null;
|
|
onSelectPrice?: (price: number) => void;
|
|
}) {
|
|
if (!setup) {
|
|
return (
|
|
<div className="rounded-xl border border-white/[0.07] p-4 text-xs text-gray-500">
|
|
Setup unavailable for this direction.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const recommended = isRecommended(setup, action);
|
|
const drift = entryDrift(setup, currentPrice);
|
|
const sizing = positionSize(risk.accountSize, risk.riskPct, setup.entry_price, setup.stop_loss);
|
|
const counterTrend = regime ? isCounterTrend(setup.direction, regime.label) : false;
|
|
const prob = primaryTargetProbability(setup);
|
|
|
|
// The real exit rules. `honorsTarget` is false under the production policy —
|
|
// the level ladder below is context, not a menu of exits.
|
|
const exitPlan = deriveExitPlan(setup, exitPolicy);
|
|
const honorsTarget = exitPlan?.honorsTarget ?? false;
|
|
|
|
// Only price through the stop leaves no tradeable setup.
|
|
const notActionable = notActionableState(setup, currentPrice) != null;
|
|
|
|
const createTrade = useCreatePaperTrade();
|
|
const [taking, setTaking] = useState(false);
|
|
const [takeShares, setTakeShares] = useState<number>(sizing?.shares ?? 0);
|
|
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. Controlled by the page
|
|
// 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 activePrice = selected?.price ?? setup.target;
|
|
const activeRR = selected?.rr_ratio ?? setup.rr_ratio;
|
|
const activeProb = selected?.probability ?? prob;
|
|
|
|
// Close the take dialog on Escape.
|
|
useEffect(() => {
|
|
if (!taking) return;
|
|
const onKey = (e: globalThis.KeyboardEvent) => {
|
|
if (e.key === 'Escape') setTaking(false);
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, [taking]);
|
|
|
|
const confirmTake = () => {
|
|
createTrade.mutate(
|
|
{
|
|
symbol: setup.symbol,
|
|
direction: setup.direction as 'long' | 'short',
|
|
entry_price: takeEntry,
|
|
shares: takeShares,
|
|
stop_loss: setup.stop_loss,
|
|
// Only a real choice when the exit honors it. Otherwise record the
|
|
// setup's own primary level, so the stored value doesn't silently depend
|
|
// on which row the user happened to click while exploring the chart.
|
|
target: honorsTarget ? takeTarget : setup.target,
|
|
},
|
|
{ onSuccess: () => setTaking(false) },
|
|
);
|
|
};
|
|
|
|
if (notActionable) {
|
|
const dir = setup.direction.toUpperCase();
|
|
return (
|
|
<div data-direction={setup.direction} className="rounded-xl border border-white/[0.07] p-4">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<DirTag direction={setup.direction} />
|
|
<span className="num text-[10px] uppercase tracking-[0.16em] text-gray-500">no current setup</span>
|
|
<span className="num ml-auto text-xs text-gray-500">
|
|
now {currentPrice != null ? formatPrice(currentPrice) : '—'} · last entry {formatPrice(setup.entry_price)}
|
|
{drift ? ` (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}%)` : ''}
|
|
</span>
|
|
</div>
|
|
<p className="mt-2 text-[11.5px] leading-relaxed text-gray-400">
|
|
The last {dir} setup is invalidated — price ({formatPrice(currentPrice!)}) has passed the stop
|
|
({formatPrice(setup.stop_loss)}). No fresh {dir} setup right now; the scanner surfaces a new one
|
|
when it forms.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
data-direction={setup.direction}
|
|
className={`rounded-xl border p-4 ${recommended ? 'border-blue-400/25' : 'border-white/[0.07] opacity-80'}`}
|
|
>
|
|
{/* Identity + key stats left, sizing + take top right */}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<DirTag direction={setup.direction} />
|
|
{recommended && (
|
|
<span className="num rounded-full border border-[#ff6a45]/40 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-[0.18em] text-[#ff6a45]">
|
|
preferred
|
|
</span>
|
|
)}
|
|
{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>
|
|
)}
|
|
<Chip>confidence {setup.confidence_score?.toFixed(0) ?? '—'}%</Chip>
|
|
<span
|
|
className="rounded-full border border-white/[0.09] px-2.5 py-0.5 text-[11px] text-gray-500"
|
|
title="Gate metrics: the R:R and touch-odds of the selected level are what admitted this setup through the activation gate. They are NOT forecasts of this trade — it does not exit at that level."
|
|
>
|
|
gate · R:R {activeRR.toFixed(1)}:1
|
|
{activeProb != null && ` · touch ${Math.round(activeProb)}%`}
|
|
</span>
|
|
{selected && !selected.is_primary && <Chip>custom level</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>
|
|
|
|
{/* Warnings — only when they apply */}
|
|
{(counterTrend || (!recommended && recommendationActionDirection(action ?? null) !== 'neutral') || drift?.status !== 'fresh') && (
|
|
<div className="mt-2.5 space-y-1">
|
|
{!recommended && recommendationActionDirection(action ?? null) !== 'neutral' && (
|
|
<p className="text-[11px] text-amber-400">Alternative setup — the ticker bias currently favors the opposite direction.</p>
|
|
)}
|
|
{counterTrend && regime && (
|
|
<p className="text-[11px] text-amber-400">
|
|
⚠ Counter-trend: {setup.direction.toUpperCase()} against a {regime.label} market
|
|
({regime.benchmark ?? 'SPY'}). Lower odds — size down or wait for confirmation.
|
|
</p>
|
|
)}
|
|
{/* No 'invalidated' branch here: that state returns the "no current
|
|
setup" card above, so it can never reach this block. */}
|
|
{drift && drift.status === 'extended' && drift.r != null && (
|
|
<p className="text-[11px] text-amber-400">
|
|
{drift.r >= 1
|
|
? `⚠ Price has run ${drift.r.toFixed(1)}R past the scan entry (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}%). Entering now means chasing — your stop sits further away, so the same dollar risk buys fewer shares.`
|
|
: `⚠ Price has drifted ${Math.abs(drift.r).toFixed(1)}R toward the stop (${drift.pct.toFixed(1)}%) — the entry is stale.`}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* The setup, spatially — stop/entry/now hold still, the *selected*
|
|
level moves along a scale that always spans the whole ladder */}
|
|
<PriceRail
|
|
direction={setup.direction}
|
|
entry={setup.entry_price}
|
|
stop={setup.stop_loss}
|
|
target={activePrice}
|
|
current={currentPrice ?? null}
|
|
scaleTo={(setup.targets ?? []).map((t) => t.price)}
|
|
/>
|
|
|
|
{/* The rules that actually close the trade. Above the ladder on purpose:
|
|
this is the plan, the levels below are only context. */}
|
|
{exitPlan && <ExitPlanPanel plan={exitPlan} direction={setup.direction} />}
|
|
|
|
{/* Take dialog — portaled overlay so the panel structure stays put */}
|
|
{taking && createPortal(
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 animate-fade-in"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={`Mark ${setup.symbol} ${setup.direction} as taken`}
|
|
>
|
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setTaking(false)} />
|
|
<div className="glass relative z-10 w-full max-w-md p-6 shadow-2xl animate-slide-up">
|
|
<div className="flex items-center gap-3">
|
|
<h3 className="font-display text-xl font-bold tracking-tight text-gray-100">{setup.symbol}</h3>
|
|
<DirTag direction={setup.direction} />
|
|
<span className="num ml-auto text-[10px] uppercase tracking-[0.16em] text-gray-500">paper trade</span>
|
|
</div>
|
|
<p className="num mt-1.5 text-[11px] text-gray-500">
|
|
stop {formatPrice(setup.stop_loss)}
|
|
{honorsTarget && <> · take profit {formatPrice(takeTarget)}</>}
|
|
{sizing && <> · suggested {sizing.shares} sh, max loss {formatPrice(sizing.dollarRisk)}</>}
|
|
</p>
|
|
|
|
{/* The exit is the trailing stop, not a target. Say so here, where the
|
|
user is actually committing — this dialog used to offer a target
|
|
dropdown whose value the exit never reads. */}
|
|
{exitPlan && !honorsTarget && (
|
|
<p className="mt-2 rounded-lg border border-white/[0.07] bg-white/[0.02] px-2.5 py-2 text-[11px] leading-relaxed text-gray-400">
|
|
<span className="num text-[10px] uppercase tracking-[0.16em] text-gray-500">exits on</span>{' '}
|
|
{exitPlan.headline}. No take-profit — the {formatPrice(setup.target)} level is recorded for
|
|
reference only and will not close this trade.
|
|
</p>
|
|
)}
|
|
|
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
|
<label className="block space-y-1">
|
|
<span className="num text-[10px] uppercase tracking-wider text-gray-500">Shares</span>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
autoFocus
|
|
value={takeShares}
|
|
onChange={(e) => setTakeShares(Number(e.target.value))}
|
|
className="w-full input-glass px-2.5 py-1.5 text-sm num"
|
|
/>
|
|
</label>
|
|
<label className="block space-y-1">
|
|
<span className="num text-[10px] uppercase tracking-wider text-gray-500">Entry</span>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
step="0.01"
|
|
value={takeEntry}
|
|
onChange={(e) => setTakeEntry(Number(e.target.value))}
|
|
className="w-full input-glass px-2.5 py-1.5 text-sm num"
|
|
/>
|
|
</label>
|
|
</div>
|
|
{/* Only a real choice when the live exit policy takes profit at a
|
|
level. Under `atr_trailing` (production) the value is inert, so
|
|
offering it would imply control the user does not have. */}
|
|
{honorsTarget && setup.targets && setup.targets.length > 1 ? (
|
|
<label className="mt-3 block space-y-1">
|
|
<span className="num text-[10px] uppercase tracking-wider text-gray-500">Take profit at</span>
|
|
<select
|
|
value={takeTarget}
|
|
onChange={(e) => setTakeTarget(Number(e.target.value))}
|
|
className="input-glass w-full px-2.5 py-1.5 text-sm num"
|
|
>
|
|
{setup.targets.map((t) => (
|
|
<option key={`${t.sr_level_id}-${t.price}`} value={t.price} className="bg-[#14161f]">
|
|
{formatPrice(t.price)} · {t.probability.toFixed(0)}% touch odds · {t.classification}{t.is_primary ? ' · primary' : ''}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
) : null}
|
|
|
|
<div className="mt-5 flex gap-2">
|
|
<button
|
|
onClick={confirmTake}
|
|
disabled={createTrade.isPending || !(takeShares > 0) || !(takeEntry > 0)}
|
|
className="flex-1 rounded-lg border border-blue-500/35 bg-blue-500/15 px-3 py-2 text-sm font-semibold text-blue-300 transition-colors hover:bg-blue-500/25 disabled:opacity-50"
|
|
>
|
|
{createTrade.isPending ? 'Taking…' : 'Confirm trade'}
|
|
</button>
|
|
<button
|
|
onClick={() => setTaking(false)}
|
|
className="rounded-lg border border-white/[0.08] px-4 py-2 text-sm text-gray-400 transition-colors hover:text-gray-200"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)}
|
|
|
|
{/* Levels ladder — still fully explorable (clicking a row drives the rail
|
|
and the candlestick overlay), but framed as what it is: overhead
|
|
structure used to screen the setup, not a menu of exits. */}
|
|
{setup.targets && setup.targets.length > 0 && (
|
|
<details className="mt-3" open>
|
|
<summary className="cursor-pointer text-[11px] font-medium text-gray-500 transition-colors hover:text-gray-300">
|
|
{honorsTarget
|
|
? `Take-profit levels (${setup.targets.length}) · select one to preview it and use it when taking`
|
|
: `Overhead levels (${setup.targets.length}) · select one to preview it on the rail and chart`}
|
|
</summary>
|
|
{!honorsTarget && (
|
|
<p className="mt-1.5 text-[11px] leading-relaxed text-gray-600">
|
|
Resistance levels the scanner found. Their R:R and touch odds are what got this setup
|
|
through the gate — but the trade exits on the trailing stop, so price reaching one of
|
|
these is not a sell signal. Clicking only moves the marker.
|
|
</p>
|
|
)}
|
|
<div className="mt-2">
|
|
<TargetTable
|
|
setup={setup}
|
|
selectedPrice={activePrice}
|
|
honorsTarget={honorsTarget}
|
|
onSelect={(t) => {
|
|
selectTargetPrice(t.price);
|
|
setTakeTarget(t.price);
|
|
}}
|
|
/>
|
|
</div>
|
|
</details>
|
|
)}
|
|
|
|
{setup.conflict_flags.length > 0 && (
|
|
<p className="mt-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-300">
|
|
{setup.conflict_flags.join(' • ')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const RISK_PRESETS = [0.5, 1, 2, 3];
|
|
|
|
/** Compact, set-once sizing controls: a clean account field (no spinners) and a
|
|
* segmented risk-% selector — risk is almost always one of a few values. */
|
|
function RiskControls({ risk, update }: { risk: RiskSettings; update: (p: Partial<RiskSettings>) => void }) {
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
|
<span>Sizing assumes a</span>
|
|
<span className="inline-flex items-center rounded-md border border-white/[0.08] bg-white/[0.03] px-2 py-0.5">
|
|
<span className="mr-0.5 text-gray-500">$</span>
|
|
<input
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={risk.accountSize ? String(risk.accountSize) : ''}
|
|
onChange={(e) => update({ accountSize: Number(e.target.value.replace(/[^0-9]/g, '')) || 0 })}
|
|
placeholder="10000"
|
|
aria-label="Account size"
|
|
className="w-20 bg-transparent font-mono text-gray-100 outline-none"
|
|
/>
|
|
</span>
|
|
<span>account, risking</span>
|
|
<span className="inline-flex overflow-hidden rounded-md border border-white/[0.08]">
|
|
{RISK_PRESETS.map((p) => (
|
|
<button
|
|
key={p}
|
|
type="button"
|
|
onClick={() => update({ riskPct: p })}
|
|
className={`px-2 py-0.5 font-mono transition-colors ${
|
|
risk.riskPct === p
|
|
? 'bg-blue-400/15 text-blue-200'
|
|
: 'text-gray-400 hover:bg-white/[0.05] hover:text-gray-200'
|
|
}`}
|
|
>
|
|
{p}%
|
|
</button>
|
|
))}
|
|
</span>
|
|
<span>per trade.</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 regime = useMarketRegime().data;
|
|
const exitPolicy = useExitPolicy().data;
|
|
const baseRates = productionBaseRates(useBacktestReport().data);
|
|
const summary = longSetup?.recommendation_summary ?? shortSetup?.recommendation_summary;
|
|
const earningsDays = nextEarningsDate ? daysUntil(nextEarningsDate) : null;
|
|
const action = (summary?.action ?? 'NEUTRAL') as TradeSetup['recommended_action'];
|
|
const preferredDirection = recommendationActionDirection(action);
|
|
|
|
const preferredSetup =
|
|
preferredDirection === 'long'
|
|
? longSetup
|
|
: preferredDirection === 'short'
|
|
? shortSetup
|
|
: undefined;
|
|
|
|
const alternativeSetup =
|
|
preferredDirection === 'long'
|
|
? shortSetup
|
|
: preferredDirection === 'short'
|
|
? longSetup
|
|
: undefined;
|
|
|
|
if (!longSetup && !shortSetup) {
|
|
return null;
|
|
}
|
|
|
|
// If the preferred setup has been invalidated (price through the stop), the
|
|
// stored ticker-level bias and reasoning are stale — don't headline "Strong
|
|
// Long" above a "no current setup" card.
|
|
const preferredInactive = preferredSetup ? notActionableState(preferredSetup, currentPrice) : null;
|
|
|
|
const body = (
|
|
<div className="space-y-4">
|
|
{/* 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()} — invalidated at the stop)</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>
|
|
|
|
{earningsDays != null && earningsDays >= 0 && (
|
|
earningsDays <= EARNINGS_HORIZON_DAYS ? (
|
|
<p className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-300">
|
|
⚠ Earnings in {earningsDays} day{earningsDays === 1 ? '' : 's'} ({nextEarningsDate}) — inside the ~30-day
|
|
max hold. A report can gap price straight through your stop; consider waiting or sizing down.
|
|
</p>
|
|
) : (
|
|
<p className="text-[11px] text-gray-500">Next earnings: {nextEarningsDate} ({earningsDays} days).</p>
|
|
)
|
|
)}
|
|
|
|
{preferredDirection !== 'neutral' && preferredSetup ? (
|
|
<div className="space-y-3">
|
|
<SetupCard setup={preferredSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} exitPolicy={exitPolicy} selectedPrice={selFor(preferredSetup)} onSelectPrice={onSelFor(preferredSetup)} />
|
|
|
|
{alternativeSetup && (
|
|
<details>
|
|
<summary className="cursor-pointer text-xs font-medium text-gray-500 transition-colors hover:text-gray-300">
|
|
Alternative scenario ({alternativeSetup.direction.toUpperCase()})
|
|
</summary>
|
|
<div className="mt-3">
|
|
<SetupCard setup={alternativeSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} exitPolicy={exitPolicy} selectedPrice={selFor(alternativeSetup)} onSelectPrice={onSelFor(alternativeSetup)} />
|
|
</div>
|
|
</details>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-4 lg:grid-cols-2">
|
|
<SetupCard setup={longSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} exitPolicy={exitPolicy} selectedPrice={selFor(longSetup)} onSelectPrice={onSelFor(longSetup)} />
|
|
<SetupCard setup={shortSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} exitPolicy={exitPolicy} selectedPrice={selFor(shortSetup)} onSelectPrice={onSelFor(shortSetup)} />
|
|
</div>
|
|
)}
|
|
|
|
{/* System-level base rates: what actually happens to trades like these,
|
|
under the real exit. The honest counterpart to per-target "probability". */}
|
|
{baseRates && <BaseRatesPanel rates={baseRates} />}
|
|
</div>
|
|
);
|
|
|
|
if (frameless) {
|
|
return (
|
|
<div>
|
|
<p className="section-index mb-3">Recommendation · {symbol.toUpperCase()}</p>
|
|
{body}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section>
|
|
<h2 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Recommendation</h2>
|
|
<div className="glass p-5 space-y-4">{body}</div>
|
|
</section>
|
|
);
|
|
}
|