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 (
-
+