diff --git a/backend/mt5_collector.py b/backend/mt5_collector.py index 9633422..4c2a2f9 100644 --- a/backend/mt5_collector.py +++ b/backend/mt5_collector.py @@ -24,6 +24,7 @@ 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 @@ -88,6 +89,41 @@ 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) + async def start(self): self.running = True backoff = 1.0 @@ -103,9 +139,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 ) @@ -165,21 +205,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: diff --git a/config/settings.py b/config/settings.py index 56425ef..aacb46a 100644 --- a/config/settings.py +++ b/config/settings.py @@ -20,3 +20,6 @@ STACKED_MIN_COUNT = int(os.environ.get("STACKED_MIN_COUNT", 3)) # WebSocket WS_PORT = int(os.environ.get("WS_PORT", 6002)) HISTORY_BUFFER_SIZE = int(os.environ.get("HISTORY_BUFFER_SIZE", 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 diff --git a/frontend/src/FootprintCanvas.jsx b/frontend/src/FootprintCanvas.jsx index 953cb27..b3b4973 100644 --- a/frontend/src/FootprintCanvas.jsx +++ b/frontend/src/FootprintCanvas.jsx @@ -178,36 +178,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) @@ -285,6 +256,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 * zoom)); + 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 (zoom >= 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'; @@ -372,9 +366,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';