diff --git a/backend/aggregator.py b/backend/aggregator.py index a28ba23..f0dc4c1 100644 --- a/backend/aggregator.py +++ b/backend/aggregator.py @@ -17,6 +17,7 @@ class FootprintCluster: self.poc: Optional[float] = None self.total_delta: float = 0.0 self.total_volume: float = 0.0 + self.total_ticks: int = 0 # levels: price_float -> { 'ask': float, 'bid': float } # internally we keep float keys to make sorting and arithmetic easy @@ -39,7 +40,8 @@ class FootprintCluster: self.open_price = price self.close_price = price - + self.total_ticks += 1 + # Round price to the nearest tick_size to avoid float precision issues rounded_price = round(price / self.tick_size) * self.tick_size @@ -259,29 +261,32 @@ class FootprintCluster: self.advanced_metrics["delta_divergence"] = True def should_close(self, current_time_msc: int) -> Optional[str]: - # Check range - if self.high is not None and self.low is not None: - points = self.high - self.low - # If tick_size is 1.0, range is in points. Let's compare directly. - # (or points/tick_size >= CLUSTER_RANGE_POINTS) - # Standard MT5 points. If range in points exceeds setting: - if points >= settings.CLUSTER_RANGE_POINTS: - return "range" - - # Check volume + mode = settings.CLUSTER_CLOSE_MODE + + # Volume safety cap — always applied (prevents runaway cluster if market halts) if self.total_volume >= settings.CLUSTER_VOLUME_MAX: return "volume" - - # Check delta - if abs(self.total_delta) >= settings.CLUSTER_DELTA_MAX: - return "delta" - - # Check time - if self.open_time is not None: - elapsed = (current_time_msc - self.open_time) / 1000.0 - if elapsed >= settings.CLUSTER_TIME_SECONDS: - return "time" - + + # Primary closing condition based on configured mode + if mode == "delta": + if abs(self.total_delta) >= settings.CLUSTER_DELTA_MAX: + return "delta" + + elif mode == "range": + if self.high is not None and self.low is not None: + if (self.high - self.low) >= settings.CLUSTER_RANGE_POINTS: + return "range" + + elif mode == "time": + if self.open_time is not None: + elapsed = (current_time_msc - self.open_time) / 1000.0 + if elapsed >= settings.CLUSTER_TIME_SECONDS: + return "time" + + elif mode == "volume": + if self.total_volume >= settings.CLUSTER_VOLUME_MAX: + return "volume" + return None def close(self, reason: str) -> None: @@ -313,6 +318,7 @@ class FootprintCluster: "poc": float(self.poc) if self.poc is not None else None, "total_delta": float(self.total_delta), "total_volume": float(self.total_volume), + "total_ticks": int(self.total_ticks), "levels": levels_str, "stacked": { "buy": bool(self.stacked.get("buy", False)), diff --git a/backend/mt5_collector.py b/backend/mt5_collector.py index 6e086a3..87d8c00 100644 --- a/backend/mt5_collector.py +++ b/backend/mt5_collector.py @@ -23,6 +23,8 @@ class MT5Collector: self.connected = False self.last_tick_time_msc = 0 self.seen_ticks_buffer = set() + self.last_mid_price = 0.0 + self.last_is_buy = True async def connect_mt5(self) -> bool: """ @@ -30,8 +32,8 @@ class MT5Collector: All MT5 calls are blocking, so they run in a thread executor. """ try: - mt5_path = r"C:\Program Files\MetaTrader 5\terminal64.exe" - initialized = await asyncio.to_thread(mt5.initialize, path=mt5_path) + # Try to connect to any running MT5 terminal without specifying path + initialized = await asyncio.to_thread(mt5.initialize) if not initialized: err = await asyncio.to_thread(mt5.last_error) logger.error(f"MT5 initialize failed: {err}") @@ -98,9 +100,9 @@ class MT5Collector: else: backoff = 1.0 - # Fetch from 6 hours ago so the chart isn't empty when started + # Fetch from 48 hours ago so the chart isn't empty when started (covers weekends) from datetime import datetime, timedelta - start_time_dt = datetime.now() - timedelta(hours=6) + start_time_dt = datetime.now() - timedelta(hours=48) ticks = await asyncio.to_thread( mt5.copy_ticks_from, self.symbol, start_time_dt, 100000, mt5.COPY_TICKS_ALL ) @@ -146,20 +148,49 @@ class MT5Collector: self.seen_ticks_buffer.add(tick_id) - price = tick['last'] if tick['last'] > 0 else (tick['bid'] if tick['bid'] > 0 else tick['ask']) - volume = tick['volume_real'] if tick['volume_real'] > 0 else float(tick['volume']) - flags = tick['flags'] + flags = int(tick['flags']) + bid_price = float(tick['bid']) + ask_price = float(tick['ask']) + last_price = float(tick['last']) - # Forex ticks often have volume=0 (they are just quote updates). - # We count each quote update as 1 unit of tick volume to build the footprint. - if volume == 0: + mid_price = (bid_price + ask_price) / 2.0 if (bid_price > 0 and ask_price > 0) else 0.0 + prev_mid = self.last_mid_price + + # Update direction tracker from mid movement + if mid_price > 0: + if mid_price > self.last_mid_price: + self.last_is_buy = True + elif mid_price < self.last_mid_price: + self.last_is_buy = False + self.last_mid_price = mid_price + + # Determine price (use mid as best proxy for CFD quote feed) + price = last_price if last_price > 0 else (mid_price if mid_price > 0 else (bid_price if bid_price > 0 else ask_price)) + + # Volume = price movement in tick-size units (how the YuCluster measures activity) + tick_sz = self.aggregator.tick_size if self.aggregator.tick_size > 0 else 0.01 + if prev_mid > 0 and mid_price > 0: + price_steps = abs(mid_price - prev_mid) / tick_sz + volume = max(price_steps, 1.0) + else: volume = 1.0 - is_buy = classify_tick(price, tick['bid'], tick['ask'], flags) + # Determine direction + if flags & 32: + is_buy = True + elif flags & 64: + is_buy = False + else: + is_buy = self.last_is_buy active_json, closed_json = self.aggregator.process_tick(price, volume, is_buy, msc) - self.on_update_callback(active_json, closed_json) + # Only broadcast during live trading (within 10s of now) to avoid + # flooding the WebSocket during historical replay + import time as _time + is_live = ((_time.time() * 1000) - msc) < 10_000 + if is_live: + self.on_update_callback(active_json, closed_json) await asyncio.sleep(0.1) diff --git a/backend/server.py b/backend/server.py index b116c94..68dc6d6 100644 --- a/backend/server.py +++ b/backend/server.py @@ -5,6 +5,7 @@ import os from contextlib import asynccontextmanager from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel from typing import Set, Dict, Any, Optional # Add project root to sys.path @@ -25,18 +26,23 @@ collector_task: Optional[asyncio.Task] = None def broadcast_update(active_json: dict, closed_json: Optional[dict]): """ Callback executed by MT5Collector when a new tick is processed. - Schedules WebSocket sends on the event loop. + Only broadcasts when a cluster closes (not every tick) to avoid flooding during replay. + Removes dead connections automatically. """ + if not active_connections or closed_json is None: + return message = { "type": "tick", "active": active_json, "closed": closed_json } - for connection in list(active_connections): + async def safe_send(ws: WebSocket, msg: dict): try: - asyncio.create_task(connection.send_json(message)) - except Exception as e: - logger.error(f"Error sending update to client: {e}") + await ws.send_json(msg) + except Exception: + active_connections.discard(ws) + for connection in list(active_connections): + asyncio.create_task(safe_send(connection, message)) # Initialize MT5 Collector collector = MT5Collector(aggregator, on_update_callback=broadcast_update) @@ -75,6 +81,38 @@ app.add_middleware( allow_headers=["*"], ) +class ConfigUpdate(BaseModel): + close_mode: Optional[str] = None + delta_max: Optional[float] = None + volume_max: Optional[float] = None + range_points: Optional[float] = None + time_seconds: Optional[float] = None + +@app.get("/config") +async def get_config(): + return { + "close_mode": settings.CLUSTER_CLOSE_MODE, + "delta_max": settings.CLUSTER_DELTA_MAX, + "volume_max": settings.CLUSTER_VOLUME_MAX, + "range_points": settings.CLUSTER_RANGE_POINTS, + "time_seconds": settings.CLUSTER_TIME_SECONDS, + } + +@app.post("/config") +async def update_config(update: ConfigUpdate): + if update.close_mode is not None: + settings.CLUSTER_CLOSE_MODE = update.close_mode + if update.delta_max is not None: + settings.CLUSTER_DELTA_MAX = update.delta_max + if update.volume_max is not None: + settings.CLUSTER_VOLUME_MAX = update.volume_max + if update.range_points is not None: + settings.CLUSTER_RANGE_POINTS = update.range_points + if update.time_seconds is not None: + settings.CLUSTER_TIME_SECONDS = update.time_seconds + logger.info(f"Config updated: mode={settings.CLUSTER_CLOSE_MODE}, delta_max={settings.CLUSTER_DELTA_MAX}") + return {"ok": True} + @app.get("/history") async def get_history(): """Returns the buffer of historical closed clusters.""" diff --git a/config/settings.py b/config/settings.py index 89c6fa4..56425ef 100644 --- a/config/settings.py +++ b/config/settings.py @@ -4,14 +4,14 @@ import os MT5_LOGIN = int(os.environ.get("MT5_LOGIN", 0)) # seu número de conta MT5_PASSWORD = os.environ.get("MT5_PASSWORD", "") # sua senha MT5_SERVER = os.environ.get("MT5_SERVER", "") # nome do servidor (ex: "XPInvestimentos-Real") -MT5_SYMBOL = os.environ.get("MT5_SYMBOL", "EURUSD") # símbolo padrão +MT5_SYMBOL = os.environ.get("MT5_SYMBOL", "USTEC") # símbolo padrão # Cluster -CLUSTER_RANGE_POINTS = int(os.environ.get("CLUSTER_RANGE_POINTS", 10)) -CLUSTER_VOLUME_MAX = float(os.environ.get("CLUSTER_VOLUME_MAX", 1000)) -CLUSTER_DELTA_MAX = float(os.environ.get("CLUSTER_DELTA_MAX", 500)) -CLUSTER_TIME_SECONDS = float(os.environ.get("CLUSTER_TIME_SECONDS", 60)) -CLUSTER_CLOSE_MODE = os.environ.get("CLUSTER_CLOSE_MODE", "range") # "range" | "volume" | "delta" | "time" +CLUSTER_RANGE_POINTS = float(os.environ.get("CLUSTER_RANGE_POINTS", 50.0)) +CLUSTER_VOLUME_MAX = float(os.environ.get("CLUSTER_VOLUME_MAX", 100000)) +CLUSTER_DELTA_MAX = float(os.environ.get("CLUSTER_DELTA_MAX", 800)) +CLUSTER_TIME_SECONDS = float(os.environ.get("CLUSTER_TIME_SECONDS", 300)) +CLUSTER_CLOSE_MODE = os.environ.get("CLUSTER_CLOSE_MODE", "delta") # "range" | "volume" | "delta" | "time" # Imbalance IMBALANCE_RATIO = float(os.environ.get("IMBALANCE_RATIO", 3.0)) @@ -19,4 +19,4 @@ STACKED_MIN_COUNT = int(os.environ.get("STACKED_MIN_COUNT", 3)) # WebSocket WS_PORT = int(os.environ.get("WS_PORT", 6002)) -HISTORY_BUFFER_SIZE = int(os.environ.get("HISTORY_BUFFER_SIZE", 50)) +HISTORY_BUFFER_SIZE = int(os.environ.get("HISTORY_BUFFER_SIZE", 500)) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a802ea3..736d6be 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 (
{/* Toast Container */} @@ -139,12 +172,13 @@ export default function App() {
{/* Footprint Chart Panel */}
-
@@ -160,7 +194,7 @@ export default function App() {
Target Symbol: - EURUSD + USTEC
@@ -181,9 +215,9 @@ export default function App() {
PRICE STEP
- -
{stepMultiplier}
- + +
{(stepMultiplier * dynamicTickSize).toFixed(2)} pts
+
@@ -231,6 +265,56 @@ export default function App() {
+ {/* Cluster Formation Config */} +
+

+ Cluster Formation +

+
+ {/* Close mode */} +
+ CLOSE MODE +
+ {['delta', 'range', 'time', 'volume'].map(m => ( + + ))} +
+
+ + {/* Delta threshold — only relevant when mode=delta */} + {closeMode === 'delta' && ( +
+
+ DELTA THRESHOLD + {deltaMax.toLocaleString()} +
+ { setDeltaMax(Number(e.target.value)); setConfigDirty(true); }} + className="w-full accent-[#00E676] bg-slate-800 rounded-lg h-1.5 appearance-none cursor-pointer" + /> +
+ 10010 000 +
+
+ )} + + {/* Apply button */} + +
+
+ {/* Aggregator Settings Rules Indicator */}

diff --git a/frontend/src/FootprintCanvas.jsx b/frontend/src/FootprintCanvas.jsx index 6432ae5..b004795 100644 --- a/frontend/src/FootprintCanvas.jsx +++ b/frontend/src/FootprintCanvas.jsx @@ -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 ( -
+
{ // 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;