Files
signal-platform/frontend/src/components/ticker/RecommendationPanel.tsx
T
dennisthiessen 33f6baca6b
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 36s
Deploy / deploy (push) Successful in 24s
Surface current price; flag stale setups; declutter chart
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>
2026-06-14 11:30:16 +02:00

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>
);
}