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>
This commit is contained in:
@@ -1,18 +1,45 @@
|
||||
import { useMemo, useRef, useEffect, useCallback, useState } from 'react';
|
||||
import { useRef, useEffect, useCallback, useState } from 'react';
|
||||
import type { OHLCVBar, SRLevel, SRZone, TradeSetup } from '../../lib/types';
|
||||
import { formatPrice, formatDate } from '../../lib/format';
|
||||
|
||||
interface CandlestickChartProps {
|
||||
data: OHLCVBar[];
|
||||
srLevels?: SRLevel[];
|
||||
maxSRLevels?: number;
|
||||
zones?: SRZone[];
|
||||
tradeSetup?: TradeSetup;
|
||||
currentPrice?: number;
|
||||
}
|
||||
|
||||
function filterTopSRLevels(levels: SRLevel[], max: number): SRLevel[] {
|
||||
if (levels.length <= max) return levels;
|
||||
return [...levels].sort((a, b) => b.strength - a.strength).slice(0, max);
|
||||
/** A horizontal price marker to draw, with an optional band (zone). */
|
||||
interface SRMarker {
|
||||
price: number;
|
||||
low: number;
|
||||
high: number;
|
||||
strength: number;
|
||||
role: 'support' | 'resistance';
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick only the nearest support below and nearest resistance above the current
|
||||
* price — the two levels that actually matter for a trade right now. Keeps the
|
||||
* chart readable; the full S/R list lives in the S/R tab.
|
||||
*/
|
||||
function nearestSRMarkers(
|
||||
srLevels: SRLevel[],
|
||||
zones: SRZone[],
|
||||
price: number,
|
||||
): SRMarker[] {
|
||||
const raw: SRMarker[] = zones.length
|
||||
? zones.map((z) => ({ price: z.midpoint, low: z.low, high: z.high, strength: z.strength, role: 'support' as const }))
|
||||
: srLevels.map((l) => ({ price: l.price_level, low: l.price_level, high: l.price_level, strength: l.strength, role: 'support' as const }));
|
||||
|
||||
const below = raw.filter((m) => m.high <= price).sort((a, b) => (price - a.price) - (price - b.price));
|
||||
const above = raw.filter((m) => m.low > price).sort((a, b) => (a.price - price) - (b.price - price));
|
||||
|
||||
const out: SRMarker[] = [];
|
||||
if (below[0]) out.push({ ...below[0], role: 'support' });
|
||||
if (above[0]) out.push({ ...above[0], role: 'resistance' });
|
||||
return out;
|
||||
}
|
||||
|
||||
interface TooltipState {
|
||||
@@ -24,7 +51,7 @@ interface TooltipState {
|
||||
|
||||
const MIN_VISIBLE_BARS = 10;
|
||||
|
||||
export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6, zones = [], tradeSetup }: CandlestickChartProps) {
|
||||
export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup, currentPrice }: CandlestickChartProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const overlayCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -46,8 +73,6 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6, zones =
|
||||
setVisibleRange({ start: 0, end: data.length });
|
||||
}, [data]);
|
||||
|
||||
const topLevels = useMemo(() => filterTopSRLevels(srLevels, maxSRLevels), [srLevels, maxSRLevels]);
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
@@ -79,12 +104,16 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6, zones =
|
||||
const cw = W - ml - mr;
|
||||
const ch = H - mt - mb;
|
||||
|
||||
// Current price = explicit prop, else latest close
|
||||
const livePrice = currentPrice ?? visibleData[visibleData.length - 1].close;
|
||||
// Only the nearest support/resistance are drawn — keep the chart legible
|
||||
const markers = nearestSRMarkers(srLevels, zones, livePrice);
|
||||
|
||||
// Price range from visible data
|
||||
const allPrices = visibleData.flatMap((b) => [b.high, b.low]);
|
||||
const srPrices = topLevels.map((l) => l.price_level);
|
||||
const zonePrices = zones.flatMap((z) => [z.low, z.high]);
|
||||
const srPrices = markers.flatMap((m) => [m.low, m.high]);
|
||||
const tradePrices = tradeSetup ? [tradeSetup.entry_price, tradeSetup.stop_loss, tradeSetup.target] : [];
|
||||
const allVals = [...allPrices, ...srPrices, ...zonePrices, ...tradePrices];
|
||||
const allVals = [...allPrices, ...srPrices, ...tradePrices, livePrice];
|
||||
const minP = Math.min(...allVals);
|
||||
const maxP = Math.max(...allVals);
|
||||
const pad = (maxP - minP) * 0.06 || 1;
|
||||
@@ -121,73 +150,37 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6, zones =
|
||||
ctx.fillText(formatDate(visibleData[i].date), x, H - 6);
|
||||
}
|
||||
|
||||
// S/R levels (only when no zones are provided)
|
||||
if (zones.length === 0) {
|
||||
topLevels.forEach((level) => {
|
||||
const y = yScale(level.price_level);
|
||||
const isSupport = level.type === 'support';
|
||||
const color = isSupport ? '#10b981' : '#ef4444';
|
||||
// Nearest support/resistance only (band if it came from a zone)
|
||||
markers.forEach((m) => {
|
||||
const isSupport = m.role === 'support';
|
||||
const color = isSupport ? '#10b981' : '#ef4444';
|
||||
const yMid = yScale(m.price);
|
||||
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.globalAlpha = 0.55;
|
||||
ctx.setLineDash([6, 3]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ml, y);
|
||||
ctx.lineTo(ml + cw, y);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.globalAlpha = 1;
|
||||
if (m.high > m.low) {
|
||||
const yTop = yScale(m.high);
|
||||
const rectHeight = Math.max(yScale(m.low) - yTop, 2);
|
||||
ctx.fillStyle = isSupport ? 'rgba(16, 185, 129, 0.12)' : 'rgba(239, 68, 68, 0.12)';
|
||||
ctx.fillRect(ml, yTop, cw, rectHeight);
|
||||
}
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(
|
||||
`${level.type[0].toUpperCase()} ${formatPrice(level.price_level)}`,
|
||||
ml + cw + 4,
|
||||
y + 3
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// S/R Zone rectangles (drawn before candles so candles render on top)
|
||||
zones.forEach((zone) => {
|
||||
const isSupport = zone.type === 'support';
|
||||
const fillColor = isSupport ? 'rgba(16, 185, 129, 0.15)' : 'rgba(239, 68, 68, 0.15)';
|
||||
const borderColor = isSupport ? 'rgba(16, 185, 129, 0.35)' : 'rgba(239, 68, 68, 0.35)';
|
||||
const labelColor = isSupport ? '#10b981' : '#ef4444';
|
||||
|
||||
const yTop = yScale(zone.high);
|
||||
const yBottom = yScale(zone.low);
|
||||
// Handle single-level zones (low == high) as thin 2px-height rectangles
|
||||
const rectHeight = Math.max(yBottom - yTop, 2);
|
||||
|
||||
// Shaded rectangle spanning full chart width
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.fillRect(ml, yTop, cw, rectHeight);
|
||||
|
||||
// Border lines at top and bottom of zone
|
||||
ctx.strokeStyle = borderColor;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.25;
|
||||
ctx.globalAlpha = 0.6;
|
||||
ctx.setLineDash([6, 3]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ml, yTop);
|
||||
ctx.lineTo(ml + cw, yTop);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ml, yTop + rectHeight);
|
||||
ctx.lineTo(ml + cw, yTop + rectHeight);
|
||||
ctx.moveTo(ml, yMid);
|
||||
ctx.lineTo(ml + cw, yMid);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Label with midpoint price and strength score
|
||||
const yMid = yTop + rectHeight / 2;
|
||||
ctx.fillStyle = labelColor;
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(
|
||||
`${zone.type[0].toUpperCase()} ${formatPrice(zone.midpoint)} (${zone.strength})`,
|
||||
`${isSupport ? 'Support' : 'Resistance'} ${formatPrice(m.price)} (${m.strength})`,
|
||||
ml + cw + 4,
|
||||
yMid + 3
|
||||
yMid + 3,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -248,6 +241,28 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6, zones =
|
||||
ctx.fillText(`TP ${formatPrice(tradeSetup.target)}`, ml + cw + 4, targetY + 3);
|
||||
}
|
||||
|
||||
// Current price line — the anchor for everything else (drawn on top)
|
||||
{
|
||||
const py = yScale(livePrice);
|
||||
ctx.strokeStyle = 'rgba(226, 232, 240, 0.9)';
|
||||
ctx.lineWidth = 1.25;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(ml, py);
|
||||
ctx.lineTo(ml + cw, py);
|
||||
ctx.stroke();
|
||||
|
||||
const label = `Now ${formatPrice(livePrice)}`;
|
||||
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
||||
const tw = ctx.measureText(label).width;
|
||||
ctx.fillStyle = 'rgba(226, 232, 240, 0.95)';
|
||||
ctx.fillRect(ml + 2, py - 8, tw + 8, 16);
|
||||
ctx.fillStyle = '#0e120f';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(label, ml + 6, py);
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
}
|
||||
|
||||
// Candles
|
||||
visibleData.forEach((bar, i) => {
|
||||
const x = ml + i * barW + barW / 2;
|
||||
@@ -285,7 +300,7 @@ export function CandlestickChart({ data, srLevels = [], maxSRLevels = 6, zones =
|
||||
overlay.style.width = `${W}px`;
|
||||
overlay.style.height = `${H}px`;
|
||||
}
|
||||
}, [data, topLevels, visibleRange, zones, tradeSetup]);
|
||||
}, [data, srLevels, visibleRange, zones, tradeSetup, currentPrice]);
|
||||
|
||||
const drawCrosshair = useCallback(() => {
|
||||
const overlay = overlayCanvasRef.current;
|
||||
|
||||
@@ -6,6 +6,26 @@ 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']) {
|
||||
@@ -54,7 +74,7 @@ function TargetTable({ setup }: { setup: TradeSetup }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SetupCard({ setup, action }: { setup?: TradeSetup; action?: TradeSetup['recommended_action'] }) {
|
||||
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">
|
||||
@@ -64,6 +84,7 @@ function SetupCard({ setup, action }: { setup?: TradeSetup; action?: TradeSetup[
|
||||
}
|
||||
|
||||
const recommended = isRecommended(setup, action);
|
||||
const drift = entryDrift(setup, currentPrice);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -81,8 +102,20 @@ function SetupCard({ setup, action }: { setup?: TradeSetup; action?: TradeSetup[
|
||||
<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">Entry</div><div className="font-mono text-gray-200">{formatPrice(setup.entry_price)}</div>
|
||||
<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>
|
||||
@@ -99,7 +132,7 @@ function SetupCard({ setup, action }: { setup?: TradeSetup; action?: TradeSetup[
|
||||
);
|
||||
}
|
||||
|
||||
export function RecommendationPanel({ symbol, longSetup, shortSetup }: RecommendationPanelProps) {
|
||||
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);
|
||||
@@ -143,7 +176,7 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup }: Recommend
|
||||
|
||||
{preferredDirection !== 'neutral' && preferredSetup ? (
|
||||
<div className="space-y-3">
|
||||
<SetupCard setup={preferredSetup} action={action} />
|
||||
<SetupCard setup={preferredSetup} action={action} currentPrice={currentPrice} />
|
||||
|
||||
{alternativeSetup && (
|
||||
<details className="glass-sm p-3">
|
||||
@@ -151,15 +184,15 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup }: Recommend
|
||||
Alternative scenario ({alternativeSetup.direction.toUpperCase()})
|
||||
</summary>
|
||||
<div className="mt-3">
|
||||
<SetupCard setup={alternativeSetup} action={action} />
|
||||
<SetupCard setup={alternativeSetup} action={action} currentPrice={currentPrice} />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<SetupCard setup={longSetup} action={action} />
|
||||
<SetupCard setup={shortSetup} action={action} />
|
||||
<SetupCard setup={longSetup} action={action} currentPrice={currentPrice} />
|
||||
<SetupCard setup={shortSetup} action={action} currentPrice={currentPrice} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user