import { useState } from 'react'; 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 type { MarketRegime } from '../../lib/types'; interface RecommendationPanelProps { symbol: string; longSetup?: TradeSetup; shortSetup?: TradeSetup; currentPrice?: number; nextEarningsDate?: string | null; } /** 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 target horizon can gap price through stop/target. */ const EARNINGS_HORIZON_DAYS = 30; /** * How far current price has drifted from the setup's entry. A setup whose * entry is far from the live price (price already ran toward target, or fell * through the stop) is stale — entering now changes the risk/reward. */ 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 towardTarget = setup.direction === 'long' ? currentPrice >= setup.entry_price : currentPrice <= setup.entry_price; // Judge staleness by how much of the entry→target distance is already gone, // not the raw % move — an 8%-wide setup is "used up" far faster than a 40% one. const span = Math.abs(setup.target - setup.entry_price); const moved = Math.abs(currentPrice - setup.entry_price); const progressPct = span > 0 ? (moved / span) * 100 : 0; const beyondStop = setup.direction === 'long' ? currentPrice <= setup.stop_loss : currentPrice >= setup.stop_loss; let status: 'fresh' | 'stale' | 'invalidated' = 'fresh'; if (beyondStop) status = 'invalidated'; else if (towardTarget && progressPct > 33) status = 'stale'; else if (!towardTarget && progressPct > 33) status = 'stale'; return { pct, progressPct, towardTarget, status }; } function riskClass(risk: TradeSetup['risk_level']) { if (risk === 'Low') return 'text-emerald-400'; if (risk === 'Medium') return 'text-amber-400'; if (risk === 'High') return 'text-red-400'; 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 TargetTable({ setup }: { setup: TradeSetup }) { if (!setup.targets || setup.targets.length === 0) { return

No target probabilities available.

; } return (
{setup.targets.map((target) => ( ))}
Classification Price Distance R:R Probability
{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)}%
); } function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: TradeSetup; action?: TradeSetup['recommended_action']; currentPrice?: number; risk: RiskSettings; regime?: MarketRegime }) { 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 createTrade = useCreatePaperTrade(); const [taking, setTaking] = useState(false); const [takeShares, setTakeShares] = useState(sizing?.shares ?? 0); const [takeEntry, setTakeEntry] = useState(currentPrice ?? setup.entry_price); const confirmTake = () => { createTrade.mutate( { symbol: setup.symbol, direction: setup.direction as 'long' | 'short', entry_price: takeEntry, shares: takeShares, stop_loss: setup.stop_loss, target: setup.target, }, { onSuccess: () => setTaking(false) }, ); }; return (

{setup.direction.toUpperCase()}

{setup.confidence_score?.toFixed(1) ?? '—'}%
{!recommended && recommendationActionDirection(action ?? null) !== 'neutral' && (

Alternative setup (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.

)} {drift && drift.status === 'invalidated' && (

⚠ Price ({formatPrice(currentPrice!)}) is past the stop — this setup is invalidated.

)} {drift && drift.status === 'stale' && (

{drift.towardTarget ? `⚠ ${drift.progressPct.toFixed(0)}% of the entry→target move is already gone (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}% from entry) — little reward left.` : `⚠ Price has moved ${Math.abs(drift.pct).toFixed(1)}% against the setup (toward the stop) — entry may be stale.`}

)}
Current
{currentPrice != null ? formatPrice(currentPrice) : '—'}
Entry
{formatPrice(setup.entry_price)}{drift ? ` (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}%)` : ''}
Stop
{formatPrice(setup.stop_loss)}
Primary Target
{formatPrice(setup.target)}
R:R
{setup.rr_ratio.toFixed(2)}
{sizing ? (

Position size · {risk.riskPct}% of {formatPrice(risk.accountSize)}

{sizing.shares}
shares
{formatPrice(sizing.positionValue)}
position
{formatPrice(sizing.dollarRisk)}
max loss
{sizing.exceedsAccount && (

Position exceeds account — needs margin.

)}
) : (

Set account size below to size this trade.

)} {!taking ? ( ) : (

Stop {formatPrice(setup.stop_loss)} · Target {formatPrice(setup.target)} · {setup.direction.toUpperCase()}

)} {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 }: RecommendationPanelProps) { const { settings: risk, update: updateRisk } = useRiskSettings(); const regime = useMarketRegime().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; } return (

Recommendation

{recommendationActionLabel(action)} Risk: {summary?.risk_level ?? '—'} Composite: {summary?.composite_score?.toFixed(1) ?? '—'} {symbol.toUpperCase()}

Recommended Action is the ticker-level bias. The preferred setup is shown first; the opposite side is available under Alternative scenario.

{summary?.reasoning && (

{summary.reasoning}

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

⚠ Earnings in {earningsDays} day{earningsDays === 1 ? '' : 's'} ({nextEarningsDate}) — inside the ~30-day target horizon. A report can gap price through your stop or target; consider waiting or sizing down.

) : (

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

) )} {preferredDirection !== 'neutral' && preferredSetup ? (
{alternativeSetup && (
Alternative scenario ({alternativeSetup.direction.toUpperCase()})
)}
) : (
)}
); }