15 Commits

Author SHA1 Message Date
rufinomec-afk a6a9164788 feat: Footprint Chart MT5 v1.85 — EA nativo MQL5
Porta fiel do YuClusters em MQL5 puro via CCanvas, sem dependências externas.

Funcionalidades:
- Clusters bid/ask por nível com POC, modo delta, painel inferior
- Ferramentas de desenho: Zona e Volume Profile de região (right-drag)
- Painéis de configuração via right-click com color picker inline (160 cores)
- Zoom vertical/horizontal, pan, eixo de preço customizado
- Menu de configurações (formação + cores) com gear button
2026-06-09 16:48:37 -03:00
rufinomec-afk d593d20cec feat: persiste passo do preço e modo de exibição no localStorage 2026-06-08 11:56:28 -03:00
rufinomec-afk 775ecf75bd fix: Aplicar só dispara replay backend quando modo/delta/volume/tempo/range mudam
- Adiciona backendDirty separado de configDirty
- stepMultiplier e viewMode só setam configDirty (frontend-only, sem replay)
- closeMode, deltaMax, volumeMax, timeSeconds, rangePoints setam backendDirty também
- Aplicar chama applyConfig apenas se backendDirty=true, evitando reload desnecessário

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:52:19 -03:00
rufinomec-afk d8dc47a27a fix: habilitar botão Aplicar ao mudar passo do preço 2026-06-08 11:49:30 -03:00
rufinomec-afk 92b810e4e5 refactor: centraliza toda config na engrenagem, remove duplicatas do sidebar
- Remove PRICE STEP e VIEW MODE do System Overview
- Remove seção inteira Cluster Formation do sidebar
- Painel da engrenagem agora tem todos os modos: delta, volume, tempo, range
- Todos os campos de threshold são inputs editáveis com botões −/+
- Sidebar fica apenas com estatísticas e status

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:46:31 -03:00
rufinomec-afk 1b520f60fd feat: toolbar do gráfico com engrenagem, alinhar ao preço e inputs editáveis
- Toolbar flutuante no canto superior esquerdo do canvas
- Ícone de mira: centraliza o gráfico no preço atual (reset scroll Y)
- Ícone de engrenagem: abre painel de configurações flutuante sobre o gráfico
- Painel de config: modo de exibição, passo do preço, modo de fechamento, delta máx
- Passo do preço e delta máx agora têm input de texto digitável + botões −/+
- zoomH/zoomV separados: centerTrigger prop para alinhar do App.jsx

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:44:40 -03:00
rufinomec-afk 935ff203d3 fix: separa zoom horizontal e vertical no footprint canvas
- zoomH controla largura das colunas (colWidth, colGap)
- zoomV controla altura das linhas (rowHeight) independentemente
- Arrastar eixo de preço (direita) → ajusta zoomV, não mais o stepMultiplier
- Arrastar painel inferior → ajusta zoomH
- Ctrl+scroll e botões +/- → zoomH com escala multiplicativa
- Mínimos: zoomH=0.05, zoomV=0.1 — permite redução bem maior
- Sensibilidade multiplicativa em todos os controles de zoom
- Corrige dependências do useEffect (zoomH, zoomV, bid, ask, etc.)
- Reset View reseta ambos os zooms

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:35:55 -03:00
rufinomec-afk b35fd2eb5a merge: integra fase5 do Thiago (DOM, VWAP, big trades, trading, historical browser) mantendo fixes de ontem
- Combina on_history_ready_callback (replay reset) com on_big_trade_callback (Thiago)
- DOM subscription + visualização no canvas
- VWAP e delta profile no volume profile
- Big trades com alerta visual no canvas
- Endpoints /trade/buy, /trade/sell para execução via MQL5
- Historical Browser: date picker no header, /history/load e /history/live
- Trading panel no sidebar com botões BUY/SELL
- config/settings.py: mantém USTEC como padrão, adiciona BIG_TRADE_THRESHOLD
- Todos os fixes de ontem preservados: broadcast live, history_ready, replay reset

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 11:22:13 -03:00
rufinomec-afk cc0e1bc3de feat: replay reset ao mudar config — gráfico regenera com novo delta 2026-06-07 23:38:28 -03:00
rufinomec-afk 6f8e64048a revert: remover natural overshoot, manter split exato em 800 2026-06-07 23:33:59 -03:00
rufinomec-afk c74b411404 fix: history_ready event — frontend refetch apos replay completo 2026-06-07 23:09:29 -03:00
rufinomec-afk 310c1f52bd fix: broadcast active cluster em todo tick live, heartbeat WS
Remove guard closed_json que bloqueava updates do cluster ativo.
Frontend recebe estado a cada tick sem precisar de F5.
Heartbeat ping a cada 20s para manter conexao WS viva.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 23:06:57 -03:00
rufinomec-afk 6fe8920412 fix: desativar calculo de imbalance no frontend
Frontend recalculava imbalance independente do backend.
Desativado ate implementacao correta ser encontrada.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 16:06:57 -03:00
rufinomec-afk a2488eaab2 feat: frontend, collector e settings updates
- mt5_collector: session start detection via gap M1, bar volume annotation, imbalance events tracking
- settings: HISTORY_FROM_DATE, HISTORY_SESSION_START flags
- FootprintCanvas: imbalance dots (centralizados, desativados por ora), stacked imbalance removido

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 16:05:59 -03:00
rufinomec-afk 3f48ef27ed feat: tick split, imbalance dots (desativado), session start detection
- Tick split via while-loop no process_tick: clusters fecham em +-800 delta sem overshoot
- Session start detection: busca maior gap em 3000 bars M1 para detectar abertura de sessao
- Imbalance dots: codigo presente mas desativado (6 abordagens testadas, todas geraram dots em todos os levels com dados CFD polling)
- ask_events/bid_events tracking por level (para futura implementacao de imbalance)
- Bottom panel redesign: volume bars + delta blocks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 16:05:50 -03:00
7 changed files with 2429 additions and 279 deletions
+72 -45
View File
@@ -1,8 +1,11 @@
import uuid
import time
import logging
from typing import Dict, Any, List, Optional
from config import settings
logger = logging.getLogger("aggregator")
class FootprintCluster:
def __init__(self, tick_size: float = 1.0):
self.tick_size = tick_size
@@ -48,12 +51,14 @@ class FootprintCluster:
rounded_price = round(price / self.tick_size) * self.tick_size
if rounded_price not in self.levels:
self.levels[rounded_price] = {"ask": 0.0, "bid": 0.0}
self.levels[rounded_price] = {"ask": 0.0, "bid": 0.0, "ask_events": 0, "bid_events": 0}
if is_buy:
self.levels[rounded_price]["ask"] += volume
self.levels[rounded_price]["ask_events"] += 1
else:
self.levels[rounded_price]["bid"] += volume
self.levels[rounded_price]["bid_events"] += 1
# Update High/Low
if self.high is None or rounded_price > self.high:
@@ -104,30 +109,13 @@ class FootprintCluster:
imbalances_buy = {}
imbalances_sell = {}
# IMBALANCE DESATIVADO TEMPORARIAMENTE
# Todas as abordagens testadas geraram dots em todos os levels.
# Ver project_imbalance_research.md para histórico completo das tentativas.
# Reativar quando encontrar a abordagem correta.
for price in sorted_prices:
ask_val = self.levels[price]["ask"]
bid_val = self.levels[price]["bid"]
# Lower level price
lower_price = round((price - self.tick_size) / self.tick_size) * self.tick_size
bid_below = self.levels[lower_price]["bid"] if lower_price in self.levels else 0.0
# Upper level price
upper_price = round((price + self.tick_size) / self.tick_size) * self.tick_size
ask_above = self.levels[upper_price]["ask"] if upper_price in self.levels else 0.0
# Buy Imbalance (diagonal): ask vs bid_below
# Avoid triggering on 0 vs 0
if ask_val > 0 and ask_val >= R * bid_below:
imbalances_buy[price] = True
else:
imbalances_buy[price] = False
# Sell Imbalance (diagonal): bid vs ask_above
if bid_val > 0 and bid_val >= R * ask_above:
imbalances_sell[price] = True
else:
imbalances_sell[price] = False
imbalances_buy[price] = False
imbalances_sell[price] = False
# Detect stacked imbalances: 3+ consecutive levels with imbalance in the same direction
# Let's check contiguous price levels in steps of tick_size
@@ -339,32 +327,71 @@ class Aggregator:
self.active_cluster = FootprintCluster(tick_size=self.tick_size)
self.history: List[Dict[str, Any]] = []
def _close_active(self, reason: str) -> Dict[str, Any]:
self.active_cluster.close(reason)
closed = self.active_cluster.to_json()
from datetime import datetime
ts = closed.get('close_time') or closed.get('open_time') or 0
dt = datetime.fromtimestamp(ts / 1000.0).strftime("%H:%M:%S") if ts else "?"
logger.info(f"CLUSTER CLOSED [{dt}] vol={closed['total_volume']:.0f} delta={closed['total_delta']:.0f} reason={reason}")
self.history.append(closed)
if len(self.history) > settings.HISTORY_BUFFER_SIZE:
self.history.pop(0)
self.active_cluster = FootprintCluster(tick_size=self.tick_size)
return closed
def process_tick(self, price: float, volume: float, is_buy: bool, timestamp_msc: int) -> tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
"""
Process a single tick.
In delta mode, splits ticks (via while loop) so no cluster ever overshoots ±CLUSTER_DELTA_MAX.
Returns:
(active_cluster_json, closed_cluster_json_if_just_closed)
"""
self.active_cluster.add_tick(price, volume, is_buy, timestamp_msc)
# Check if the active cluster should be closed
close_reason = self.active_cluster.should_close(timestamp_msc)
closed_json = None
if close_reason:
self.active_cluster.close(close_reason)
closed_json = self.active_cluster.to_json()
# Save to history
self.history.append(closed_json)
if len(self.history) > settings.HISTORY_BUFFER_SIZE:
self.history.pop(0)
# Start new cluster
self.active_cluster = FootprintCluster(tick_size=self.tick_size)
# The next tick will set the open_time of the new cluster.
return self.active_cluster.to_json(), closed_json
last_closed = None
if settings.CLUSTER_CLOSE_MODE != "delta":
self.active_cluster.add_tick(price, volume, is_buy, timestamp_msc)
close_reason = self.active_cluster.should_close(timestamp_msc)
if close_reason:
last_closed = self._close_active(close_reason)
return self.active_cluster.to_json(), last_closed
remaining = volume
while remaining > 0:
current_delta = self.active_cluster.total_delta
# If cluster is already at/beyond threshold (from a previous leftover), close it first
if self.active_cluster.total_ticks > 0 and abs(current_delta) >= settings.CLUSTER_DELTA_MAX:
last_closed = self._close_active("delta")
continue
contribution = remaining if is_buy else -remaining
projected = current_delta + contribution
if abs(projected) <= settings.CLUSTER_DELTA_MAX:
# Remaining fits — add and check for any close condition
self.active_cluster.add_tick(price, remaining, is_buy, timestamp_msc)
close_reason = self.active_cluster.should_close(timestamp_msc)
if close_reason:
last_closed = self._close_active(close_reason)
break
# Split exactly at CLUSTER_DELTA_MAX
if is_buy:
capacity = settings.CLUSTER_DELTA_MAX - current_delta
else:
capacity = current_delta + settings.CLUSTER_DELTA_MAX
capacity = max(capacity, 0.0)
if capacity > 0:
self.active_cluster.add_tick(price, capacity, is_buy, timestamp_msc)
last_closed = self._close_active("delta")
remaining -= capacity
return self.active_cluster.to_json(), last_closed
def classify_tick(last: float, bid: float, ask: float, flags: int) -> bool:
+76 -7
View File
@@ -15,9 +15,10 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(me
logger = logging.getLogger("mt5_collector")
class MT5Collector:
def __init__(self, aggregator: Aggregator, on_update_callback: Callable[[dict, Optional[dict], Optional[list]], Any], on_big_trade_callback: Optional[Callable[[dict], Any]] = None):
def __init__(self, aggregator: Aggregator, on_update_callback: Callable[[dict, Optional[dict], Optional[list]], Any], on_history_ready_callback: Callable = None, on_big_trade_callback: Optional[Callable[[dict], Any]] = None):
self.aggregator = aggregator
self.on_update_callback = on_update_callback
self.on_history_ready_callback = on_history_ready_callback
self.on_big_trade_callback = on_big_trade_callback
self.symbol = settings.MT5_SYMBOL
self.running = False
@@ -26,10 +27,12 @@ class MT5Collector:
self.last_tick_time_msc = 0
self.seen_ticks_buffer = set()
self.last_mid_price = 0.0
self.last_bid_price = 0.0
self.last_is_buy = True
self.last_bid = 0.0
self.last_ask = 0.0
self._history_annotated = False
self._replay_reset_requested = False
async def connect_mt5(self) -> bool:
"""
@@ -96,11 +99,60 @@ class MT5Collector:
logger.error(f"Error during MT5 shutdown: {e}")
self.connected = False
async def _find_last_session_open(self):
"""
Detecta o início da última sessão de mercado buscando o maior gap
nos últimos N bars M1. Um gap > 30min indica fechamento de sessão.
Retorna o datetime do primeiro bar após o gap (abertura de sessão).
"""
from datetime import datetime, timedelta
SESSION_GAP_MINUTES = 30
LOOKBACK_BARS = 3000 # ~50h de M1
rates = await asyncio.to_thread(
mt5.copy_rates_from_pos, self.symbol, mt5.TIMEFRAME_M1, 0, LOOKBACK_BARS
)
if rates is None or len(rates) < 2:
logger.warning("_find_last_session_open: sem bars M1, usando HISTORY_HOURS")
return datetime.now() - timedelta(hours=settings.HISTORY_HOURS)
# Percorre de trás para frente procurando o maior gap (fechamento de sessão)
best_gap = 0
session_open_ts = None
for i in range(len(rates) - 1, 0, -1):
gap_sec = int(rates[i]['time']) - int(rates[i - 1]['time'])
if gap_sec > best_gap:
best_gap = gap_sec
session_open_ts = int(rates[i]['time'])
if session_open_ts and best_gap >= SESSION_GAP_MINUTES * 60:
dt = datetime.fromtimestamp(session_open_ts)
logger.info(f"Última abertura de sessão detectada: {dt} (gap de {best_gap//60}min)")
return dt
else:
logger.warning("Nenhum gap de sessão encontrado, usando HISTORY_HOURS")
return datetime.now() - timedelta(hours=settings.HISTORY_HOURS)
def request_replay_reset(self):
"""Signal the polling loop to reset the aggregator and replay from session start."""
self.aggregator.history.clear()
self.aggregator.active_cluster = type(self.aggregator.active_cluster)(tick_size=self.aggregator.tick_size)
self._history_annotated = False
self._replay_reset_requested = True
async def start(self):
self.running = True
backoff = 1.0
while self.running:
# Config changed — restart replay from session start with new settings
if self._replay_reset_requested:
self._replay_reset_requested = False
self.connected = False
logger.info("Replay reset requested — restarting from session start...")
continue
if not self.connected:
success = await self.connect_mt5()
if not success:
@@ -111,9 +163,13 @@ class MT5Collector:
else:
backoff = 1.0
# 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=48)
if settings.HISTORY_FROM_DATE:
start_time_dt = datetime.strptime(settings.HISTORY_FROM_DATE, "%Y.%m.%d")
elif settings.HISTORY_SESSION_START:
start_time_dt = await self._find_last_session_open()
else:
start_time_dt = datetime.now() - timedelta(hours=settings.HISTORY_HOURS)
ticks = await asyncio.to_thread(
mt5.copy_ticks_from, self.symbol, start_time_dt, 100000, mt5.COPY_TICKS_ALL
)
@@ -149,6 +205,8 @@ class MT5Collector:
if not self._history_annotated and len(ticks) < 1000:
self._history_annotated = True
await self.annotate_history_bar_volume()
if self.on_history_ready_callback:
self.on_history_ready_callback()
if len(ticks) > 0:
logger.info(f"Fetched {len(ticks)} ticks starting at {ticks[0]['time_msc']}")
@@ -177,21 +235,32 @@ class MT5Collector:
if ask_price > 0: self.last_ask = ask_price
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
prev_bid = self.last_bid_price
# Update direction tracker from mid movement
if mid_price > 0:
# Update direction tracker from bid movement (YuCluster uses Bid as price reference)
if bid_price > 0:
if bid_price > self.last_bid_price:
self.last_is_buy = True
elif bid_price < self.last_bid_price:
self.last_is_buy = False
self.last_bid_price = bid_price
elif 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
if mid_price > 0:
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)
# Volume = bid price movement in tick-size units (YuCluster: "Ticks & Bid")
tick_sz = self.aggregator.tick_size if self.aggregator.tick_size > 0 else 0.01
if prev_mid > 0 and mid_price > 0:
if prev_bid > 0 and bid_price > 0:
price_steps = abs(bid_price - prev_bid) / tick_sz
volume = max(price_steps, 1.0)
elif prev_mid > 0 and mid_price > 0:
price_steps = abs(mid_price - prev_mid) / tick_sz
volume = max(price_steps, 1.0)
else:
+38 -6
View File
@@ -26,11 +26,11 @@ collector_task: Optional[asyncio.Task] = None
def broadcast_update(active_json: dict, closed_json: Optional[dict], dom_data: Optional[list] = None):
"""
Callback executed by MT5Collector when a new tick is processed.
Only broadcasts when a cluster closes (not every tick) to avoid flooding during replay.
Removes dead connections automatically.
Callback executed by MT5Collector on every live tick.
Always broadcasts the active cluster state so the frontend updates in real-time.
closed_json is None between cluster closes frontend handles that gracefully.
"""
if not active_connections or closed_json is None:
if not active_connections:
return
message = {
"type": "tick",
@@ -61,8 +61,20 @@ def broadcast_big_trade(trade_data: dict):
for connection in list(active_connections):
asyncio.create_task(safe_send(connection, message))
def broadcast_history_ready():
"""Notify all clients that historical replay is complete — they should refetch /history."""
if not active_connections:
return
async def _send():
for ws in list(active_connections):
try:
await ws.send_json({"type": "history_ready"})
except Exception:
active_connections.discard(ws)
asyncio.create_task(_send())
# Initialize MT5 Collector
collector = MT5Collector(aggregator, on_update_callback=broadcast_update, on_big_trade_callback=broadcast_big_trade)
collector = MT5Collector(aggregator, on_update_callback=broadcast_update, on_history_ready_callback=broadcast_history_ready, on_big_trade_callback=broadcast_big_trade)
async def _start_collector_delayed():
"""Wait a moment for the server to fully start, then begin polling MT5."""
@@ -129,6 +141,15 @@ async def update_config(update: ConfigUpdate):
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}")
# Notify clients to clear history, then reset and replay with new config
for ws in list(active_connections):
try:
await ws.send_json({"type": "reset"})
except Exception:
active_connections.discard(ws)
collector.request_replay_reset()
return {"ok": True}
@app.post("/trade/buy")
@@ -173,7 +194,6 @@ async def websocket_endpoint(websocket: WebSocket):
active_connections.add(websocket)
logger.info(f"Client connected. Active connections: {len(active_connections)}")
# Send the current active cluster state on connection
try:
await websocket.send_json({
"type": "init",
@@ -182,6 +202,16 @@ async def websocket_endpoint(websocket: WebSocket):
except Exception as e:
logger.error(f"Error sending init state: {e}")
async def heartbeat():
"""Ping every 20s to keep the connection alive through proxies/browsers."""
try:
while True:
await asyncio.sleep(20)
await websocket.send_json({"type": "ping"})
except Exception:
pass
ping_task = asyncio.create_task(heartbeat())
try:
while True:
await websocket.receive_text()
@@ -191,6 +221,8 @@ async def websocket_endpoint(websocket: WebSocket):
except Exception as e:
logger.error(f"WebSocket error: {e}")
active_connections.discard(websocket)
finally:
ping_task.cancel()
if __name__ == "__main__":
import uvicorn
+4 -1
View File
@@ -4,7 +4,7 @@ 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 = float(os.environ.get("CLUSTER_RANGE_POINTS", 50.0))
@@ -21,3 +21,6 @@ BIG_TRADE_THRESHOLD = float(os.environ.get("BIG_TRADE_THRESHOLD", 50.0))
# WebSocket
WS_PORT = int(os.environ.get("WS_PORT", 6002))
HISTORY_BUFFER_SIZE = int(os.environ.get("HISTORY_BUFFER_SIZE", 500))
HISTORY_HOURS = float(os.environ.get("HISTORY_HOURS", 4.0))
HISTORY_FROM_DATE = os.environ.get("HISTORY_FROM_DATE", "") # ex: "2026.06.04" — se definido, ignora HISTORY_HOURS
HISTORY_SESSION_START = os.environ.get("HISTORY_SESSION_START", "true").lower() == "true" # puxar desde a última abertura de sessão
+170 -142
View File
@@ -14,10 +14,10 @@ export default function App() {
const [lastBid, setLastBid] = useState(0);
const [lastAsk, setLastAsk] = useState(0);
// Phase 2 & 4 Settings
const [stepMultiplier, setStepMultiplier] = useState(75);
const [viewMode, setViewMode] = useState('bidask'); // 'bidask' or 'delta'
const [imbalanceRatio, setImbalanceRatio] = useState(300); // percentage
// Phase 2 & 4 Settings persistidos no localStorage
const [stepMultiplier, setStepMultiplier] = useState(() => Number(localStorage.getItem('yc_step') || 75));
const [viewMode, setViewMode] = useState(() => localStorage.getItem('yc_viewmode') || 'bidask');
const [imbalanceRatio, setImbalanceRatio] = useState(300);
// Cluster aggregator config
const [closeMode, setCloseMode] = useState('delta');
@@ -25,7 +25,8 @@ export default function App() {
const [volumeMax, setVolumeMax] = useState(1000);
const [timeSeconds, setTimeSeconds] = useState(300);
const [rangePoints, setRangePoints] = useState(10);
const [configDirty, setConfigDirty] = useState(false);
const [configDirty, setConfigDirty] = useState(false); // qualquer campo mudou
const [backendDirty, setBackendDirty] = useState(false); // só campos que precisam do backend
const [targetSymbol, setTargetSymbol] = useState('EURUSD');
// History Mode
@@ -35,6 +36,10 @@ export default function App() {
// WebSocket Data States (Phase 5)
const [domData, setDomData] = useState(null);
const [bigTrades, setBigTrades] = useState([]);
// Chart toolbar
const [showChartSettings, setShowChartSettings] = useState(false);
const [centerTrigger, setCenterTrigger] = useState(0);
// Toasts
const [toasts, setToasts] = useState([]);
@@ -94,7 +99,12 @@ export default function App() {
if (msg.type === 'init') {
setActiveCluster(msg.active);
fetchHistory(); // Sync history on connection
fetchHistory();
} else if (msg.type === 'reset') {
setHistory([]);
setActiveCluster(null);
} else if (msg.type === 'history_ready') {
fetchHistory();
} else if (msg.type === 'tick') {
setActiveCluster(msg.active);
if (msg.active?.bid) setLastBid(msg.active.bid);
@@ -125,6 +135,10 @@ export default function App() {
const wsStatus = useWebSocket(WS_URL, handleWebSocketMessage);
// Persistência local
useEffect(() => { localStorage.setItem('yc_step', stepMultiplier); }, [stepMultiplier]);
useEffect(() => { localStorage.setItem('yc_viewmode', viewMode); }, [viewMode]);
useEffect(() => {
fetchHistory();
fetchConfig();
@@ -258,6 +272,155 @@ export default function App() {
</div>
)}
{/* Chart Toolbar */}
<div className="absolute top-2 left-2 z-20 flex items-center gap-1 bg-[#0B0E14]/90 border border-slate-700 rounded-lg px-1.5 py-1">
{/* Align to current price */}
<button
onClick={() => setCenterTrigger(t => t + 1)}
title="Alinhar ao preço atual"
className="w-7 h-7 flex items-center justify-center text-slate-400 hover:text-[#00E676] hover:bg-slate-800 rounded transition"
>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<circle cx="12" cy="12" r="3"/>
<line x1="12" y1="2" x2="12" y2="6"/>
<line x1="12" y1="18" x2="12" y2="22"/>
<line x1="2" y1="12" x2="6" y2="12"/>
<line x1="18" y1="12" x2="22" y2="12"/>
</svg>
</button>
<div className="w-px h-4 bg-slate-700"/>
{/* Settings gear */}
<button
onClick={() => setShowChartSettings(s => !s)}
title="Configurações do gráfico"
className={`w-7 h-7 flex items-center justify-center rounded transition ${showChartSettings ? 'text-[#00E676] bg-slate-800' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
>
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94s-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96a6.97 6.97 0 0 0-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87a.48.48 0 0 0 .12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32a.49.49 0 0 0-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/>
</svg>
</button>
</div>
{/* Floating Settings Panel */}
{showChartSettings && (
<div className="absolute top-12 left-2 z-30 bg-[#0F1520] border border-slate-700 rounded-xl shadow-2xl w-64 p-4 space-y-4">
<div className="flex justify-between items-center">
<span className="text-xs font-bold text-slate-300 uppercase tracking-wider">Configurações</span>
<button onClick={() => setShowChartSettings(false)} className="text-slate-500 hover:text-white text-xs"></button>
</div>
{/* View Mode */}
<div>
<span className="text-[10px] text-slate-500 font-bold block mb-1">MODO DE EXIBIÇÃO</span>
<div className="flex bg-[#151B26] border border-slate-700 rounded-md overflow-hidden">
<button onClick={() => setViewMode('bidask')} className={`flex-1 py-1 text-xs font-semibold ${viewMode === 'bidask' ? 'bg-[#00E676] text-slate-900' : 'text-slate-400 hover:bg-slate-800'}`}>Bid x Ask</button>
<button onClick={() => setViewMode('delta')} className={`flex-1 py-1 text-xs font-semibold ${viewMode === 'delta' ? 'bg-[#00E676] text-slate-900' : 'text-slate-400 hover:bg-slate-800'}`}>Delta</button>
</div>
</div>
{/* Price Step */}
<div>
<span className="text-[10px] text-slate-500 font-bold block mb-1">PASSO DO PREÇO (ticks)</span>
<div className="flex items-center bg-[#151B26] border border-slate-700 rounded-md overflow-hidden">
<button onClick={() => { setStepMultiplier(s => Math.max(1, s - 25)); setConfigDirty(true); }} className="px-3 py-1.5 text-slate-400 hover:text-white hover:bg-slate-800"></button>
<input
type="number"
min="1"
value={stepMultiplier}
onChange={e => { setStepMultiplier(Math.max(1, Number(e.target.value) || 1)); setConfigDirty(true); }}
className="flex-1 bg-transparent text-center text-sm font-bold text-white outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button onClick={() => { setStepMultiplier(s => s + 25); setConfigDirty(true); }} className="px-3 py-1.5 text-slate-400 hover:text-white hover:bg-slate-800">+</button>
</div>
</div>
{/* Close Mode */}
<div>
<span className="text-[10px] text-slate-500 font-bold block mb-1">MODO DE FECHAMENTO</span>
<select
value={closeMode}
onChange={e => { setCloseMode(e.target.value); setConfigDirty(true); setBackendDirty(true); }}
className="w-full bg-[#151B26] border border-slate-700 rounded-md px-2 py-1.5 text-xs text-slate-300 outline-none"
>
<option value="delta">Delta</option>
<option value="volume">Volume</option>
<option value="range">Range</option>
<option value="time">Tempo</option>
</select>
</div>
{/* Threshold por modo */}
{closeMode === 'delta' && (
<div>
<span className="text-[10px] text-slate-500 font-bold block mb-1">DELTA MÁX</span>
<div className="flex items-center bg-[#151B26] border border-slate-700 rounded-md overflow-hidden">
<button onClick={() => { setDeltaMax(d => Math.max(100, d - 100)); setConfigDirty(true); setBackendDirty(true); }} className="px-3 py-1.5 text-slate-400 hover:text-white hover:bg-slate-800"></button>
<input type="number" min="100" step="100" value={deltaMax}
onChange={e => { setDeltaMax(Math.max(100, Number(e.target.value) || 100)); setConfigDirty(true); setBackendDirty(true); }}
className="flex-1 bg-transparent text-center text-sm font-bold text-white outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button onClick={() => { setDeltaMax(d => d + 100); setConfigDirty(true); setBackendDirty(true); }} className="px-3 py-1.5 text-slate-400 hover:text-white hover:bg-slate-800">+</button>
</div>
</div>
)}
{closeMode === 'volume' && (
<div>
<span className="text-[10px] text-slate-500 font-bold block mb-1">VOLUME MÁX</span>
<div className="flex items-center bg-[#151B26] border border-slate-700 rounded-md overflow-hidden">
<button onClick={() => { setVolumeMax(v => Math.max(100, v - 100)); setConfigDirty(true); setBackendDirty(true); }} className="px-3 py-1.5 text-slate-400 hover:text-white hover:bg-slate-800"></button>
<input type="number" min="100" step="100" value={volumeMax}
onChange={e => { setVolumeMax(Math.max(100, Number(e.target.value) || 100)); setConfigDirty(true); setBackendDirty(true); }}
className="flex-1 bg-transparent text-center text-sm font-bold text-white outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button onClick={() => { setVolumeMax(v => v + 100); setConfigDirty(true); setBackendDirty(true); }} className="px-3 py-1.5 text-slate-400 hover:text-white hover:bg-slate-800">+</button>
</div>
</div>
)}
{closeMode === 'time' && (
<div>
<span className="text-[10px] text-slate-500 font-bold block mb-1">TEMPO (segundos)</span>
<div className="flex items-center bg-[#151B26] border border-slate-700 rounded-md overflow-hidden">
<button onClick={() => { setTimeSeconds(t => Math.max(10, t - 30)); setConfigDirty(true); setBackendDirty(true); }} className="px-3 py-1.5 text-slate-400 hover:text-white hover:bg-slate-800"></button>
<input type="number" min="10" step="30" value={timeSeconds}
onChange={e => { setTimeSeconds(Math.max(10, Number(e.target.value) || 10)); setConfigDirty(true); setBackendDirty(true); }}
className="flex-1 bg-transparent text-center text-sm font-bold text-white outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button onClick={() => { setTimeSeconds(t => t + 30); setConfigDirty(true); setBackendDirty(true); }} className="px-3 py-1.5 text-slate-400 hover:text-white hover:bg-slate-800">+</button>
</div>
</div>
)}
{closeMode === 'range' && (
<div>
<span className="text-[10px] text-slate-500 font-bold block mb-1">RANGE (pontos)</span>
<div className="flex items-center bg-[#151B26] border border-slate-700 rounded-md overflow-hidden">
<button onClick={() => { setRangePoints(r => Math.max(1, r - 1)); setConfigDirty(true); setBackendDirty(true); }} className="px-3 py-1.5 text-slate-400 hover:text-white hover:bg-slate-800"></button>
<input type="number" min="1" step="1" value={rangePoints}
onChange={e => { setRangePoints(Math.max(1, Number(e.target.value) || 1)); setConfigDirty(true); setBackendDirty(true); }}
className="flex-1 bg-transparent text-center text-sm font-bold text-white outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
<button onClick={() => { setRangePoints(r => r + 1); setConfigDirty(true); setBackendDirty(true); }} className="px-3 py-1.5 text-slate-400 hover:text-white hover:bg-slate-800">+</button>
</div>
</div>
)}
{/* Apply — só chama backend se campos do backend mudaram */}
<button
onClick={() => {
if (backendDirty) applyConfig(closeMode, deltaMax, volumeMax, timeSeconds, rangePoints);
setConfigDirty(false);
setBackendDirty(false);
setShowChartSettings(false);
}}
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'}`}
>
Aplicar
</button>
</div>
)}
<FootprintCanvas
clusters={allClusters}
tickSize={dynamicTickSize}
@@ -269,6 +432,7 @@ export default function App() {
ask={lastAsk}
domData={domData}
bigTrades={bigTrades}
centerTrigger={centerTrigger}
/>
</div>
@@ -299,54 +463,6 @@ export default function App() {
</div>
</div>
<div className="h-8 w-px bg-slate-800 hidden sm:block"></div>
<div className="flex items-center gap-4">
<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(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>
<div className="flex flex-col">
<span className="text-[10px] text-slate-500 font-bold mb-1">VIEW MODE</span>
<div className="flex bg-[#151B26] border border-slate-700 rounded-md overflow-hidden">
<button
onClick={() => setViewMode('bidask')}
className={`px-3 py-1 text-xs font-semibold ${viewMode === 'bidask' ? 'bg-[#00E676] text-slate-900' : 'text-slate-400 hover:bg-slate-800'}`}
>
Bid x Ask
</button>
<button
onClick={() => setViewMode('delta')}
className={`px-3 py-1 text-xs font-semibold ${viewMode === 'delta' ? 'bg-[#00E676] text-slate-900' : 'text-slate-400 hover:bg-slate-800'}`}
>
Delta
</button>
</div>
</div>
</div>
<div className="flex items-center gap-4 mt-4">
<div className="flex flex-col flex-1">
<div className="flex justify-between mb-1">
<span className="text-[10px] text-slate-500 font-bold">IMBALANCE RATIO</span>
<span className="text-[10px] font-mono text-slate-300">{imbalanceRatio}%</span>
</div>
<input
type="range"
min="150"
max="500"
step="10"
value={imbalanceRatio}
onChange={(e) => setImbalanceRatio(parseInt(e.target.value))}
className="w-full accent-[#00B0FF] bg-slate-800 rounded-lg h-1.5 appearance-none cursor-pointer"
/>
</div>
</div>
</div>
<div className="flex justify-between items-center text-xs mt-4 border-t border-slate-800 pt-4">
<span className="text-slate-400">Closed Clusters:</span>
@@ -355,94 +471,6 @@ 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>
{/* Range thresholds */}
{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>
)}
{closeMode === 'volume' && (
<div>
<div className="flex justify-between mb-1">
<span className="text-[10px] text-slate-500 font-bold">VOLUME THRESHOLD</span>
<span className="text-[10px] font-mono text-slate-300">{volumeMax.toLocaleString()}</span>
</div>
<input
type="range" min="100" max="20000" step="100"
value={volumeMax}
onChange={e => { setVolumeMax(Number(e.target.value)); setConfigDirty(true); }}
className="w-full accent-[#00E676] bg-slate-800 rounded-lg h-1.5 appearance-none cursor-pointer"
/>
</div>
)}
{closeMode === 'time' && (
<div>
<div className="flex justify-between mb-1">
<span className="text-[10px] text-slate-500 font-bold">TIME THRESHOLD (seconds)</span>
<span className="text-[10px] font-mono text-slate-300">{timeSeconds}s</span>
</div>
<input
type="range" min="30" max="3600" step="30"
value={timeSeconds}
onChange={e => { setTimeSeconds(Number(e.target.value)); setConfigDirty(true); }}
className="w-full accent-[#00E676] bg-slate-800 rounded-lg h-1.5 appearance-none cursor-pointer"
/>
</div>
)}
{closeMode === 'range' && (
<div>
<div className="flex justify-between mb-1">
<span className="text-[10px] text-slate-500 font-bold">RANGE THRESHOLD (points)</span>
<span className="text-[10px] font-mono text-slate-300">{rangePoints}</span>
</div>
<input
type="range" min="1" max="500" step="1"
value={rangePoints}
onChange={e => { setRangePoints(Number(e.target.value)); setConfigDirty(true); }}
className="w-full accent-[#00E676] bg-slate-800 rounded-lg h-1.5 appearance-none cursor-pointer"
/>
</div>
)}
{/* Apply button */}
<button
onClick={() => applyConfig(closeMode, deltaMax, volumeMax, timeSeconds, rangePoints)}
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">
+74 -78
View File
@@ -1,33 +1,41 @@
import React, { useRef, useEffect, useState } from 'react';
export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultiplier = 1, viewMode = 'bidask', imbalanceRatio = 300, onStepChange, bid = 0, ask = 0, domData = null, bigTrades = [] }) {
export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultiplier = 1, viewMode = 'bidask', imbalanceRatio = 300, onStepChange, bid = 0, ask = 0, domData = null, bigTrades = [], centerTrigger = 0 }) {
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 [zoomH, setZoomH] = useState(1); // horizontal zoom: column width
const [zoomV, setZoomV] = useState(1); // vertical zoom: row height
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
// Price axis drag (vertical scale) state
const isDraggingAxis = useRef(false);
const axisDragStartY = useRef(0);
const axisDragStartStep = useRef(0);
const axisDragStartZoomV = useRef(1);
// 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
const colGap = 15 * zoom; // gap between columns
const rowHeight = 26 * zoom; // height of each price cell
const timeAxisDragStartZoomH = useRef(1);
// Independent zoom dimensions
const colWidth = 140 * zoomH; // width of each cluster column
const colGap = 15 * zoomH; // gap between columns
const rowHeight = 26 * zoomV; // height of each price cell (vertical only)
const axisWidth = 70; // width of the vertical price axis on the right
// Auto-scroll to keep latest cluster visible whenever clusters or zoom changes
// Center on current price when triggered from toolbar
useEffect(() => {
if (centerTrigger > 0) {
setScrollOffset(prev => ({ ...prev, y: 0 }));
}
}, [centerTrigger]);
// Auto-scroll to keep latest cluster visible whenever clusters or horizontal zoom changes
const lastClusterCount = useRef(0);
useEffect(() => {
if (clusters && canvasRef.current) {
@@ -40,7 +48,7 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
setScrollOffset(prev => ({ ...prev, x: newX }));
lastClusterCount.current = clusters.length;
}
}, [clusters?.length, zoom]);
}, [clusters?.length, zoomH]);
// Main Render Loop
useEffect(() => {
@@ -160,12 +168,8 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
binnedLevels[binPrice].total += data.total || 0;
binnedLevels[binPrice].delta += data.delta || 0;
const ratio = imbalanceRatio / 100.0;
if (binnedLevels[binPrice].ask >= binnedLevels[binPrice].bid * ratio && binnedLevels[binPrice].ask > 0) {
binnedLevels[binPrice].imbalance = 'buy';
} else if (binnedLevels[binPrice].bid >= binnedLevels[binPrice].ask * ratio && binnedLevels[binPrice].bid > 0) {
binnedLevels[binPrice].imbalance = 'sell';
}
// Imbalance desativado cálculo pendente de revisão
binnedLevels[binPrice].imbalance = null;
});
levels = binnedLevels;
}
@@ -178,36 +182,7 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
const highestPrice = Math.max(...numericPrices);
const lowestPrice = Math.min(...numericPrices);
// Draw Stacked Imbalance background zone if present
if (cluster.stacked && (cluster.stacked.buy || cluster.stacked.sell)) {
const stackedPrices = cluster.stacked.price_range || [];
if (stackedPrices.length > 0) {
const sHigh = Math.max(...stackedPrices);
const sLow = Math.min(...stackedPrices);
const yTop = getPriceY(sHigh) - rowHeight / 2;
const yBottom = getPriceY(sLow) + rowHeight / 2;
const grad = ctx.createLinearGradient(colX - 8, yTop, colX, yTop);
if (cluster.stacked.buy) {
grad.addColorStop(0, 'rgba(0, 230, 118, 0.4)');
grad.addColorStop(1, 'rgba(0, 230, 118, 0.05)');
ctx.fillStyle = grad;
} else {
grad.addColorStop(0, 'rgba(255, 23, 68, 0.4)');
grad.addColorStop(1, 'rgba(255, 23, 68, 0.05)');
ctx.fillStyle = grad;
}
ctx.fillRect(colX - 10, yTop, 10, yBottom - yTop);
// Draw thin outline
ctx.strokeStyle = cluster.stacked.buy ? '#00E676' : '#FF1744';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(colX - 10, yTop);
ctx.lineTo(colX - 10, yBottom);
ctx.stroke();
}
}
// Stacked imbalance visual to be reimplemented based on YuCluster config
// OHLC body range levels outside = wicks (just a line)
@@ -257,7 +232,7 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
// 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);
const fontSize = Math.max(6, 11 * zoomH);
ctx.font = `bold ${fontSize}px JetBrains Mono, monospace`;
ctx.textBaseline = 'middle';
@@ -285,6 +260,29 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
ctx.fillRect(colX, cellY + 1, barW, rowHeight - 3);
// Imbalance dots centered in the cluster column, like YuCluster
if (!isWick && cellData.imbalance) {
const cy = cellY + rowHeight / 2;
const cx = colX + colWidth / 2;
const radius = Math.max(3, Math.min(rowHeight * 0.38, 10 * zoomH));
const imbalColor = cellData.imbalance === 'sell' ? '#CC0000' : '#1A237E';
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fillStyle = imbalColor;
ctx.fill();
// Show number inside circle when zoomed in enough
if (zoomH >= 1.2 && radius >= 7) {
const val = cellData.imbalance === 'sell' ? (cellData.bid || 0) : (cellData.ask || 0);
ctx.fillStyle = '#FFFFFF';
ctx.font = `bold ${Math.floor(radius * 1.1)}px JetBrains Mono, monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(fmtK(val), cx, cy);
}
}
// POC border (rectangle only at POC level)
if (!isWick && price === cluster.poc) {
ctx.strokeStyle = '#FFD600';
@@ -293,7 +291,7 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
}
// Number only at POC (inside bar, right-aligned)
if (zoom >= 0.5 && !isWick && price === cluster.poc) {
if (zoomH >= 0.5 && !isWick && price === cluster.poc) {
const cy = cellY + rowHeight / 2;
if (viewMode === 'delta') {
const d = cellData.delta || 0;
@@ -372,9 +370,9 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
ctx.fillStyle = 'rgba(37, 99, 235, 0.9)';
ctx.fillRect(colX + halfW + gap + 1, barBaseY - askBarH, halfW, askBarH);
// Volume label white, inside the bars at the bottom
const totalVol = cluster.total_volume || 0;
const volLabel = totalVol >= 1000 ? (totalVol / 1000).toFixed(1) + 'K' : totalVol.toFixed(0);
// Volume label dominant side (max of ask/bid), matching original YuCluster display
const domVol = Math.max(bidTotal, askTotal);
const volLabel = domVol >= 1000 ? (domVol / 1000).toFixed(1) + 'K' : domVol.toFixed(0);
const delta = cluster.total_delta || 0;
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 11px JetBrains Mono, monospace';
@@ -757,7 +755,7 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
}
}
}, [clusters, scrollOffset]);
}, [clusters, scrollOffset, zoomH, zoomV, stepMultiplier, viewMode, bid, ask, domData, bigTrades]);
// Mouse Interaction: Panning/Scrolling
const handleMouseDown = (e) => {
@@ -765,11 +763,11 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Clicking on price axis enter scale drag mode
// Clicking on price axis enter vertical zoom drag mode
if (x > rect.width - axisWidth) {
isDraggingAxis.current = true;
axisDragStartY.current = y;
axisDragStartStep.current = stepMultiplier;
axisDragStartZoomV.current = zoomV;
setCursor('ns-resize');
return;
}
@@ -779,7 +777,7 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
if (y > chartH) {
isDraggingTimeAxis.current = true;
timeAxisDragStartX.current = x;
timeAxisDragStartZoom.current = zoom;
timeAxisDragStartZoomH.current = zoomH;
setCursor('ew-resize');
return;
}
@@ -803,21 +801,19 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
else setCursor('grab');
}
// Price axis drag adjust stepMultiplier
if (isDraggingAxis.current && onStepChange) {
const dy = y - axisDragStartY.current; // inverted: drag down = zoom in (larger step)
const sensitivity = 4;
const newStep = Math.max(25, Math.round((axisDragStartStep.current + dy * sensitivity) / 25) * 25);
onStepChange(newStep);
// Price axis drag adjust vertical zoom (row height only)
if (isDraggingAxis.current) {
const dy = axisDragStartY.current - y; // drag up = taller rows
const newZoomV = Math.max(0.1, Math.min(4.0, axisDragStartZoomV.current * (1 + dy * 0.008)));
setZoomV(newZoomV);
return;
}
// Time axis drag adjust horizontal zoom
// Time axis drag adjust horizontal zoom (column width only)
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);
const newZoomH = Math.max(0.05, Math.min(3.0, timeAxisDragStartZoomH.current * (1 + dx * 0.008)));
setZoomH(newZoomH);
return;
}
@@ -842,11 +838,11 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
// Wheel interaction for scrolling and zooming
const handleWheel = (e) => {
if (e.ctrlKey) {
// Zoom in/out
// Horizontal zoom in/out (column width)
if (e.deltaY < 0) {
setZoom(z => Math.min(2.5, z + 0.1));
setZoomH(z => Math.min(3.0, z * 1.1));
} else {
setZoom(z => Math.max(0.3, z - 0.1));
setZoomH(z => Math.max(0.05, z * 0.9));
}
return;
}
@@ -871,27 +867,27 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
/>
<div className="absolute bottom-4 left-4 flex gap-2 items-center bg-darkBg/50 p-2 rounded-xl backdrop-blur-md border border-slate-800">
<button
onClick={() => setZoom(z => Math.max(0.3, z - 0.1))}
onClick={() => setZoomH(z => Math.max(0.05, z * 0.85))}
className="w-8 h-8 flex items-center justify-center bg-darkPanel border border-slate-700 rounded-lg text-lg font-bold text-slate-300 hover:text-white hover:bg-slate-800 transition"
title="Zoom Out"
title="Zoom Horizontal Out"
>
-
</button>
<span className="text-xs font-mono text-slate-400 min-w-[35px] text-center">
{Math.round(zoom * 100)}%
<span className="text-xs font-mono text-slate-400 min-w-[50px] text-center">
{Math.round(zoomH * 100)}%
</span>
<button
onClick={() => setZoom(z => Math.min(2.5, z + 0.1))}
onClick={() => setZoomH(z => Math.min(3.0, z * 1.15))}
className="w-8 h-8 flex items-center justify-center bg-darkPanel border border-slate-700 rounded-lg text-lg font-bold text-slate-300 hover:text-white hover:bg-slate-800 transition"
title="Zoom In"
title="Zoom Horizontal In"
>
+
</button>
<div className="w-px h-6 bg-slate-700 mx-1"></div>
<button
onClick={() => { setScrollOffset({ x: 50, y: 0 }); setZoom(1); }}
onClick={() => { setScrollOffset({ x: 50, y: 0 }); setZoomH(1); setZoomV(1); }}
className="px-4 py-1.5 bg-darkPanel border border-slate-700 rounded-lg text-xs font-semibold text-slate-300 hover:text-white hover:bg-slate-800 transition"
>
Reset View
File diff suppressed because it is too large Load Diff