33f6baca6b
Triggered by MRK: entry 113 shown with no current price (actually ~119). - Ticker header shows last close + day change % + "last close · Nd ago" (the age reveals OHLCV collection lag — why entry looked off) - Setup cards show Current price and entry drift; flag setups as stale (price moved >1/3 toward target) or invalidated (past stop) - Chart: draw only nearest support below + nearest resistance above current price, plus a prominent "Now" price line (full S/R stays in the S/R tab) - Chart overlay is selectable (Auto/Long/Short/None) — only the chosen setup's entry/stop/target render, instead of everything at once Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
202 lines
8.7 KiB
TypeScript
202 lines
8.7 KiB
TypeScript
import type { TradeSetup } from '../../lib/types';
|
|
import { formatPrice, formatPercent } from '../../lib/format';
|
|
import { recommendationActionDirection, recommendationActionLabel } from '../../lib/recommendation';
|
|
|
|
interface RecommendationPanelProps {
|
|
symbol: string;
|
|
longSetup?: TradeSetup;
|
|
shortSetup?: TradeSetup;
|
|
currentPrice?: number;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
// Past the stop entirely = invalidated; moved >1/3 of the way to target = stale
|
|
const span = Math.abs(setup.target - setup.entry_price);
|
|
const moved = Math.abs(currentPrice - setup.entry_price);
|
|
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 (span > 0 && moved / span > 0.33) status = 'stale';
|
|
return { pct, 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 <p className="text-xs text-gray-500">No target probabilities available.</p>;
|
|
}
|
|
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="text-left text-gray-500 border-b border-white/[0.06]">
|
|
<th className="py-2 pr-3">Classification</th>
|
|
<th className="py-2 pr-3">Price</th>
|
|
<th className="py-2 pr-3">Distance</th>
|
|
<th className="py-2 pr-3">R:R</th>
|
|
<th className="py-2">Probability</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{setup.targets.map((target) => (
|
|
<tr key={`${setup.id}-${target.sr_level_id}-${target.price}`} className="border-b border-white/[0.04]">
|
|
<td className="py-2 pr-3 text-gray-300">{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 }: { setup?: TradeSetup; action?: TradeSetup['recommended_action']; currentPrice?: number }) {
|
|
if (!setup) {
|
|
return (
|
|
<div className="glass-sm p-4 text-xs text-gray-500">
|
|
Setup unavailable for this direction.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const recommended = isRecommended(setup, action);
|
|
const drift = entryDrift(setup, currentPrice);
|
|
|
|
return (
|
|
<div
|
|
data-direction={setup.direction}
|
|
className={`glass-sm p-4 space-y-3 ${recommended ? 'border border-emerald-500/40' : 'opacity-80'}`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<h4 className={`text-sm font-semibold ${setup.direction === 'long' ? 'text-emerald-400' : 'text-red-400'}`}>
|
|
{setup.direction.toUpperCase()}
|
|
</h4>
|
|
<span className="text-xs text-gray-300">{setup.confidence_score?.toFixed(1) ?? '—'}%</span>
|
|
</div>
|
|
|
|
{!recommended && recommendationActionDirection(action ?? null) !== 'neutral' && (
|
|
<p className="text-[11px] text-amber-400">Alternative setup (ticker bias currently favors the opposite direction).</p>
|
|
)}
|
|
|
|
{drift && drift.status === 'invalidated' && (
|
|
<p className="text-[11px] text-red-400">
|
|
⚠ Price ({formatPrice(currentPrice!)}) is past the stop — this setup is invalidated.
|
|
</p>
|
|
)}
|
|
{drift && drift.status === 'stale' && (
|
|
<p className="text-[11px] text-amber-400">
|
|
⚠ Price has moved {drift.pct >= 0 ? '+' : ''}{drift.pct.toFixed(1)}% from entry{drift.towardTarget ? ' toward target' : ' against the setup'} — entry may be stale.
|
|
</p>
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
<div className="text-gray-500">Current</div><div className="font-mono text-gray-200">{currentPrice != null ? formatPrice(currentPrice) : '—'}</div>
|
|
<div className="text-gray-500">Entry</div><div className="font-mono text-gray-200">{formatPrice(setup.entry_price)}{drift ? ` (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}%)` : ''}</div>
|
|
<div className="text-gray-500">Stop</div><div className="font-mono text-gray-200">{formatPrice(setup.stop_loss)}</div>
|
|
<div className="text-gray-500">Primary Target</div><div className="font-mono text-gray-200">{formatPrice(setup.target)}</div>
|
|
<div className="text-gray-500">R:R</div><div className="font-mono text-gray-200">{setup.rr_ratio.toFixed(2)}</div>
|
|
</div>
|
|
|
|
<TargetTable setup={setup} />
|
|
|
|
{setup.conflict_flags.length > 0 && (
|
|
<div className="rounded border border-amber-500/30 bg-amber-500/10 p-2 text-[11px] text-amber-300">
|
|
{setup.conflict_flags.join(' • ')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPrice }: RecommendationPanelProps) {
|
|
const summary = longSetup?.recommendation_summary ?? shortSetup?.recommendation_summary;
|
|
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 (
|
|
<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">
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
<span className="text-sm font-semibold text-blue-300">{recommendationActionLabel(action)}</span>
|
|
<span className={`text-sm font-semibold ${riskClass(summary?.risk_level ?? null)}`}>
|
|
Risk: {summary?.risk_level ?? '—'}
|
|
</span>
|
|
<span className="text-sm text-gray-300">Composite: {summary?.composite_score?.toFixed(1) ?? '—'}</span>
|
|
<span className="text-xs text-gray-500">{symbol.toUpperCase()}</span>
|
|
</div>
|
|
|
|
<p className="text-xs text-gray-500">Recommended Action is the ticker-level bias. The preferred setup is shown first; the opposite side is available under Alternative scenario.</p>
|
|
|
|
{summary?.reasoning && (
|
|
<p className="text-sm text-gray-300">{summary.reasoning}</p>
|
|
)}
|
|
|
|
{preferredDirection !== 'neutral' && preferredSetup ? (
|
|
<div className="space-y-3">
|
|
<SetupCard setup={preferredSetup} action={action} currentPrice={currentPrice} />
|
|
|
|
{alternativeSetup && (
|
|
<details className="glass-sm p-3">
|
|
<summary className="cursor-pointer text-xs font-medium text-gray-300">
|
|
Alternative scenario ({alternativeSetup.direction.toUpperCase()})
|
|
</summary>
|
|
<div className="mt-3">
|
|
<SetupCard setup={alternativeSetup} action={action} currentPrice={currentPrice} />
|
|
</div>
|
|
</details>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-4 lg:grid-cols-2">
|
|
<SetupCard setup={longSetup} action={action} currentPrice={currentPrice} />
|
|
<SetupCard setup={shortSetup} action={action} currentPrice={currentPrice} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|