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:
co-authored by
Claude Sonnet 4.6
parent
3f48ef27ed
commit
a2488eaab2
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user