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 ( {direction} ); } function Chip({ children }: { children: React.ReactNode }) { return ( {children} ); } type Target = NonNullable[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

No overhead levels detected.

; } return (
{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]' }`} > ); })}
Band Level Distance Gate R:R Touch odds
{formatPrice(target.price)} {formatPercent((target.distance_from_entry / setup.entry_price) * 100)} {target.rr_ratio.toFixed(2)} {target.probability.toFixed(1)}%
); } 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 (
Setup unavailable for this direction.
); } 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(sizing?.shares ?? 0); 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. Controlled by the page // when provided (so the candlestick overlay follows), else local. const [internalSel, setInternalSel] = useState(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 (
no current setup now {currentPrice != null ? formatPrice(currentPrice) : '—'} · last entry {formatPrice(setup.entry_price)} {drift ? ` (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}%)` : ''}

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.

); } return (
{/* Identity + key stats left, sizing + take top right */}
{recommended && ( preferred )} {setup.momentum_percentile != null && ( momentum top {Math.max(1, Math.round(100 - setup.momentum_percentile))}% )} confidence {setup.confidence_score?.toFixed(0) ?? '—'}% gate · R:R {activeRR.toFixed(1)}:1 {activeProb != null && ` · touch ${Math.round(activeProb)}%`} {selected && !selected.is_primary && custom level} {sizing ? ( {sizing.shares} sh · {formatPrice(sizing.positionValue)} · risk {formatPrice(sizing.dollarRisk)} {sizing.exceedsAccount && } ) : ( set account size to size this )} {!taking && ( )}
{/* Warnings — only when they apply */} {(counterTrend || (!recommended && recommendationActionDirection(action ?? null) !== 'neutral') || drift?.status !== 'fresh') && (
{!recommended && recommendationActionDirection(action ?? null) !== 'neutral' && (

Alternative setup — the ticker bias currently favors the opposite direction.

)} {counterTrend && regime && (

⚠ Counter-trend: {setup.direction.toUpperCase()} against a {regime.label} market ({regime.benchmark ?? 'SPY'}). Lower odds — size down or wait for confirmation.

)} {/* 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 && (

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

)}
)} {/* The setup, spatially — stop/entry/now hold still, the *selected* level moves along a scale that always spans the whole ladder */} 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 && } {/* Take dialog — portaled overlay so the panel structure stays put */} {taking && createPortal(
setTaking(false)} />

{setup.symbol}

paper trade

stop {formatPrice(setup.stop_loss)} {honorsTarget && <> · take profit {formatPrice(takeTarget)}} {sizing && <> · suggested {sizing.shares} sh, max loss {formatPrice(sizing.dollarRisk)}}

{/* 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 && (

exits on{' '} {exitPlan.headline}. No take-profit — the {formatPrice(setup.target)} level is recorded for reference only and will not close this trade.

)}
{/* 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 ? ( ) : null}
, 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 && (
{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`} {!honorsTarget && (

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.

)}
{ selectTargetPrice(t.price); setTakeTarget(t.price); }} />
)} {setup.conflict_flags.length > 0 && (

{setup.conflict_flags.join(' • ')}

)}
); } 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) => void }) { return (
Sizing assumes a $ 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" /> account, risking {RISK_PRESETS.map((p) => ( ))} per trade.
); } 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 = (
{/* Verdict: action loud, the signal detail as a quiet subtitle */}
{preferredInactive ? ( No current setup (last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} — invalidated at the stop) ) : (() => { 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}

} ); })()}
{earningsDays != null && earningsDays >= 0 && ( earningsDays <= EARNINGS_HORIZON_DAYS ? (

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

) : (

Next earnings: {nextEarningsDate} ({earningsDays} days).

) )} {preferredDirection !== 'neutral' && preferredSetup ? (
{alternativeSetup && (
Alternative scenario ({alternativeSetup.direction.toUpperCase()})
)}
) : (
)} {/* System-level base rates: what actually happens to trades like these, under the real exit. The honest counterpart to per-target "probability". */} {baseRates && }
); if (frameless) { return (

Recommendation · {symbol.toUpperCase()}

{body}
); } return (

Recommendation

{body}
); }