Improve chart trade overlays and paper fill markers.
De-clutter setup labels (left roles, right prices; hide Entry near Now; S/R in-plot), and draw green/red arrows for paper trade entry and exit on the matching sessions.
This commit is contained in:
@@ -2,6 +2,7 @@ import { useRef, useEffect, useCallback, useState } from 'react';
|
||||
import type {
|
||||
GateTargetLevel,
|
||||
OHLCVBar,
|
||||
PaperTrade,
|
||||
SRLevel,
|
||||
SRZone,
|
||||
TradeSetup,
|
||||
@@ -20,6 +21,69 @@ interface CandlestickChartProps {
|
||||
onShowGateTrafficChange?: (visible: boolean) => void;
|
||||
tradeSetup?: TradeSetup;
|
||||
currentPrice?: number;
|
||||
/** Paper trades for this ticker — entry/exit arrows on the matching bars. */
|
||||
paperTrades?: PaperTrade[];
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD from an ISO timestamp or bare date string. */
|
||||
function dayKey(value: string): string {
|
||||
return value.length >= 10 ? value.slice(0, 10) : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the bar that best represents a paper-trade event time.
|
||||
* Prefer the same calendar day; else the first session on/after that day.
|
||||
*/
|
||||
function barIndexForEvent(data: OHLCVBar[], timestamp: string): number {
|
||||
if (!data.length) return -1;
|
||||
const day = dayKey(timestamp);
|
||||
const exact = data.findIndex((b) => dayKey(b.date) === day);
|
||||
if (exact >= 0) return exact;
|
||||
const after = data.findIndex((b) => dayKey(b.date) >= day);
|
||||
if (after >= 0) return after;
|
||||
// Event is after the last bar we have — pin to the last session.
|
||||
if (dayKey(data[data.length - 1].date) < day) return data.length - 1;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Filled triangle pointing down; tip sits at (x, y). */
|
||||
function drawEntryArrow(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
color: string,
|
||||
size = 7,
|
||||
) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.strokeStyle = 'rgba(10, 11, 17, 0.55)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
ctx.lineTo(x - size, y - size * 1.35);
|
||||
ctx.lineTo(x + size, y - size * 1.35);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/** Filled triangle pointing up; tip sits at (x, y). */
|
||||
function drawExitArrow(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
color: string,
|
||||
size = 7,
|
||||
) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.strokeStyle = 'rgba(10, 11, 17, 0.55)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
ctx.lineTo(x - size, y + size * 1.35);
|
||||
ctx.lineTo(x + size, y + size * 1.35);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/** A horizontal price marker to draw, with an optional band (zone). */
|
||||
@@ -98,6 +162,7 @@ export function CandlestickChart({
|
||||
onShowGateTrafficChange,
|
||||
tradeSetup,
|
||||
currentPrice,
|
||||
paperTrades = [],
|
||||
}: CandlestickChartProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const overlayCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
@@ -153,10 +218,10 @@ export function CandlestickChart({
|
||||
// Only the nearest support/resistance are drawn — keep the chart legible
|
||||
const markers = nearestSRMarkers(srLevels, zones, livePrice);
|
||||
|
||||
// Margins: line labels (Entry / Stop / Support…) on the LEFT, pure price
|
||||
// ticks on the RIGHT. Packing both on the right made trade overlays unreadable.
|
||||
const hasLineLabels = Boolean(tradeSetup) || markers.length > 0;
|
||||
const ml = hasLineLabels ? 78 : 16;
|
||||
// Margins: trade role labels (Now / Stop / Gate / Entry) outside on the
|
||||
// LEFT; pure price ticks on the RIGHT. Support/Resist sit *inside* the
|
||||
// plot on their lines, so they don't need exterior gutter space.
|
||||
const ml = 56;
|
||||
const mr = 58;
|
||||
const mt = 12, mb = 32;
|
||||
const cw = W - ml - mr;
|
||||
@@ -289,22 +354,63 @@ export function CandlestickChart({
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Collect left-side line labels; prices live on the right axis only.
|
||||
type LineLabel = { y: number; text: string; color: string; weight?: 'normal' | 'bold' };
|
||||
// Left-side role labels only; prices live on the right axis.
|
||||
// Priority when levels crowd: Now > Stop > Gate > Entry > S/R.
|
||||
// Best practice: hide Entry when it sits on top of Now — at market, Now
|
||||
// *is* the fill; Entry only matters once price has drifted from the scan.
|
||||
type LineLabel = {
|
||||
y: number;
|
||||
text: string;
|
||||
color: string;
|
||||
weight?: 'normal' | 'bold';
|
||||
priority: number;
|
||||
};
|
||||
const lineLabels: LineLabel[] = [];
|
||||
const LABEL_MIN_GAP_PX = 14;
|
||||
const nowY = yScale(livePrice);
|
||||
|
||||
// Nearest support/resistance only (band if it came from a zone)
|
||||
// Entry is noise when price hasn't moved off the scan level. ~0.25R or
|
||||
// ~12px, whichever is larger, keeps the chart honest without double lines.
|
||||
const riskDist = tradeSetup
|
||||
? Math.abs(tradeSetup.entry_price - tradeSetup.stop_loss)
|
||||
: 0;
|
||||
const entryDriftPx = tradeSetup
|
||||
? Math.abs(yScale(tradeSetup.entry_price) - nowY)
|
||||
: 0;
|
||||
const entryDriftR = tradeSetup && riskDist > 0
|
||||
? Math.abs(livePrice - tradeSetup.entry_price) / riskDist
|
||||
: 0;
|
||||
const showEntry = Boolean(
|
||||
tradeSetup
|
||||
&& (entryDriftPx >= 12 || entryDriftR >= 0.25),
|
||||
);
|
||||
|
||||
const tradeYs: number[] = [nowY];
|
||||
if (tradeSetup) {
|
||||
tradeYs.push(yScale(tradeSetup.stop_loss), yScale(tradeSetup.target));
|
||||
if (showEntry) tradeYs.push(yScale(tradeSetup.entry_price));
|
||||
}
|
||||
|
||||
// In-plot S/R labels (drawn after candles so they stay readable). Not
|
||||
// exterior margin labels — they ride the line itself, left-aligned.
|
||||
type InPlotLabel = { y: number; text: string; color: string; bg: string };
|
||||
const inPlotLabels: InPlotLabel[] = [];
|
||||
|
||||
// Nearest support/resistance only (band if it came from a zone).
|
||||
// Drop S/R entirely when it sits on a trade line — the trade label wins.
|
||||
markers.forEach((m) => {
|
||||
const isSupport = m.role === 'support';
|
||||
// Support = rim-cyan, resistance = neutral ink — rose stays reserved for
|
||||
// the stop level so S/R never impersonates trade levels.
|
||||
const color = isSupport ? '#2f9db2' : '#9aa0b0';
|
||||
// Support stays cyan; resistance is a soft coral (warmer than neutral ink,
|
||||
// cooler/softer than the stop so the two never read as the same thing).
|
||||
const color = isSupport ? '#2f9db2' : '#e89b8c';
|
||||
const yMid = yScale(m.price);
|
||||
const collidesTrade = tradeYs.some((ty) => Math.abs(ty - yMid) < LABEL_MIN_GAP_PX);
|
||||
if (collidesTrade) return;
|
||||
|
||||
if (m.high > m.low) {
|
||||
const yTop = yScale(m.high);
|
||||
const rectHeight = Math.max(yScale(m.low) - yTop, 2);
|
||||
ctx.fillStyle = isSupport ? 'rgba(47, 157, 178, 0.12)' : 'rgba(154, 160, 176, 0.10)';
|
||||
ctx.fillStyle = isSupport ? 'rgba(47, 157, 178, 0.12)' : 'rgba(232, 155, 140, 0.12)';
|
||||
ctx.fillRect(ml, yTop, cw, rectHeight);
|
||||
}
|
||||
|
||||
@@ -319,10 +425,11 @@ export function CandlestickChart({
|
||||
ctx.setLineDash([]);
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
lineLabels.push({
|
||||
inPlotLabels.push({
|
||||
y: yMid,
|
||||
text: isSupport ? 'Support' : 'Resist',
|
||||
color,
|
||||
bg: isSupport ? 'rgba(12, 18, 28, 0.78)' : 'rgba(28, 14, 14, 0.78)',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -358,7 +465,9 @@ export function CandlestickChart({
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Entry price: dashed horizontal line (neutral ink)
|
||||
// Entry only when it has drifted from Now — otherwise the two lines
|
||||
// stack and fight for the same label slot.
|
||||
if (showEntry) {
|
||||
ctx.strokeStyle = 'rgba(154, 160, 176, 0.9)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([6, 4]);
|
||||
@@ -367,59 +476,56 @@ export function CandlestickChart({
|
||||
ctx.lineTo(ml + cw, entryY);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
lineLabels.push({
|
||||
y: entryY,
|
||||
text: 'Entry',
|
||||
color: 'rgba(154, 160, 176, 0.95)',
|
||||
weight: 'bold',
|
||||
priority: 70,
|
||||
});
|
||||
}
|
||||
|
||||
lineLabels.push(
|
||||
{ y: entryY, text: 'Entry', color: 'rgba(154, 160, 176, 0.95)', weight: 'bold' },
|
||||
{ y: stopY, text: 'Stop', color: 'rgba(239, 145, 130, 0.95)', weight: 'bold' },
|
||||
{ y: targetY, text: 'Gate', color: 'rgba(196, 181, 253, 0.95)', weight: 'bold' },
|
||||
{ y: stopY, text: 'Stop', color: 'rgba(239, 145, 130, 0.95)', weight: 'bold', priority: 90 },
|
||||
{ y: targetY, text: 'Gate', color: 'rgba(196, 181, 253, 0.95)', weight: 'bold', priority: 80 },
|
||||
);
|
||||
}
|
||||
|
||||
// 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.moveTo(ml, nowY);
|
||||
ctx.lineTo(ml + cw, nowY);
|
||||
ctx.stroke();
|
||||
|
||||
lineLabels.push({
|
||||
y: py,
|
||||
y: nowY,
|
||||
text: 'Now',
|
||||
color: 'rgba(226, 232, 240, 0.95)',
|
||||
weight: 'bold',
|
||||
priority: 100,
|
||||
});
|
||||
}
|
||||
|
||||
// Left-side role labels (no prices — those sit on the right axis). Spread
|
||||
// stacked labels so Entry/Stop/Gate don't paint over each other when close.
|
||||
// Left-side role labels. Prefer dropping lower-priority labels over shifting
|
||||
// them off their lines (shifted labels look like wrong prices).
|
||||
if (lineLabels.length > 0) {
|
||||
const ordered = [...lineLabels].sort((a, b) => a.y - b.y);
|
||||
const minGap = 13;
|
||||
for (let i = 1; i < ordered.length; i++) {
|
||||
if (ordered[i].y - ordered[i - 1].y < minGap) {
|
||||
ordered[i].y = ordered[i - 1].y + minGap;
|
||||
}
|
||||
}
|
||||
// If we overflow the pane bottom, pull the stack back up.
|
||||
const maxY = mt + ch - 4;
|
||||
if (ordered.length > 0 && ordered[ordered.length - 1].y > maxY) {
|
||||
let shift = ordered[ordered.length - 1].y - maxY;
|
||||
for (let i = ordered.length - 1; i >= 0 && shift > 0; i--) {
|
||||
const prevFloor = i === 0 ? mt + 4 : ordered[i - 1].y + minGap;
|
||||
const room = ordered[i].y - prevFloor;
|
||||
const pull = Math.min(shift, Math.max(0, room));
|
||||
ordered[i].y -= pull;
|
||||
shift -= pull;
|
||||
}
|
||||
const byPriority = [...lineLabels].sort((a, b) => b.priority - a.priority);
|
||||
const kept: LineLabel[] = [];
|
||||
for (const candidate of byPriority) {
|
||||
const collides = kept.some(
|
||||
(k) => Math.abs(k.y - candidate.y) < LABEL_MIN_GAP_PX,
|
||||
);
|
||||
if (!collides) kept.push(candidate);
|
||||
}
|
||||
kept.sort((a, b) => a.y - b.y);
|
||||
|
||||
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
for (const label of ordered) {
|
||||
for (const label of kept) {
|
||||
ctx.fillStyle = label.color;
|
||||
if (label.weight === 'bold') {
|
||||
ctx.font = '600 10px "IBM Plex Mono", ui-monospace, monospace';
|
||||
@@ -466,6 +572,72 @@ export function CandlestickChart({
|
||||
ctx.fillRect(x - candleW / 2, bodyTop, candleW, bodyH);
|
||||
});
|
||||
|
||||
// Support / Resist: label rides the line inside the plot, left-aligned.
|
||||
// Soft pill so text stays legible over candles without needing the gutter.
|
||||
if (inPlotLabels.length > 0) {
|
||||
ctx.font = '600 10px "IBM Plex Mono", ui-monospace, monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
const padX = 5;
|
||||
const padY = 2;
|
||||
for (const label of inPlotLabels) {
|
||||
const tw = ctx.measureText(label.text).width;
|
||||
const bx = ml + 6;
|
||||
const by = label.y;
|
||||
const bw = tw + padX * 2;
|
||||
const bh = 14 + padY;
|
||||
ctx.fillStyle = label.bg;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(bx, by - bh / 2, bw, bh, 3);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = label.color;
|
||||
ctx.fillText(label.text, bx + padX, by);
|
||||
}
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
}
|
||||
|
||||
// Paper-trade markers: green ↓ entry above the bar at entry price, red ↑
|
||||
// exit below the bar at close price (classic execution-chart convention).
|
||||
if (paperTrades.length > 0) {
|
||||
const ENTRY_GREEN = '#34d399';
|
||||
const EXIT_RED = '#f87171';
|
||||
for (const pt of paperTrades) {
|
||||
const entryAbs = barIndexForEvent(data, pt.opened_at);
|
||||
if (entryAbs >= start && entryAbs < end) {
|
||||
const local = entryAbs - start;
|
||||
const x = ml + local * barW + barW / 2;
|
||||
const bar = data[entryAbs];
|
||||
// Sit the tip on the entry price, but keep the arrow above the high
|
||||
// so it stays readable when entry is mid-body.
|
||||
const yPrice = yScale(pt.entry_price);
|
||||
const yHigh = yScale(bar.high);
|
||||
const y = Math.min(yPrice, yHigh) - 2;
|
||||
if (y >= mt - 2 && y <= priceBottom + 4) {
|
||||
drawEntryArrow(ctx, x, y, ENTRY_GREEN);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
pt.status === 'closed'
|
||||
&& pt.closed_at
|
||||
&& pt.close_price != null
|
||||
) {
|
||||
const closeAbs = barIndexForEvent(data, pt.closed_at);
|
||||
if (closeAbs >= start && closeAbs < end) {
|
||||
const local = closeAbs - start;
|
||||
const x = ml + local * barW + barW / 2;
|
||||
const bar = data[closeAbs];
|
||||
const yPrice = yScale(pt.close_price);
|
||||
const yLow = yScale(bar.low);
|
||||
const y = Math.max(yPrice, yLow) + 2;
|
||||
if (y >= mt - 4 && y <= priceBottom + 6) {
|
||||
drawExitArrow(ctx, x, y, EXIT_RED);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store geometry for hit testing (includes visibleRange offset)
|
||||
(canvas as any).__chartMeta = {
|
||||
ml,
|
||||
@@ -503,6 +675,7 @@ export function CandlestickChart({
|
||||
currentPrice,
|
||||
data,
|
||||
gateTargetLevels,
|
||||
paperTrades,
|
||||
showGateTraffic,
|
||||
srLevels,
|
||||
tradeSetup,
|
||||
|
||||
@@ -145,12 +145,21 @@ export default function TickerDetailPage() {
|
||||
// Status labels: is there an open paper trade on this ticker, and is it the
|
||||
// current top pick (same ranking the dashboard highlights)?
|
||||
const openTrades = usePaperTrades('open');
|
||||
// Full history for chart entry/exit arrows (open + closed on this symbol).
|
||||
const paperTradeHistory = usePaperTrades();
|
||||
const allTrades = useTrades();
|
||||
const activation = useActivation();
|
||||
const hasOpenTrade = useMemo(
|
||||
() => (openTrades.data ?? []).some((t) => t.symbol.toUpperCase() === symbol.toUpperCase()),
|
||||
[openTrades.data, symbol],
|
||||
);
|
||||
const symbolPaperTrades = useMemo(
|
||||
() =>
|
||||
(paperTradeHistory.data ?? []).filter(
|
||||
(t) => t.symbol.toUpperCase() === symbol.toUpperCase(),
|
||||
),
|
||||
[paperTradeHistory.data, symbol],
|
||||
);
|
||||
const isTopPick = useMemo(
|
||||
() => topPickSymbol(allTrades.data, activation.data)?.toUpperCase() === symbol.toUpperCase(),
|
||||
[allTrades.data, activation.data, symbol],
|
||||
@@ -456,11 +465,20 @@ export default function TickerDetailPage() {
|
||||
onShowGateTrafficChange={setShowGateTraffic}
|
||||
tradeSetup={overlayWithTarget}
|
||||
currentPrice={priceInfo?.price}
|
||||
paperTrades={symbolPaperTrades}
|
||||
/>
|
||||
<p className="mt-2 text-[11px] text-gray-500">
|
||||
Only the nearest support & resistance are drawn. Full list in the S/R Levels tab.
|
||||
{srLevels.isError && ' S/R levels unavailable.'}
|
||||
{gateTargetLadder.isError && ' GTL diagnostic unavailable.'}
|
||||
{symbolPaperTrades.length > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
<span className="text-emerald-400/90">▼</span> paper entry
|
||||
{' · '}
|
||||
<span className="text-red-400/90">▲</span> paper exit
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<ProductionRankStrip
|
||||
setup={longSetup ?? shortSetup}
|
||||
|
||||
Reference in New Issue
Block a user