feat: visual e coleta de dados do footprint chart
- Footprint bars bid/ask por nível com cor dominante - Painel inferior duplo: volume (barras) + delta (blocos azul/laranja) - Linha de preço atual ciano dashed com tag no eixo - Timestamp por cluster no eixo X - Drag no eixo de tempo para zoom horizontal - Drag no eixo de preço para ajustar step multiplier - Coleta de volume via price-step method para delta correto - Guard is_live para evitar flood de WebSocket no replay histórico - total_ticks adicionado ao aggregator Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
0a185c0223
commit
11a0921e20
+96
-12
@@ -11,11 +11,16 @@ export default function App() {
|
||||
const [history, setHistory] = useState([]);
|
||||
const [activeCluster, setActiveCluster] = useState(null);
|
||||
const [lastTickTime, setLastTickTime] = useState(null);
|
||||
|
||||
|
||||
// Phase 2 & 4 Settings
|
||||
const [stepMultiplier, setStepMultiplier] = useState(1);
|
||||
const [stepMultiplier, setStepMultiplier] = useState(75);
|
||||
const [viewMode, setViewMode] = useState('bidask'); // 'bidask' or 'delta'
|
||||
const [imbalanceRatio, setImbalanceRatio] = useState(300); // percentage
|
||||
|
||||
// Cluster aggregator config
|
||||
const [closeMode, setCloseMode] = useState('delta');
|
||||
const [deltaMax, setDeltaMax] = useState(800);
|
||||
const [configDirty, setConfigDirty] = useState(false);
|
||||
|
||||
// Toasts
|
||||
const [toasts, setToasts] = useState([]);
|
||||
@@ -28,6 +33,30 @@ export default function App() {
|
||||
}, 5000);
|
||||
}, []);
|
||||
|
||||
// Fetch config from backend on load
|
||||
const fetchConfig = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/config`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCloseMode(data.close_mode || 'delta');
|
||||
setDeltaMax(data.delta_max || 2418);
|
||||
}
|
||||
} catch (e) {}
|
||||
}, []);
|
||||
|
||||
// Push config update to backend
|
||||
const applyConfig = useCallback(async (mode, delta) => {
|
||||
try {
|
||||
await fetch(`${API_URL}/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ close_mode: mode, delta_max: delta }),
|
||||
});
|
||||
setConfigDirty(false);
|
||||
} catch (e) {}
|
||||
}, []);
|
||||
|
||||
// Fetch initial history when backend becomes reachable
|
||||
const fetchHistory = useCallback(async () => {
|
||||
try {
|
||||
@@ -54,7 +83,7 @@ export default function App() {
|
||||
if (msg.closed) {
|
||||
setHistory(prev => {
|
||||
const updated = [...prev, msg.closed];
|
||||
if (updated.length > 50) {
|
||||
if (updated.length > 500) {
|
||||
updated.shift();
|
||||
}
|
||||
return updated;
|
||||
@@ -70,7 +99,8 @@ export default function App() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistory();
|
||||
}, [fetchHistory, wsStatus]);
|
||||
fetchConfig();
|
||||
}, [fetchHistory, fetchConfig, wsStatus]);
|
||||
|
||||
// Aggregate stats from history
|
||||
const totalVolume = history.reduce((acc, c) => acc + (c.total_volume || 0), 0) + (activeCluster?.total_volume || 0);
|
||||
@@ -78,7 +108,10 @@ export default function App() {
|
||||
|
||||
const allClusters = [...history];
|
||||
if (activeCluster) allClusters.push(activeCluster);
|
||||
|
||||
|
||||
// Use tick_size from the latest cluster data (set by MT5 backend)
|
||||
const dynamicTickSize = allClusters.length > 0 ? (allClusters[allClusters.length - 1].tick_size || 0.01) : 0.01;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-[#0B0E14] text-slate-100 relative">
|
||||
{/* Toast Container */}
|
||||
@@ -139,12 +172,13 @@ export default function App() {
|
||||
<main className="flex-1 flex overflow-hidden p-6 gap-6">
|
||||
{/* Footprint Chart Panel */}
|
||||
<div className="flex-1 flex flex-col h-full bg-[#151B26]/30 rounded-xl overflow-hidden">
|
||||
<FootprintCanvas
|
||||
clusters={allClusters}
|
||||
tickSize={0.00001}
|
||||
<FootprintCanvas
|
||||
clusters={allClusters}
|
||||
tickSize={dynamicTickSize}
|
||||
stepMultiplier={stepMultiplier}
|
||||
viewMode={viewMode}
|
||||
imbalanceRatio={imbalanceRatio}
|
||||
onStepChange={setStepMultiplier}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -160,7 +194,7 @@ export default function App() {
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-slate-400">Target Symbol:</span>
|
||||
<span className="font-mono font-semibold text-[#00B0FF] bg-[#00B0FF]/10 px-2 py-0.5 rounded">
|
||||
EURUSD
|
||||
USTEC
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-6">
|
||||
@@ -181,9 +215,9 @@ export default function App() {
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-slate-500 font-bold mb-1">PRICE STEP</span>
|
||||
<div className="flex items-center bg-[#151B26] border border-slate-700 rounded-md overflow-hidden">
|
||||
<button onClick={() => setStepMultiplier(Math.max(1, stepMultiplier - 1))} className="px-2 py-1 text-slate-400 hover:text-white hover:bg-slate-800">-</button>
|
||||
<div className="px-3 py-1 text-sm font-bold text-white min-w-[30px] text-center">{stepMultiplier}</div>
|
||||
<button onClick={() => setStepMultiplier(stepMultiplier + 1)} className="px-2 py-1 text-slate-400 hover:text-white hover:bg-slate-800">+</button>
|
||||
<button onClick={() => setStepMultiplier(s => Math.max(25, s - 25))} className="px-2 py-1 text-slate-400 hover:text-white hover:bg-slate-800">-</button>
|
||||
<div className="px-3 py-1 text-sm font-bold text-white min-w-[52px] text-center">{(stepMultiplier * dynamicTickSize).toFixed(2)} pts</div>
|
||||
<button onClick={() => setStepMultiplier(s => s + 25)} className="px-2 py-1 text-slate-400 hover:text-white hover:bg-slate-800">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -231,6 +265,56 @@ export default function App() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Cluster Formation Config */}
|
||||
<section className="bg-[#151B26] border border-slate-800 rounded-xl p-5 shadow-lg">
|
||||
<h2 className="text-sm font-semibold text-slate-300 mb-4 border-b border-slate-800 pb-2">
|
||||
Cluster Formation
|
||||
</h2>
|
||||
<div className="space-y-4 text-xs">
|
||||
{/* Close mode */}
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-500 font-bold block mb-1">CLOSE MODE</span>
|
||||
<div className="flex bg-[#0B0E14] border border-slate-700 rounded-md overflow-hidden">
|
||||
{['delta', 'range', 'time', 'volume'].map(m => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => { setCloseMode(m); setConfigDirty(true); }}
|
||||
className={`flex-1 py-1 text-[10px] font-semibold uppercase ${closeMode === m ? 'bg-[#00E676] text-slate-900' : 'text-slate-400 hover:bg-slate-800'}`}
|
||||
>{m}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delta threshold — only relevant when mode=delta */}
|
||||
{closeMode === 'delta' && (
|
||||
<div>
|
||||
<div className="flex justify-between mb-1">
|
||||
<span className="text-[10px] text-slate-500 font-bold">DELTA THRESHOLD</span>
|
||||
<span className="text-[10px] font-mono text-slate-300">{deltaMax.toLocaleString()}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range" min="100" max="10000" step="50"
|
||||
value={deltaMax}
|
||||
onChange={e => { setDeltaMax(Number(e.target.value)); setConfigDirty(true); }}
|
||||
className="w-full accent-[#00E676] bg-slate-800 rounded-lg h-1.5 appearance-none cursor-pointer"
|
||||
/>
|
||||
<div className="flex justify-between text-[9px] text-slate-600 mt-0.5">
|
||||
<span>100</span><span>10 000</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Apply button */}
|
||||
<button
|
||||
onClick={() => applyConfig(closeMode, deltaMax)}
|
||||
disabled={!configDirty}
|
||||
className={`w-full py-1.5 rounded-md text-xs font-semibold transition ${configDirty ? 'bg-[#00E676] text-slate-900 hover:bg-[#00c853]' : 'bg-slate-800 text-slate-600 cursor-not-allowed'}`}
|
||||
>
|
||||
{configDirty ? 'Apply' : 'Applied'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Aggregator Settings Rules Indicator */}
|
||||
<section className="bg-[#151B26] border border-slate-800 rounded-xl p-5 shadow-lg flex-1">
|
||||
<h2 className="text-sm font-semibold text-slate-300 mb-4 border-b border-slate-800 pb-2">
|
||||
|
||||
+242
-200
@@ -1,14 +1,25 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
|
||||
export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultiplier = 1, viewMode = 'bidask', imbalanceRatio = 300 }) {
|
||||
export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultiplier = 1, viewMode = 'bidask', imbalanceRatio = 300, onStepChange }) {
|
||||
const canvasRef = useRef(null);
|
||||
|
||||
|
||||
// Navigation & Scale State
|
||||
const [scrollOffset, setScrollOffset] = useState({ x: 50, y: 0 }); // X: horizontal offset, Y: vertical offset
|
||||
const [zoom, setZoom] = useState(1); // Zoom level
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [cursor, setCursor] = useState('grab');
|
||||
const dragStart = useRef({ x: 0, y: 0 });
|
||||
const dragOffsetStart = useRef({ x: 0, y: 0 });
|
||||
|
||||
// Price axis drag (scale) state
|
||||
const isDraggingAxis = useRef(false);
|
||||
const axisDragStartY = useRef(0);
|
||||
const axisDragStartStep = useRef(0);
|
||||
|
||||
// Time axis drag (horizontal zoom) state
|
||||
const isDraggingTimeAxis = useRef(false);
|
||||
const timeAxisDragStartX = useRef(0);
|
||||
const timeAxisDragStartZoom = useRef(1);
|
||||
|
||||
// Apply zoom to sizes
|
||||
const colWidth = 140 * zoom; // width of each cluster column
|
||||
@@ -16,17 +27,20 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
|
||||
const rowHeight = 26 * zoom; // height of each price cell
|
||||
const axisWidth = 70; // width of the vertical price axis on the right
|
||||
|
||||
// Handle auto-scroll to the right (most recent cluster) on new cluster load
|
||||
// Auto-scroll to keep latest cluster visible whenever clusters or zoom changes
|
||||
const lastClusterCount = useRef(0);
|
||||
useEffect(() => {
|
||||
if (clusters && clusters.length > lastClusterCount.current && canvasRef.current) {
|
||||
if (clusters && canvasRef.current) {
|
||||
const canvas = canvasRef.current;
|
||||
// Scroll to show the active cluster at the right side
|
||||
const rightmostX = canvas.width - axisWidth - (clusters.length * (colWidth + colGap)) - 50;
|
||||
setScrollOffset(prev => ({ ...prev, x: Math.min(160, rightmostX) }));
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const visibleWidth = rect.width - axisWidth - 160; // leave space for vol profile
|
||||
const totalClustersWidth = clusters.length * (colWidth + colGap);
|
||||
// Pin latest cluster to right side of visible area
|
||||
const newX = visibleWidth - totalClustersWidth;
|
||||
setScrollOffset(prev => ({ ...prev, x: newX }));
|
||||
lastClusterCount.current = clusters.length;
|
||||
}
|
||||
}, [clusters?.length]);
|
||||
}, [clusters?.length, zoom]);
|
||||
|
||||
// Main Render Loop
|
||||
useEffect(() => {
|
||||
@@ -96,7 +110,30 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
|
||||
};
|
||||
|
||||
// Cumulative Delta Tracking
|
||||
let cumulativeDelta = 0;
|
||||
// Max volume across all clusters (for proportional volume bars in bottom panel)
|
||||
const maxClusterVolume = Math.max(...clusters.map(c => c.total_volume || 1), 1);
|
||||
|
||||
// Bottom panel layout
|
||||
const bottomPanelH = bottomPanelHeight;
|
||||
const volRowH = Math.round(bottomPanelH * 0.55);
|
||||
const deltaRowH = bottomPanelH - volRowH;
|
||||
|
||||
// Draw bottom panel background BEFORE cluster loop so bars render on top
|
||||
const panelBaseY = height - bottomPanelH;
|
||||
ctx.fillStyle = '#0B0E14';
|
||||
ctx.fillRect(0, panelBaseY, width, bottomPanelH);
|
||||
ctx.strokeStyle = '#1E293B';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, panelBaseY);
|
||||
ctx.lineTo(width, panelBaseY);
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = 'rgba(30, 41, 59, 0.8)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, panelBaseY + volRowH);
|
||||
ctx.lineTo(width, panelBaseY + volRowH);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw Columns (Clusters)
|
||||
clusters.forEach((cluster, index) => {
|
||||
@@ -174,165 +211,101 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
|
||||
}
|
||||
}
|
||||
|
||||
// Draw Cluster Header (Info Card at the top)
|
||||
const headerY = getPriceY(highestPrice) - rowHeight - 35;
|
||||
|
||||
// Header Background
|
||||
ctx.fillStyle = 'rgba(21, 27, 38, 0.85)';
|
||||
ctx.strokeStyle = cluster.status === 'active' ? 'rgba(0, 230, 118, 0.4)' : '#2A364F';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(colX, headerY, colWidth, 45, 6);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
// Header Text
|
||||
ctx.fillStyle = '#94A3B8';
|
||||
ctx.font = '10px JetBrains Mono, monospace';
|
||||
ctx.textAlign = 'left';
|
||||
|
||||
const pattern = cluster.advanced_metrics?.pattern;
|
||||
const divergence = cluster.advanced_metrics?.delta_divergence;
|
||||
|
||||
let patternTag = '';
|
||||
if (pattern === 'P') patternTag = '[P] ';
|
||||
if (pattern === 'B') patternTag = '[B] ';
|
||||
|
||||
// Divergence Tag
|
||||
if (divergence) patternTag += '⚠️ ';
|
||||
|
||||
// Volume & Delta
|
||||
const volK = (cluster.total_volume || 0).toFixed(0);
|
||||
const deltaStr = (cluster.total_delta >= 0 ? '+' : '') + (cluster.total_delta || 0).toFixed(0);
|
||||
|
||||
ctx.fillText(`${patternTag}VOL: ${volK}`, colX + 8, headerY + 18);
|
||||
ctx.fillStyle = cluster.total_delta >= 0 ? '#00E676' : '#FF1744';
|
||||
ctx.fillText(`DEL: ${deltaStr}`, colX + 8, headerY + 32);
|
||||
// OHLC body range — levels outside = wicks (just a line)
|
||||
const bodyHigh = (cluster.open_price !== undefined && cluster.close_price !== undefined)
|
||||
? Math.max(cluster.open_price, cluster.close_price)
|
||||
: highestPrice;
|
||||
const bodyLow = (cluster.open_price !== undefined && cluster.close_price !== undefined)
|
||||
? Math.min(cluster.open_price, cluster.close_price)
|
||||
: lowestPrice;
|
||||
const isBull = cluster.close_price >= cluster.open_price;
|
||||
|
||||
// Time or reason
|
||||
ctx.fillStyle = '#64748B';
|
||||
const timeStr = cluster.open_time ? new Date(cluster.open_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '--:--:--';
|
||||
ctx.fillText(timeStr, colX + colWidth - 55, headerY + 18);
|
||||
|
||||
// Find max volume in this cluster to calculate relative opacities
|
||||
const maxTotalVolumeInCluster = Math.max(...Object.values(levels).map(l => l.total || 1));
|
||||
|
||||
// OHLC Candlestick Skeleton (Background)
|
||||
// OHLC body border rectangle
|
||||
if (cluster.open_price !== undefined && cluster.close_price !== undefined) {
|
||||
const isBull = cluster.close_price >= cluster.open_price;
|
||||
ctx.strokeStyle = isBull ? 'rgba(0, 230, 118, 0.4)' : 'rgba(255, 23, 68, 0.4)';
|
||||
ctx.fillStyle = isBull ? 'rgba(0, 230, 118, 0.1)' : 'rgba(255, 23, 68, 0.1)';
|
||||
ctx.lineWidth = 1;
|
||||
const openY = getPriceY(cluster.open_price);
|
||||
const closeY = getPriceY(cluster.close_price);
|
||||
const highY = getPriceY(highestPrice);
|
||||
const lowY = getPriceY(lowestPrice);
|
||||
|
||||
const candleTop = Math.min(openY, closeY) - rowHeight / 2;
|
||||
const candleBottom = Math.max(openY, closeY) + rowHeight / 2;
|
||||
const bodyHeight = Math.max(2, candleBottom - candleTop);
|
||||
|
||||
// Draw Wick (Pavio)
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(colX + colWidth / 2, highY - rowHeight / 2);
|
||||
ctx.lineTo(colX + colWidth / 2, lowY + rowHeight / 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw Body Background
|
||||
ctx.fillRect(colX - 4, candleTop, colWidth + 8, bodyHeight);
|
||||
ctx.strokeRect(colX - 4, candleTop, colWidth + 8, bodyHeight);
|
||||
const openY = getPriceY(cluster.open_price);
|
||||
const closeY = getPriceY(cluster.close_price);
|
||||
const candleTop = Math.min(openY, closeY) - rowHeight / 2;
|
||||
const candleBottom = Math.max(openY, closeY) + rowHeight / 2;
|
||||
ctx.strokeStyle = isBull ? 'rgba(0, 230, 118, 0.55)' : 'rgba(255, 23, 68, 0.55)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeRect(colX - 2, candleTop, colWidth + 4, Math.max(2, candleBottom - candleTop));
|
||||
|
||||
// Wick lines (thin center line above body to high, below body to low)
|
||||
const highY = getPriceY(highestPrice);
|
||||
const lowY = getPriceY(lowestPrice);
|
||||
ctx.strokeStyle = isBull ? 'rgba(0, 230, 118, 0.4)' : 'rgba(255, 23, 68, 0.4)';
|
||||
ctx.lineWidth = 1;
|
||||
if (highestPrice > bodyHigh) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(colX + colWidth / 2, highY);
|
||||
ctx.lineTo(colX + colWidth / 2, candleTop);
|
||||
ctx.stroke();
|
||||
}
|
||||
if (lowestPrice < bodyLow) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(colX + colWidth / 2, candleBottom);
|
||||
ctx.lineTo(colX + colWidth / 2, lowY + rowHeight);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// Draw Cells
|
||||
const fmtK = (v) => {
|
||||
const n = Math.abs(v);
|
||||
if (n >= 1000) return (v / 1000).toFixed(1) + 'K';
|
||||
return v.toFixed(0);
|
||||
};
|
||||
|
||||
// Max volume in cluster for proportional bars
|
||||
const maxVol = Math.max(...Object.values(levels).map(l => Math.max(l.ask || 0, l.bid || 0)), 1);
|
||||
|
||||
const fontSize = Math.max(6, 11 * zoom);
|
||||
ctx.font = `bold ${fontSize}px JetBrains Mono, monospace`;
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
// Draw Cells — one color per level (dominant side), number only at POC
|
||||
pricesStr.forEach((priceStr, i) => {
|
||||
const price = Number(priceStr);
|
||||
const cellData = levels[priceStr];
|
||||
|
||||
const cellY = getPriceY(price) - rowHeight / 2;
|
||||
|
||||
// Skip if vertically out of bounds
|
||||
if (cellY + rowHeight < 0 || cellY > height) return;
|
||||
|
||||
const isWick = price < bodyLow - actualTickSize * 0.5 || price > bodyHigh + actualTickSize * 0.5;
|
||||
const alpha = isWick ? 0.4 : 0.85;
|
||||
|
||||
const bid = cellData.bid || 0;
|
||||
const ask = cellData.ask || 0;
|
||||
const total = cellData.total || 0;
|
||||
|
||||
// Dynamic Imbalance Calculation (Frontend)
|
||||
const ratio = imbalanceRatio / 100.0;
|
||||
let dynImbalance = null;
|
||||
|
||||
// ask vs lower bid
|
||||
const lowerData = i + 1 < pricesStr.length ? levels[pricesStr[i + 1]] : null;
|
||||
const lowerBid = lowerData ? (lowerData.bid || 0) : 0;
|
||||
const isBuyImbalance = ask >= lowerBid * ratio && ask > 0;
|
||||
|
||||
// bid vs higher ask
|
||||
const upperData = i - 1 >= 0 ? levels[pricesStr[i - 1]] : null;
|
||||
const upperAsk = upperData ? (upperData.ask || 0) : 0;
|
||||
const isSellImbalance = bid >= upperAsk * ratio && bid > 0;
|
||||
|
||||
if (isBuyImbalance && isSellImbalance) dynImbalance = 'both';
|
||||
else if (isBuyImbalance) dynImbalance = 'buy';
|
||||
else if (isSellImbalance) dynImbalance = 'sell';
|
||||
const dominant = ask >= bid ? 'ask' : 'bid';
|
||||
const dominantVal = Math.max(ask, bid);
|
||||
|
||||
// Volume-based opacity
|
||||
const relOpacity = maxTotalVolumeInCluster > 0 ? (total / maxTotalVolumeInCluster) : 0;
|
||||
|
||||
// Base fill color with opacity
|
||||
let cellColor = `rgba(59, 130, 246, ${0.05 + relOpacity * 0.25})`; // Dark Slate Blue default
|
||||
if (ask >= bid * ratio && ask > 0) {
|
||||
cellColor = `rgba(0, 230, 118, ${0.2 + relOpacity * 0.5})`; // Strong Green Heatmap (Horizontal)
|
||||
} else if (bid >= ask * ratio && bid > 0) {
|
||||
cellColor = `rgba(255, 23, 68, ${0.2 + relOpacity * 0.5})`; // Strong Red Heatmap (Horizontal)
|
||||
} else if (dynImbalance === 'buy') {
|
||||
cellColor = `rgba(0, 230, 118, ${0.1 + relOpacity * 0.35})`; // Neon Green tint
|
||||
} else if (dynImbalance === 'sell') {
|
||||
cellColor = `rgba(255, 23, 68, ${0.1 + relOpacity * 0.35})`; // Neon Red tint
|
||||
} else if (dynImbalance === 'both') {
|
||||
cellColor = `rgba(168, 85, 247, ${0.1 + relOpacity * 0.35})`; // Purple
|
||||
}
|
||||
// Single proportional bar — dominant color only, grows left to right
|
||||
const barW = Math.min((dominantVal / maxVol) * colWidth, colWidth);
|
||||
ctx.fillStyle = dominant === 'ask'
|
||||
? `rgba(236, 72, 153, ${alpha})`
|
||||
: `rgba(59, 130, 246, ${alpha})`;
|
||||
ctx.fillRect(colX, cellY + 1, barW, rowHeight - 3);
|
||||
|
||||
ctx.fillStyle = cellColor;
|
||||
ctx.fillRect(colX, cellY, colWidth, rowHeight - 2);
|
||||
|
||||
// Imbalance border outline
|
||||
if (dynImbalance === 'buy') {
|
||||
ctx.strokeStyle = 'rgba(0, 230, 118, 0.8)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(colX + 0.5, cellY + 0.5, colWidth - 1, rowHeight - 3);
|
||||
} else if (dynImbalance === 'sell') {
|
||||
ctx.strokeStyle = 'rgba(255, 23, 68, 0.8)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(colX + 0.5, cellY + 0.5, colWidth - 1, rowHeight - 3);
|
||||
}
|
||||
|
||||
// Draw POC outline (Thick Gold Border)
|
||||
if (price === cluster.poc) {
|
||||
// POC border (rectangle only at POC level)
|
||||
if (!isWick && price === cluster.poc) {
|
||||
ctx.strokeStyle = '#FFD600';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(colX + 1, cellY + 1, colWidth - 2, rowHeight - 4);
|
||||
}
|
||||
|
||||
// Draw Text (Bid x Ask OR Delta)
|
||||
const fontSize = Math.max(6, 11 * zoom);
|
||||
ctx.font = `${fontSize}px JetBrains Mono, monospace`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
if (dynImbalance === 'buy') {
|
||||
ctx.fillStyle = '#00E676';
|
||||
} else if (dynImbalance === 'sell') {
|
||||
ctx.fillStyle = '#FF1744';
|
||||
} else {
|
||||
ctx.fillStyle = '#E2E8F0';
|
||||
}
|
||||
|
||||
// Hide text if zoomed out too much to avoid clutter
|
||||
if (zoom >= 0.5) {
|
||||
// Number only at POC (inside bar, right-aligned)
|
||||
if (zoom >= 0.5 && !isWick && price === cluster.poc) {
|
||||
const cy = cellY + rowHeight / 2;
|
||||
if (viewMode === 'delta') {
|
||||
const deltaStr = (cellData.delta >= 0 ? '+' : '') + (cellData.delta || 0).toFixed(0);
|
||||
ctx.fillText(deltaStr, colX + colWidth / 2, cellY + rowHeight / 2);
|
||||
const d = cellData.delta || 0;
|
||||
ctx.fillStyle = d >= 0 ? '#00E676' : '#FF1744';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText((d >= 0 ? '+' : '') + fmtK(d), colX + colWidth - 4, cy);
|
||||
} else {
|
||||
ctx.fillText(`${bid.toFixed(0)} × ${ask.toFixed(0)}`, colX + colWidth / 2, cellY + rowHeight / 2);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.95)';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(fmtK(dominantVal), colX + colWidth - 4, cy);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -384,28 +357,45 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate Cumulative Delta for bottom panel
|
||||
cumulativeDelta += (cluster.total_delta || 0);
|
||||
|
||||
// Bottom Panel (Delta Histogram)
|
||||
const panelY = height - bottomPanelHeight;
|
||||
const cvdBaseline = panelY + bottomPanelHeight / 2;
|
||||
|
||||
// Delta cluster bar
|
||||
const deltaVol = cluster.total_delta || 0;
|
||||
const deltaColor = deltaVol >= 0 ? 'rgba(0, 230, 118, 0.7)' : 'rgba(255, 23, 68, 0.7)';
|
||||
ctx.fillStyle = deltaColor;
|
||||
|
||||
// Scale: 1000 volume = 20px
|
||||
const scaleFactor = 30 / 1000;
|
||||
const barH = Math.min(Math.abs(deltaVol) * scaleFactor, bottomPanelHeight / 2 - 5);
|
||||
const startY = deltaVol >= 0 ? cvdBaseline - barH : cvdBaseline;
|
||||
ctx.fillRect(colX + 5, startY, colWidth - 10, barH);
|
||||
|
||||
// CVD line (Cumulative Delta)
|
||||
ctx.fillStyle = cumulativeDelta >= 0 ? '#00E676' : '#FF1744';
|
||||
ctx.font = '10px JetBrains Mono, monospace';
|
||||
ctx.fillText(`CVD: ${cumulativeDelta.toFixed(0)}`, colX + colWidth / 2, panelY + 15);
|
||||
// Bottom Panel — two rows: Volume (top) + Delta (bottom)
|
||||
const panelY = height - bottomPanelH;
|
||||
|
||||
// --- Volume row ---
|
||||
const totalVol = cluster.total_volume || 0;
|
||||
const volBarMaxH = volRowH - 16;
|
||||
const volBarH = Math.max(3, (totalVol / maxClusterVolume) * volBarMaxH);
|
||||
const volBarColor = (cluster.total_delta || 0) >= 0 ? 'rgba(30, 80, 150, 0.9)' : 'rgba(110, 30, 50, 0.9)';
|
||||
ctx.fillStyle = volBarColor;
|
||||
ctx.fillRect(colX + 2, panelY + volRowH - volBarH, colWidth - 4, volBarH);
|
||||
|
||||
// Volume label above the bar
|
||||
const volLabel = totalVol >= 1000 ? (totalVol / 1000).toFixed(1) + 'K' : totalVol.toFixed(0);
|
||||
ctx.fillStyle = '#CBD5E1';
|
||||
ctx.font = 'bold 12px JetBrains Mono, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillText(volLabel, colX + colWidth / 2, panelY + volRowH - volBarH - 2);
|
||||
|
||||
// --- Delta row ---
|
||||
const delta = cluster.total_delta || 0;
|
||||
ctx.fillStyle = delta >= 0 ? '#1565C0' : '#C84B00';
|
||||
ctx.fillRect(colX + 1, panelY + volRowH + 1, colWidth - 2, deltaRowH - 2);
|
||||
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = 'bold 12px JetBrains Mono, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(delta.toFixed(0), colX + colWidth / 2, panelY + volRowH + deltaRowH / 2);
|
||||
|
||||
// Timestamp on X axis
|
||||
if (cluster.open_time) {
|
||||
const timeStr = new Date(cluster.open_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.font = '9px JetBrains Mono, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(timeStr, colX + colWidth / 2, height - 14);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@@ -534,6 +524,36 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
|
||||
ctx.fillText('VOL PROFILE', volProfileWidth / 2, 20);
|
||||
}
|
||||
|
||||
// Current Price Line (latest close_price from active cluster)
|
||||
const currentPrice = latestCluster.close_price;
|
||||
if (currentPrice) {
|
||||
const priceY = getPriceY(currentPrice);
|
||||
if (priceY >= 0 && priceY <= chartHeight) {
|
||||
// Horizontal line across chart (skip volume profile area)
|
||||
ctx.strokeStyle = 'rgba(0, 229, 255, 0.85)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([6, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(volProfileWidth, priceY);
|
||||
ctx.lineTo(width - axisWidth, priceY);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Price tag on axis
|
||||
const decimals = tickSize < 1 ? Math.max(0, -Math.floor(Math.log10(tickSize))) : 2;
|
||||
const priceLabel = currentPrice.toFixed(decimals);
|
||||
const tagH = 18;
|
||||
const tagW = axisWidth - 4;
|
||||
ctx.fillStyle = '#00E5FF';
|
||||
ctx.fillRect(width - axisWidth + 2, priceY - tagH / 2, tagW, tagH);
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.font = 'bold 10px JetBrains Mono, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(priceLabel, width - axisWidth + 2 + tagW / 2, priceY);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw Vertical Price Axis (Right Side)
|
||||
ctx.fillStyle = 'rgba(11, 14, 20, 0.95)';
|
||||
ctx.fillRect(width - axisWidth, 0, axisWidth, chartHeight);
|
||||
@@ -575,34 +595,6 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
|
||||
ctx.fillText(`${roundedPrice.toFixed(decimals)}`, width - axisWidth + 8, labelY);
|
||||
}
|
||||
|
||||
// Bottom Panel separator
|
||||
const panelY = height - bottomPanelHeight;
|
||||
ctx.fillStyle = '#111827';
|
||||
ctx.fillRect(0, panelY, width, bottomPanelHeight);
|
||||
|
||||
ctx.strokeStyle = '#1E293B';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, panelY);
|
||||
ctx.lineTo(width, panelY);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw CVD zero line
|
||||
const cvdBaseline = panelY + bottomPanelHeight / 2;
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([5, 5]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, cvdBaseline);
|
||||
ctx.lineTo(width, cvdBaseline);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// CVD Label
|
||||
ctx.fillStyle = '#94A3B8';
|
||||
ctx.font = '12px Outfit, sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText('CVD / Delta', width - 20, panelY + 20);
|
||||
|
||||
}, [clusters, scrollOffset]);
|
||||
|
||||
@@ -611,21 +603,68 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
|
||||
// Clicking on price axis → enter scale drag mode
|
||||
if (x > rect.width - axisWidth) {
|
||||
isDraggingAxis.current = true;
|
||||
axisDragStartY.current = y;
|
||||
axisDragStartStep.current = stepMultiplier;
|
||||
setCursor('ns-resize');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clicking on time axis (bottom panel) → horizontal zoom drag
|
||||
const chartH = rect.height - 100;
|
||||
if (y > chartH) {
|
||||
isDraggingTimeAxis.current = true;
|
||||
timeAxisDragStartX.current = x;
|
||||
timeAxisDragStartZoom.current = zoom;
|
||||
setCursor('ew-resize');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDragging(true);
|
||||
dragStart.current = { x, y };
|
||||
dragOffsetStart.current = { ...scrollOffset };
|
||||
setCursor('grabbing');
|
||||
};
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (!isDragging) return;
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
|
||||
// Cursor hover logic
|
||||
if (!isDragging && !isDraggingAxis.current && !isDraggingTimeAxis.current) {
|
||||
const chartH = rect.height - 100;
|
||||
if (x > rect.width - axisWidth) setCursor('ns-resize');
|
||||
else if (y > chartH) setCursor('ew-resize');
|
||||
else setCursor('grab');
|
||||
}
|
||||
|
||||
// Price axis drag → adjust stepMultiplier
|
||||
if (isDraggingAxis.current && onStepChange) {
|
||||
const dy = axisDragStartY.current - y;
|
||||
const sensitivity = 4;
|
||||
const newStep = Math.max(25, Math.round((axisDragStartStep.current + dy * sensitivity) / 25) * 25);
|
||||
onStepChange(newStep);
|
||||
return;
|
||||
}
|
||||
|
||||
// Time axis drag → adjust horizontal zoom
|
||||
if (isDraggingTimeAxis.current) {
|
||||
const dx = x - timeAxisDragStartX.current;
|
||||
const sensitivity = 0.005;
|
||||
const newZoom = Math.max(0.1, Math.min(3.0, timeAxisDragStartZoom.current + dx * sensitivity));
|
||||
setZoom(newZoom);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDragging) return;
|
||||
|
||||
const dx = x - dragStart.current.x;
|
||||
const dy = y - dragStart.current.y;
|
||||
|
||||
|
||||
setScrollOffset({
|
||||
x: dragOffsetStart.current.x + dx,
|
||||
y: dragOffsetStart.current.y + dy
|
||||
@@ -634,6 +673,9 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
isDraggingAxis.current = false;
|
||||
isDraggingTimeAxis.current = false;
|
||||
setCursor('grab');
|
||||
};
|
||||
|
||||
// Wheel interaction for scrolling and zooming
|
||||
@@ -656,7 +698,7 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full cursor-grab active:cursor-grabbing select-none overflow-hidden rounded-xl border border-slate-800 bg-darkBg shadow-2xl">
|
||||
<div className="relative w-full h-full select-none overflow-hidden rounded-xl border border-slate-800 bg-darkBg shadow-2xl" style={{ cursor }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full block"
|
||||
|
||||
@@ -22,10 +22,15 @@ class AlertEngine {
|
||||
processClusters(clusters, pushToast) {
|
||||
if (!clusters || clusters.length === 0) return;
|
||||
|
||||
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;
|
||||
|
||||
clusters.forEach(cluster => {
|
||||
// Only alert on closed clusters that we haven't processed yet
|
||||
if (cluster.status === 'closed' && cluster.open_time && !this.processedClusters.has(cluster.open_time)) {
|
||||
this.processedClusters.add(cluster.open_time);
|
||||
|
||||
// Skip historical clusters — only alert for recent data
|
||||
if (cluster.open_time < fiveMinutesAgo) return;
|
||||
|
||||
const adv = cluster.advanced_metrics;
|
||||
if (!adv) return;
|
||||
|
||||
Reference in New Issue
Block a user