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>
This commit is contained in:
rufinomec-afk
2026-06-07 16:05:59 -03:00
co-authored by Claude Sonnet 4.6
parent 3f48ef27ed
commit a2488eaab2
3 changed files with 87 additions and 39 deletions
+57 -6
View File
@@ -24,6 +24,7 @@ class MT5Collector:
self.last_tick_time_msc = 0 self.last_tick_time_msc = 0
self.seen_ticks_buffer = set() self.seen_ticks_buffer = set()
self.last_mid_price = 0.0 self.last_mid_price = 0.0
self.last_bid_price = 0.0
self.last_is_buy = True self.last_is_buy = True
self.last_bid = 0.0 self.last_bid = 0.0
self.last_ask = 0.0 self.last_ask = 0.0
@@ -88,6 +89,41 @@ class MT5Collector:
logger.error(f"Error during MT5 shutdown: {e}") logger.error(f"Error during MT5 shutdown: {e}")
self.connected = False 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): async def start(self):
self.running = True self.running = True
backoff = 1.0 backoff = 1.0
@@ -103,9 +139,13 @@ class MT5Collector:
else: else:
backoff = 1.0 backoff = 1.0
# Fetch from 48 hours ago so the chart isn't empty when started (covers weekends)
from datetime import datetime, timedelta 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( ticks = await asyncio.to_thread(
mt5.copy_ticks_from, self.symbol, start_time_dt, 100000, mt5.COPY_TICKS_ALL 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 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 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_mid = self.last_mid_price
prev_bid = self.last_bid_price
# Update direction tracker from mid movement # Update direction tracker from bid movement (YuCluster uses Bid as price reference)
if mid_price > 0: 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: if mid_price > self.last_mid_price:
self.last_is_buy = True self.last_is_buy = True
elif mid_price < self.last_mid_price: elif mid_price < self.last_mid_price:
self.last_is_buy = False self.last_is_buy = False
if mid_price > 0:
self.last_mid_price = mid_price self.last_mid_price = mid_price
# Determine price (use mid as best proxy for CFD quote feed) # 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)) 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 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 price_steps = abs(mid_price - prev_mid) / tick_sz
volume = max(price_steps, 1.0) volume = max(price_steps, 1.0)
else: else:
+3
View File
@@ -20,3 +20,6 @@ STACKED_MIN_COUNT = int(os.environ.get("STACKED_MIN_COUNT", 3))
# WebSocket # WebSocket
WS_PORT = int(os.environ.get("WS_PORT", 6002)) WS_PORT = int(os.environ.get("WS_PORT", 6002))
HISTORY_BUFFER_SIZE = int(os.environ.get("HISTORY_BUFFER_SIZE", 500)) 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
+27 -33
View File
@@ -178,36 +178,7 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli
const highestPrice = Math.max(...numericPrices); const highestPrice = Math.max(...numericPrices);
const lowestPrice = Math.min(...numericPrices); const lowestPrice = Math.min(...numericPrices);
// Draw Stacked Imbalance background zone if present // Stacked imbalance visual — to be reimplemented based on YuCluster config
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();
}
}
// OHLC body range — levels outside = wicks (just a line) // 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); 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) // POC border (rectangle only at POC level)
if (!isWick && price === cluster.poc) { if (!isWick && price === cluster.poc) {
ctx.strokeStyle = '#FFD600'; 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.fillStyle = 'rgba(37, 99, 235, 0.9)';
ctx.fillRect(colX + halfW + gap + 1, barBaseY - askBarH, halfW, askBarH); ctx.fillRect(colX + halfW + gap + 1, barBaseY - askBarH, halfW, askBarH);
// Volume label — white, inside the bars at the bottom // Volume label — dominant side (max of ask/bid), matching original YuCluster display
const totalVol = cluster.total_volume || 0; const domVol = Math.max(bidTotal, askTotal);
const volLabel = totalVol >= 1000 ? (totalVol / 1000).toFixed(1) + 'K' : totalVol.toFixed(0); const volLabel = domVol >= 1000 ? (domVol / 1000).toFixed(1) + 'K' : domVol.toFixed(0);
const delta = cluster.total_delta || 0; const delta = cluster.total_delta || 0;
ctx.fillStyle = '#FFFFFF'; ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 11px JetBrains Mono, monospace'; ctx.font = 'bold 11px JetBrains Mono, monospace';