Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6a9164788 | |||
| d593d20cec | |||
| 775ecf75bd | |||
| d8dc47a27a | |||
| 92b810e4e5 | |||
| 1b520f60fd | |||
| 935ff203d3 | |||
| b35fd2eb5a | |||
| cc0e1bc3de | |||
| 6f8e64048a | |||
| c74b411404 | |||
| 310c1f52bd | |||
| 6fe8920412 | |||
| a2488eaab2 | |||
| 3f48ef27ed |
+72
-45
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user