Improve chart trade overlays and paper fill markers.
Deploy / lint (push) Successful in 9s
Deploy / test (push) Successful in 1m12s
Deploy / deploy (push) Successful in 39s

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:
2026-07-14 10:44:56 +02:00
parent 8db535b889
commit d5b0ebf895
2 changed files with 240 additions and 49 deletions
@@ -2,6 +2,7 @@ import { useRef, useEffect, useCallback, useState } from 'react';
import type { import type {
GateTargetLevel, GateTargetLevel,
OHLCVBar, OHLCVBar,
PaperTrade,
SRLevel, SRLevel,
SRZone, SRZone,
TradeSetup, TradeSetup,
@@ -20,6 +21,69 @@ interface CandlestickChartProps {
onShowGateTrafficChange?: (visible: boolean) => void; onShowGateTrafficChange?: (visible: boolean) => void;
tradeSetup?: TradeSetup; tradeSetup?: TradeSetup;
currentPrice?: number; 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). */ /** A horizontal price marker to draw, with an optional band (zone). */
@@ -98,6 +162,7 @@ export function CandlestickChart({
onShowGateTrafficChange, onShowGateTrafficChange,
tradeSetup, tradeSetup,
currentPrice, currentPrice,
paperTrades = [],
}: CandlestickChartProps) { }: CandlestickChartProps) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const overlayCanvasRef = 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 // Only the nearest support/resistance are drawn — keep the chart legible
const markers = nearestSRMarkers(srLevels, zones, livePrice); const markers = nearestSRMarkers(srLevels, zones, livePrice);
// Margins: line labels (Entry / Stop / Support…) on the LEFT, pure price // Margins: trade role labels (Now / Stop / Gate / Entry) outside on the
// ticks on the RIGHT. Packing both on the right made trade overlays unreadable. // LEFT; pure price ticks on the RIGHT. Support/Resist sit *inside* the
const hasLineLabels = Boolean(tradeSetup) || markers.length > 0; // plot on their lines, so they don't need exterior gutter space.
const ml = hasLineLabels ? 78 : 16; const ml = 56;
const mr = 58; const mr = 58;
const mt = 12, mb = 32; const mt = 12, mb = 32;
const cw = W - ml - mr; const cw = W - ml - mr;
@@ -289,22 +354,63 @@ export function CandlestickChart({
ctx.restore(); ctx.restore();
} }
// Collect left-side line labels; prices live on the right axis only. // Left-side role labels only; prices live on the right axis.
type LineLabel = { y: number; text: string; color: string; weight?: 'normal' | 'bold' }; // 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 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) => { markers.forEach((m) => {
const isSupport = m.role === 'support'; const isSupport = m.role === 'support';
// Support = rim-cyan, resistance = neutral ink — rose stays reserved for // Support stays cyan; resistance is a soft coral (warmer than neutral ink,
// the stop level so S/R never impersonates trade levels. // cooler/softer than the stop so the two never read as the same thing).
const color = isSupport ? '#2f9db2' : '#9aa0b0'; const color = isSupport ? '#2f9db2' : '#e89b8c';
const yMid = yScale(m.price); 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) { if (m.high > m.low) {
const yTop = yScale(m.high); const yTop = yScale(m.high);
const rectHeight = Math.max(yScale(m.low) - yTop, 2); 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); ctx.fillRect(ml, yTop, cw, rectHeight);
} }
@@ -319,10 +425,11 @@ export function CandlestickChart({
ctx.setLineDash([]); ctx.setLineDash([]);
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
lineLabels.push({ inPlotLabels.push({
y: yMid, y: yMid,
text: isSupport ? 'Support' : 'Resist', text: isSupport ? 'Support' : 'Resist',
color, 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.stroke();
ctx.setLineDash([]); 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.strokeStyle = 'rgba(154, 160, 176, 0.9)';
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
ctx.setLineDash([6, 4]); ctx.setLineDash([6, 4]);
@@ -367,59 +476,56 @@ export function CandlestickChart({
ctx.lineTo(ml + cw, entryY); ctx.lineTo(ml + cw, entryY);
ctx.stroke(); ctx.stroke();
ctx.setLineDash([]); ctx.setLineDash([]);
lineLabels.push({
y: entryY,
text: 'Entry',
color: 'rgba(154, 160, 176, 0.95)',
weight: 'bold',
priority: 70,
});
}
lineLabels.push( 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', priority: 90 },
{ 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', priority: 80 },
{ y: targetY, text: 'Gate', color: 'rgba(196, 181, 253, 0.95)', weight: 'bold' },
); );
} }
// Current price line — the anchor for everything else (drawn on top) // Current price line — the anchor for everything else (drawn on top)
{ {
const py = yScale(livePrice);
ctx.strokeStyle = 'rgba(226, 232, 240, 0.9)'; ctx.strokeStyle = 'rgba(226, 232, 240, 0.9)';
ctx.lineWidth = 1.25; ctx.lineWidth = 1.25;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(ml, py); ctx.moveTo(ml, nowY);
ctx.lineTo(ml + cw, py); ctx.lineTo(ml + cw, nowY);
ctx.stroke(); ctx.stroke();
lineLabels.push({ lineLabels.push({
y: py, y: nowY,
text: 'Now', text: 'Now',
color: 'rgba(226, 232, 240, 0.95)', color: 'rgba(226, 232, 240, 0.95)',
weight: 'bold', weight: 'bold',
priority: 100,
}); });
} }
// Left-side role labels (no prices — those sit on the right axis). Spread // Left-side role labels. Prefer dropping lower-priority labels over shifting
// stacked labels so Entry/Stop/Gate don't paint over each other when close. // them off their lines (shifted labels look like wrong prices).
if (lineLabels.length > 0) { if (lineLabels.length > 0) {
const ordered = [...lineLabels].sort((a, b) => a.y - b.y); const byPriority = [...lineLabels].sort((a, b) => b.priority - a.priority);
const minGap = 13; const kept: LineLabel[] = [];
for (let i = 1; i < ordered.length; i++) { for (const candidate of byPriority) {
if (ordered[i].y - ordered[i - 1].y < minGap) { const collides = kept.some(
ordered[i].y = ordered[i - 1].y + minGap; (k) => Math.abs(k.y - candidate.y) < LABEL_MIN_GAP_PX,
} );
} if (!collides) kept.push(candidate);
// 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;
}
} }
kept.sort((a, b) => a.y - b.y);
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace'; ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
ctx.textAlign = 'right'; ctx.textAlign = 'right';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
for (const label of ordered) { for (const label of kept) {
ctx.fillStyle = label.color; ctx.fillStyle = label.color;
if (label.weight === 'bold') { if (label.weight === 'bold') {
ctx.font = '600 10px "IBM Plex Mono", ui-monospace, monospace'; 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); 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) // Store geometry for hit testing (includes visibleRange offset)
(canvas as any).__chartMeta = { (canvas as any).__chartMeta = {
ml, ml,
@@ -503,6 +675,7 @@ export function CandlestickChart({
currentPrice, currentPrice,
data, data,
gateTargetLevels, gateTargetLevels,
paperTrades,
showGateTraffic, showGateTraffic,
srLevels, srLevels,
tradeSetup, tradeSetup,
+18
View File
@@ -145,12 +145,21 @@ export default function TickerDetailPage() {
// Status labels: is there an open paper trade on this ticker, and is it the // Status labels: is there an open paper trade on this ticker, and is it the
// current top pick (same ranking the dashboard highlights)? // current top pick (same ranking the dashboard highlights)?
const openTrades = usePaperTrades('open'); const openTrades = usePaperTrades('open');
// Full history for chart entry/exit arrows (open + closed on this symbol).
const paperTradeHistory = usePaperTrades();
const allTrades = useTrades(); const allTrades = useTrades();
const activation = useActivation(); const activation = useActivation();
const hasOpenTrade = useMemo( const hasOpenTrade = useMemo(
() => (openTrades.data ?? []).some((t) => t.symbol.toUpperCase() === symbol.toUpperCase()), () => (openTrades.data ?? []).some((t) => t.symbol.toUpperCase() === symbol.toUpperCase()),
[openTrades.data, symbol], [openTrades.data, symbol],
); );
const symbolPaperTrades = useMemo(
() =>
(paperTradeHistory.data ?? []).filter(
(t) => t.symbol.toUpperCase() === symbol.toUpperCase(),
),
[paperTradeHistory.data, symbol],
);
const isTopPick = useMemo( const isTopPick = useMemo(
() => topPickSymbol(allTrades.data, activation.data)?.toUpperCase() === symbol.toUpperCase(), () => topPickSymbol(allTrades.data, activation.data)?.toUpperCase() === symbol.toUpperCase(),
[allTrades.data, activation.data, symbol], [allTrades.data, activation.data, symbol],
@@ -456,11 +465,20 @@ export default function TickerDetailPage() {
onShowGateTrafficChange={setShowGateTraffic} onShowGateTrafficChange={setShowGateTraffic}
tradeSetup={overlayWithTarget} tradeSetup={overlayWithTarget}
currentPrice={priceInfo?.price} currentPrice={priceInfo?.price}
paperTrades={symbolPaperTrades}
/> />
<p className="mt-2 text-[11px] text-gray-500"> <p className="mt-2 text-[11px] text-gray-500">
Only the nearest support &amp; resistance are drawn. Full list in the S/R Levels tab. Only the nearest support &amp; resistance are drawn. Full list in the S/R Levels tab.
{srLevels.isError && ' S/R levels unavailable.'} {srLevels.isError && ' S/R levels unavailable.'}
{gateTargetLadder.isError && ' GTL diagnostic 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> </p>
<ProductionRankStrip <ProductionRankStrip
setup={longSetup ?? shortSetup} setup={longSetup ?? shortSetup}