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 { primaryTargetProbability } from '../../lib/qualification';
import { PriceRail } from '../charts/horizon';
import type { MarketRegime } from '../../lib/types';
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 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 };
}
/**
* A stored setup is the latest for its direction. When price has run to/past the
* target (played out) or through the stop (invalidated), there is no fresh setup
* — the card and the ticker-level header should say so rather than present a
* stale actionable recommendation. Returns null when there's no live price.
*/
function notActionableState(setup: TradeSetup, currentPrice?: number) {
if (currentPrice == null) return null;
const drift = entryDrift(setup, currentPrice);
const playedOut = setup.direction === 'long' ? currentPrice >= setup.target : currentPrice <= setup.target;
const invalidated = drift?.status === 'invalidated';
if (!playedOut && !invalidated) return null;
return { playedOut, invalidated };
}
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 }: {
setup: TradeSetup;
selectedPrice: number;
onSelect: (target: Target) => void;
}) {
if (!setup.targets || setup.targets.length === 0) {
return
);
}
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) {
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);
// When price has run to/past the target (played out) or through the stop
// (invalidated), there is no fresh setup — show a plain "no current setup"
// state instead of an actionable card with no reward left.
const inactive = notActionableState(setup, currentPrice);
const invalidated = inactive?.invalidated ?? false;
const notActionable = inactive != 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;
const confirmTake = () => {
createTrade.mutate(
{
symbol: setup.symbol,
direction: setup.direction as 'long' | 'short',
entry_price: takeEntry,
shares: takeShares,
stop_loss: setup.stop_loss,
target: takeTarget,
},
{ 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)}%)` : ''} · last target {formatPrice(setup.target)}
{invalidated
? `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.`
: `The last ${dir} setup has played out — price (${formatPrice(currentPrice!)}) is at or past the target (${formatPrice(setup.target)}). No fresh ${dir} setup right now; the scanner surfaces a new one when it forms.`}
);
}
return (
{/* Identity + key stats left, sizing + take top right */}
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.
)}
{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.`}
)}
)}
{activeProb != null && activeProb < 15 && (
⚠ This target has only a {Math.round(activeProb)}% probability — pick a nearer one from the target list below.
)}
{/* The setup, spatially — stop/entry/now hold still, the *selected*
target moves along a scale that always spans the whole ladder */}
t.price)}
/>
{taking && (
Stop {formatPrice(setup.stop_loss)} · Target {formatPrice(takeTarget)} · {setup.direction.toUpperCase()} paper trade
)}
{/* Target ladder — open by default; clicking a row previews it on the rail */}
{setup.targets && setup.targets.length > 0 && (
Targets ({setup.targets.length}) · select a row to preview it on the rail and use it when taking
);
}
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 (
⚠ 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).