From 764822da464f55f050e1f91a0e215e7934dcb00c Mon Sep 17 00:00:00 2001 From: Thiago Moura Date: Sun, 7 Jun 2026 23:17:08 -0300 Subject: [PATCH] feat: Implementa Fase 5 - DOM, VWAP, Audio Alerts e Historical Browser --- backend/mt5_collector.py | 130 +++++++++++++++++++++- backend/server.py | 55 ++++++++- backend/trading.py | 38 +++++++ config/settings.py | 5 +- frontend/src/App.jsx | 179 ++++++++++++++++++++++++++++-- frontend/src/FootprintCanvas.jsx | 117 ++++++++++++++++++- frontend/src/utils/AlertEngine.js | 9 ++ mql5/YuCluster_Order_Router.mq5 | 115 +++++++++++++++++++ 8 files changed, 622 insertions(+), 26 deletions(-) create mode 100644 backend/trading.py create mode 100644 mql5/YuCluster_Order_Router.mq5 diff --git a/backend/mt5_collector.py b/backend/mt5_collector.py index 9633422..5a0810f 100644 --- a/backend/mt5_collector.py +++ b/backend/mt5_collector.py @@ -15,12 +15,14 @@ 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]], Any]): + def __init__(self, aggregator: Aggregator, on_update_callback: Callable[[dict, Optional[dict], Optional[list]], Any], on_big_trade_callback: Optional[Callable[[dict], Any]] = None): self.aggregator = aggregator self.on_update_callback = on_update_callback + self.on_big_trade_callback = on_big_trade_callback self.symbol = settings.MT5_SYMBOL self.running = False self.connected = False + self.historical_mode = False self.last_tick_time_msc = 0 self.seen_ticks_buffer = set() self.last_mid_price = 0.0 @@ -74,6 +76,11 @@ class MT5Collector: self.aggregator.active_cluster.tick_size = tick_size logger.info(f"Set aggregator tick size to {tick_size}") + # Subscribe to Depth of Market (DOM) + book_added = await asyncio.to_thread(mt5.market_book_add, self.symbol) + if not book_added: + logger.warning(f"Failed to subscribe to market book (DOM) for {self.symbol}") + logger.info("Successfully connected to MetaTrader 5 and logged in.") self.connected = True return True @@ -83,6 +90,7 @@ class MT5Collector: async def disconnect_mt5(self): try: + await asyncio.to_thread(mt5.market_book_release, self.symbol) await asyncio.to_thread(mt5.shutdown) except Exception as e: logger.error(f"Error during MT5 shutdown: {e}") @@ -116,6 +124,10 @@ class MT5Collector: # Polling loop try: + if self.historical_mode: + await asyncio.sleep(1) + continue + from datetime import datetime polling_dt = datetime.fromtimestamp(self.last_tick_time_msc / 1000.0) ticks = await asyncio.to_thread( @@ -193,6 +205,18 @@ class MT5Collector: else: is_buy = self.last_is_buy + # Broadcast Big Trades instantly + if volume >= settings.BIG_TRADE_THRESHOLD and self.on_big_trade_callback: + import time as _time + is_live = ((_time.time() * 1000) - msc) < 10_000 + if is_live: + self.on_big_trade_callback({ + "price": price, + "volume": volume, + "is_buy": is_buy, + "time_msc": msc + }) + active_json, closed_json = self.aggregator.process_tick(price, volume, is_buy, msc) # Only broadcast during live trading (within 10s of now) to avoid @@ -215,15 +239,109 @@ class MT5Collector: if is_live: active_json['bid'] = self.last_bid active_json['ask'] = self.last_ask - self.on_update_callback(active_json, closed_json) + + # Fetch DOM + dom_json = None + try: + book_items = await asyncio.to_thread(mt5.market_book_get, self.symbol) + if book_items: + dom_json = [ + {"type": item.type, "price": item.price, "volume": item.volume} + for item in book_items + ] + except Exception as e: + logger.error(f"Error fetching DOM: {e}") + + self.on_update_callback(active_json, closed_json, dom_json) await asyncio.sleep(0.1) except Exception as e: - logger.error(f"Error during tick polling loop: {e}") - self.connected = False - await self.disconnect_mt5() - await asyncio.sleep(2.0) + logger.error(f"Error in polling loop: {e}") + import traceback + logger.error(traceback.format_exc()) + await asyncio.sleep(1.0) + + async def load_historical_range(self, start_dt, end_dt): + """ + Pauses live polling, clears the aggregator, fetches a specific historical date range, + processes all ticks into clusters, and broadcasts the completed history to clients. + """ + self.historical_mode = True + logger.info(f"Loading historical data from {start_dt} to {end_dt} for {self.symbol}...") + + # Clear existing data + self.aggregator.history.clear() + self.aggregator.active_cluster = None + self.aggregator._create_new_cluster() + + # We need to fetch ticks in range. + ticks = await asyncio.to_thread( + mt5.copy_ticks_range, + self.symbol, + start_dt, + end_dt, + mt5.COPY_TICKS_ALL + ) + + if ticks is None or len(ticks) == 0: + logger.warning(f"No historical ticks found for {self.symbol} in the requested range.") + self.on_update_callback(self.aggregator.active_cluster.to_json(), None, []) + return + + logger.info(f"Fetched {len(ticks)} historical ticks. Processing...") + + # Process ticks without broadcasting every tick + for tick in ticks: + msc = tick['time_msc'] + flags = int(tick['flags']) + bid_price = float(tick['bid']) + ask_price = float(tick['ask']) + volume = float(tick['volume_real']) + price = float(tick['last']) + + if price <= 0.0 or volume <= 0.0: + continue + + if bid_price > 0: self.last_bid = bid_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 + + if 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 + self.last_mid_price = mid_price + + is_buy = self.last_is_buy + if flags & 32: + is_buy = True + elif flags & 64: + is_buy = False + + # We don't trigger big trade alerts during history load to avoid spam + self.aggregator.process_tick(price, volume, is_buy, msc) + + # Apply bar-level volume annotation + await self.annotate_history_bar_volume() + + # Broadcast the reconstructed history + active_json = self.aggregator.active_cluster.to_json() if self.aggregator.active_cluster else None + closed_json = [c.to_json() for c in self.aggregator.history] + self.on_update_callback(active_json, None, closed_json) + logger.info("Historical data loaded and broadcasted.") + + def return_to_live(self): + """Resumes live polling from the current time.""" + self.historical_mode = False + from datetime import datetime + self.last_tick_time_msc = int(datetime.now().timestamp() * 1000) + self.aggregator.history.clear() + self.aggregator.active_cluster = None + self.aggregator._create_new_cluster() + self._history_annotated = False + logger.info("Returned to live polling.") async def fetch_bar_volume(self, open_time_msc: int, close_time_msc: int) -> int: """Sum tick_volume of M1 bars that overlap with the cluster's time range.""" diff --git a/backend/server.py b/backend/server.py index 68dc6d6..6b8991a 100644 --- a/backend/server.py +++ b/backend/server.py @@ -14,6 +14,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..") from config import settings from backend.aggregator import Aggregator from backend.mt5_collector import MT5Collector +from backend.trading import send_buy_signal, send_sell_signal logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger("server") @@ -23,7 +24,7 @@ aggregator = Aggregator(tick_size=1.0) active_connections: Set[WebSocket] = set() collector_task: Optional[asyncio.Task] = None -def broadcast_update(active_json: dict, closed_json: Optional[dict]): +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. @@ -34,7 +35,23 @@ def broadcast_update(active_json: dict, closed_json: Optional[dict]): message = { "type": "tick", "active": active_json, - "closed": closed_json + "closed": closed_json, + "dom": dom_data + } + async def safe_send(ws: WebSocket, msg: dict): + try: + await ws.send_json(msg) + except Exception: + active_connections.discard(ws) + for connection in list(active_connections): + asyncio.create_task(safe_send(connection, message)) + +def broadcast_big_trade(trade_data: dict): + if not active_connections: + return + message = { + "type": "big_trade", + "data": trade_data } async def safe_send(ws: WebSocket, msg: dict): try: @@ -45,7 +62,7 @@ def broadcast_update(active_json: dict, closed_json: Optional[dict]): asyncio.create_task(safe_send(connection, message)) # Initialize MT5 Collector -collector = MT5Collector(aggregator, on_update_callback=broadcast_update) +collector = MT5Collector(aggregator, on_update_callback=broadcast_update, 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.""" @@ -96,6 +113,7 @@ async def get_config(): "volume_max": settings.CLUSTER_VOLUME_MAX, "range_points": settings.CLUSTER_RANGE_POINTS, "time_seconds": settings.CLUSTER_TIME_SECONDS, + "symbol": settings.MT5_SYMBOL, } @app.post("/config") @@ -113,6 +131,37 @@ async def update_config(update: ConfigUpdate): logger.info(f"Config updated: mode={settings.CLUSTER_CLOSE_MODE}, delta_max={settings.CLUSTER_DELTA_MAX}") return {"ok": True} +@app.post("/trade/buy") +async def trade_buy(): + success = send_buy_signal() + return {"success": success} + +@app.post("/trade/sell") +async def trade_sell(): + success = send_sell_signal() + return {"success": success} + +class HistoryLoadRequest(BaseModel): + start_time: str + end_time: str + +@app.post("/history/load") +async def load_history(req: HistoryLoadRequest): + from datetime import datetime + try: + start_dt = datetime.fromisoformat(req.start_time) + end_dt = datetime.fromisoformat(req.end_time) + await collector.load_historical_range(start_dt, end_dt) + return {"success": True} + except Exception as e: + logger.error(f"Error loading history: {e}") + return {"success": False, "error": str(e)} + +@app.post("/history/live") +async def return_to_live(): + collector.return_to_live() + return {"success": True} + @app.get("/history") async def get_history(): """Returns the buffer of historical closed clusters.""" diff --git a/backend/trading.py b/backend/trading.py new file mode 100644 index 0000000..28f6e07 --- /dev/null +++ b/backend/trading.py @@ -0,0 +1,38 @@ +import MetaTrader5 as mt5 +import logging + +logger = logging.getLogger("trading") + +def send_buy_signal(): + """ + Sets the global variable SINAL_PYTHON_COMPRA to 1 in MT5. + The MQL5 Expert Advisor will detect this, execute the trade, and reset the variable to 0. + """ + try: + success = mt5.global_variable_set("SINAL_PYTHON_COMPRA", 1.0) + if success: + logger.info("SINAL_PYTHON_COMPRA defined successfully.") + return True + else: + logger.error("Failed to set SINAL_PYTHON_COMPRA.") + return False + except Exception as e: + logger.error(f"Exception when sending BUY signal: {e}") + return False + +def send_sell_signal(): + """ + Sets the global variable SINAL_PYTHON_VENDA to 1 in MT5. + The MQL5 Expert Advisor will detect this, execute the trade, and reset the variable to 0. + """ + try: + success = mt5.global_variable_set("SINAL_PYTHON_VENDA", 1.0) + if success: + logger.info("SINAL_PYTHON_VENDA defined successfully.") + return True + else: + logger.error("Failed to set SINAL_PYTHON_VENDA.") + return False + except Exception as e: + logger.error(f"Exception when sending SELL signal: {e}") + return False diff --git a/config/settings.py b/config/settings.py index 56425ef..39b1a27 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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", "USTEC") # símbolo padrão +MT5_SYMBOL = os.environ.get("MT5_SYMBOL", "EURUSD") # símbolo padrão # Cluster CLUSTER_RANGE_POINTS = float(os.environ.get("CLUSTER_RANGE_POINTS", 50.0)) @@ -13,9 +13,10 @@ CLUSTER_DELTA_MAX = float(os.environ.get("CLUSTER_DELTA_MAX", 800)) CLUSTER_TIME_SECONDS = float(os.environ.get("CLUSTER_TIME_SECONDS", 300)) CLUSTER_CLOSE_MODE = os.environ.get("CLUSTER_CLOSE_MODE", "delta") # "range" | "volume" | "delta" | "time" -# Imbalance +# Imbalance & Big Trades IMBALANCE_RATIO = float(os.environ.get("IMBALANCE_RATIO", 3.0)) STACKED_MIN_COUNT = int(os.environ.get("STACKED_MIN_COUNT", 3)) +BIG_TRADE_THRESHOLD = float(os.environ.get("BIG_TRADE_THRESHOLD", 50.0)) # WebSocket WS_PORT = int(os.environ.get("WS_PORT", 6002)) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d780766..0808a9a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -22,7 +22,19 @@ export default function App() { // Cluster aggregator config const [closeMode, setCloseMode] = useState('delta'); const [deltaMax, setDeltaMax] = useState(800); + const [volumeMax, setVolumeMax] = useState(1000); + const [timeSeconds, setTimeSeconds] = useState(300); + const [rangePoints, setRangePoints] = useState(10); const [configDirty, setConfigDirty] = useState(false); + const [targetSymbol, setTargetSymbol] = useState('EURUSD'); + + // History Mode + const [historicalDate, setHistoricalDate] = useState(''); + const [isLoadingHistory, setIsLoadingHistory] = useState(false); + + // WebSocket Data States (Phase 5) + const [domData, setDomData] = useState(null); + const [bigTrades, setBigTrades] = useState([]); // Toasts const [toasts, setToasts] = useState([]); @@ -42,18 +54,22 @@ export default function App() { if (res.ok) { const data = await res.json(); setCloseMode(data.close_mode || 'delta'); - setDeltaMax(data.delta_max || 2418); + setDeltaMax(data.delta_max || 800); + setVolumeMax(data.volume_max || 1000); + setTimeSeconds(data.time_seconds || 300); + setRangePoints(data.range_points || 10); + if (data.symbol) setTargetSymbol(data.symbol); } } catch (e) {} }, []); // Push config update to backend - const applyConfig = useCallback(async (mode, delta) => { + const applyConfig = useCallback(async (mode, delta, vol, time, range) => { try { await fetch(`${API_URL}/config`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ close_mode: mode, delta_max: delta }), + body: JSON.stringify({ close_mode: mode, delta_max: delta, volume_max: vol, time_seconds: time, range_points: range }), }); setConfigDirty(false); } catch (e) {} @@ -96,8 +112,16 @@ export default function App() { // Dispatch alerts for the newly closed cluster AlertEngine.processClusters([msg.closed], pushToast); } + + if (msg.dom) { + setDomData(msg.dom); + } + } else if (msg.type === 'big_trade') { + setBigTrades(prev => [...prev.slice(-49), msg.data]); // Keep last 50 + pushToast(`BIG TRADE: ${msg.data.is_buy ? 'BUY' : 'SELL'} ${msg.data.volume} @ ${msg.data.price}`, msg.data.is_buy ? 'info' : 'warning'); + AlertEngine.processBigTrade(msg.data); } - }, [fetchHistory]); + }, [fetchHistory, pushToast]); const wsStatus = useWebSocket(WS_URL, handleWebSocketMessage); @@ -108,6 +132,36 @@ export default function App() { // Aggregate stats from history const totalVolume = history.reduce((acc, c) => acc + (c.total_volume || 0), 0) + (activeCluster?.total_volume || 0); + const loadHistory = async (dateStr) => { + setIsLoadingHistory(true); + try { + const start = new Date(dateStr); + start.setHours(0, 0, 0, 0); + const end = new Date(dateStr); + end.setHours(23, 59, 59, 999); + + await fetch(`${API_URL}/history/load`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ start_time: start.toISOString(), end_time: end.toISOString() }) + }); + setHistoricalDate(dateStr); + } catch (e) { + console.error(e); + pushToast('Error loading history', 'error'); + } + setIsLoadingHistory(false); + }; + + const returnToLive = async () => { + setIsLoadingHistory(true); + try { + await fetch(`${API_URL}/history/live`, { method: 'POST' }); + setHistoricalDate(''); + } catch (e) {} + setIsLoadingHistory(false); + }; + const avgVolumePerCluster = history.length > 0 ? (history.reduce((acc, c) => acc + (c.total_volume || 0), 0) / history.length).toFixed(0) : 0; const allClusters = [...history]; @@ -133,7 +187,7 @@ export default function App() { {/* Premium Header */} -
+
Yu @@ -146,8 +200,26 @@ export default function App() {
- {/* Connection Status indicator */} + {/* Connection Status & History indicator */}
+
+ loadHistory(e.target.value)} + className="bg-transparent text-slate-300 text-xs px-2 py-1 outline-none cursor-pointer" + title="Load Historical Date" + /> + {historicalDate && ( + + )} +
+ {lastTickTime && (
Last Update: {lastTickTime.toLocaleTimeString()} @@ -175,7 +247,17 @@ export default function App() { {/* Main Body Layout */}
{/* Footprint Chart Panel */} -
+
+ + {isLoadingHistory && ( +
+
+
+ Fetching History... +
+
+ )} +
@@ -200,7 +284,7 @@ export default function App() {
Target Symbol: - USTEC + {targetSymbol}
@@ -291,7 +375,7 @@ export default function App() {
- {/* Delta threshold — only relevant when mode=delta */} + {/* Range thresholds */} {closeMode === 'delta' && (
@@ -304,15 +388,54 @@ export default function App() { 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" /> -
- 10010 000 +
+ )} + {closeMode === 'volume' && ( +
+
+ VOLUME THRESHOLD + {volumeMax.toLocaleString()}
+ { setVolumeMax(Number(e.target.value)); setConfigDirty(true); }} + className="w-full accent-[#00E676] bg-slate-800 rounded-lg h-1.5 appearance-none cursor-pointer" + /> +
+ )} + {closeMode === 'time' && ( +
+
+ TIME THRESHOLD (seconds) + {timeSeconds}s +
+ { setTimeSeconds(Number(e.target.value)); setConfigDirty(true); }} + className="w-full accent-[#00E676] bg-slate-800 rounded-lg h-1.5 appearance-none cursor-pointer" + /> +
+ )} + {closeMode === 'range' && ( +
+
+ RANGE THRESHOLD (points) + {rangePoints} +
+ { setRangePoints(Number(e.target.value)); setConfigDirty(true); }} + className="w-full accent-[#00E676] bg-slate-800 rounded-lg h-1.5 appearance-none cursor-pointer" + />
)} {/* Apply button */} + +
+
+ Executes via MQL5 CTrade API with OCO brackets.
Latência estimada: < 20ms +
+
+ +
diff --git a/frontend/src/FootprintCanvas.jsx b/frontend/src/FootprintCanvas.jsx index 953cb27..97765d1 100644 --- a/frontend/src/FootprintCanvas.jsx +++ b/frontend/src/FootprintCanvas.jsx @@ -1,6 +1,6 @@ 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 }) { +export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultiplier = 1, viewMode = 'bidask', imbalanceRatio = 300, onStepChange, bid = 0, ask = 0, domData = null, bigTrades = [] }) { const canvasRef = useRef(null); // Navigation & Scale State @@ -408,20 +408,41 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli // Draw Volume Profile Overlay Panel (Left Side) const volProfileWidth = 140; const volumeProfile = {}; + const deltaProfile = {}; // Phase 5 let maxProfileVolume = 0; + let maxDeltaAbs = 0; + + // VWAP Calculation + let cumulativeVolume = 0; + let cumulativePriceVolume = 0; + clusters.forEach(cluster => { const lvls = cluster.levels || {}; Object.keys(lvls).forEach(pStr => { const p = parseFloat(pStr); const data = lvls[pStr]; const binPrice = Math.round(p / actualTickSize) * actualTickSize; - volumeProfile[binPrice] = (volumeProfile[binPrice] || 0) + (data.total || 0); + + const vol = data.total || 0; + const delta = (data.buy_vol || 0) - (data.sell_vol || 0); + + volumeProfile[binPrice] = (volumeProfile[binPrice] || 0) + vol; + deltaProfile[binPrice] = (deltaProfile[binPrice] || 0) + delta; + + cumulativeVolume += vol; + cumulativePriceVolume += (binPrice * vol); + if (volumeProfile[binPrice] > maxProfileVolume) { maxProfileVolume = volumeProfile[binPrice]; } + if (Math.abs(deltaProfile[binPrice]) > maxDeltaAbs) { + maxDeltaAbs = Math.abs(deltaProfile[binPrice]); + } }); }); + const vwap = cumulativeVolume > 0 ? (cumulativePriceVolume / cumulativeVolume) : null; + // Value Area Calculation (70% of total volume) let totalDayVolume = 0; let dayPocPrice = null; @@ -521,13 +542,103 @@ export default function FootprintCanvas({ clusters, tickSize = 1.0, stepMultipli } ctx.fillRect(0, y, barWidth, rowHeight - 2); + + // Draw Delta Profile inside the Volume bar + const delta = deltaProfile[pStr] || 0; + if (maxDeltaAbs > 0 && delta !== 0) { + const deltaBarWidth = (Math.abs(delta) / maxDeltaAbs) * (volProfileWidth - 5) * 0.5; // Max 50% width + ctx.fillStyle = delta > 0 ? 'rgba(0, 230, 118, 0.7)' : 'rgba(255, 23, 68, 0.7)'; + ctx.fillRect(0, y + rowHeight / 2 - 2, deltaBarWidth, 4); + } }); + // Draw VWAP Line + if (vwap !== null) { + const vwapY = getPriceY(vwap); + ctx.strokeStyle = '#FF4081'; // Pink VWAP + ctx.lineWidth = 2; + ctx.setLineDash([5, 5]); + ctx.beginPath(); + ctx.moveTo(0, vwapY); + ctx.lineTo(width, vwapY); + ctx.stroke(); + ctx.setLineDash([]); + + // VWAP Label + ctx.fillStyle = '#FF4081'; + ctx.font = 'bold 10px JetBrains Mono, monospace'; + ctx.textAlign = 'right'; + ctx.fillText('VWAP', width - axisWidth - 5, vwapY - 5); + } + // Panel Title ctx.fillStyle = '#94A3B8'; ctx.font = '10px JetBrains Mono, monospace'; ctx.textAlign = 'center'; - ctx.fillText('VOL PROFILE', volProfileWidth / 2, 20); + ctx.fillText('VOL & DELTA PROF', volProfileWidth / 2, 20); + } + + // --- DOM (Depth of Market) Visualization (Phase 5) --- + if (domData) { + const maxDomVol = Math.max( + ...((domData.asks || []).map(d => d[1])), + ...((domData.bids || []).map(d => d[1])), + 1 + ); + const maxDomWidth = 100; // pixels + const domBaseX = width - axisWidth; + + // Draw Asks + (domData.asks || []).forEach(([p, v]) => { + const y = getPriceY(p) - rowHeight / 2; + const barW = (v / maxDomVol) * maxDomWidth; + ctx.fillStyle = 'rgba(239, 68, 68, 0.4)'; // Red for asks + ctx.fillRect(domBaseX - barW, y, barW, rowHeight - 2); + ctx.fillStyle = 'rgba(239, 68, 68, 0.9)'; + ctx.font = '9px JetBrains Mono, monospace'; + ctx.textAlign = 'right'; + ctx.fillText(v.toString(), domBaseX - barW - 4, y + rowHeight / 2 + 3); + }); + + // Draw Bids + (domData.bids || []).forEach(([p, v]) => { + const y = getPriceY(p) - rowHeight / 2; + const barW = (v / maxDomVol) * maxDomWidth; + ctx.fillStyle = 'rgba(59, 130, 246, 0.4)'; // Blue for bids + ctx.fillRect(domBaseX - barW, y, barW, rowHeight - 2); + ctx.fillStyle = 'rgba(59, 130, 246, 0.9)'; + ctx.font = '9px JetBrains Mono, monospace'; + ctx.textAlign = 'right'; + ctx.fillText(v.toString(), domBaseX - barW - 4, y + rowHeight / 2 + 3); + }); + } + + // --- Big Trades Visualization --- + if (bigTrades && bigTrades.length > 0 && clusters.length > 0) { + // Find the last cluster's X + const lastColIdx = clusters.length - 1; + const totalClustersWidth = clusters.length * (colWidth + colGap); + const startX = width - axisWidth - scrollOffset.x - totalClustersWidth + 100; + const lastColX = startX + lastColIdx * (colWidth + colGap); + + bigTrades.forEach(bt => { + const y = getPriceY(bt.price); + // Draw a glowing circle for the Big Trade + ctx.beginPath(); + ctx.arc(lastColX + colWidth / 2, y, 12, 0, 2 * Math.PI); + ctx.fillStyle = bt.is_buy ? 'rgba(59, 130, 246, 0.8)' : 'rgba(239, 68, 68, 0.8)'; + ctx.fill(); + ctx.lineWidth = 2; + ctx.strokeStyle = '#FFF'; + ctx.stroke(); + + // Volume text + ctx.fillStyle = '#FFF'; + ctx.font = 'bold 9px Outfit'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(Math.round(bt.volume).toString(), lastColX + colWidth / 2, y); + }); } // Draw Vertical Price Axis (Right Side) diff --git a/frontend/src/utils/AlertEngine.js b/frontend/src/utils/AlertEngine.js index 4104129..3cc3ebd 100644 --- a/frontend/src/utils/AlertEngine.js +++ b/frontend/src/utils/AlertEngine.js @@ -10,6 +10,8 @@ class AlertEngine { playAudio(message) { if ('speechSynthesis' in window) { + // Prevent spamming the queue + window.speechSynthesis.cancel(); const msg = new SpeechSynthesisUtterance(message); msg.rate = 1.2; msg.pitch = 1.1; @@ -19,6 +21,13 @@ class AlertEngine { } } + processBigTrade(trade) { + if (!trade) return; + const direction = trade.is_buy ? 'Big Buy' : 'Big Sell'; + const volume = Math.round(trade.volume); + this.playAudio(`${direction} ${volume} lots!`); + } + processClusters(clusters, pushToast) { if (!clusters || clusters.length === 0) return; diff --git a/mql5/YuCluster_Order_Router.mq5 b/mql5/YuCluster_Order_Router.mq5 new file mode 100644 index 0000000..eaa9f73 --- /dev/null +++ b/mql5/YuCluster_Order_Router.mq5 @@ -0,0 +1,115 @@ +//+------------------------------------------------------------------+ +//| YuCluster_Order_Router.mq5 | +//| Integração Python <-> CTrade | +//+------------------------------------------------------------------+ +#property description "Roteador de Execução Institucional OCO" +#property version "1.00" + +// 1. Incluindo a Biblioteca CTrade para facilitar o envio de ordens +#include + +// Instanciando o objeto de trade +CTrade trade; + +//+------------------------------------------------------------------+ +//| VARIÁVEIS DE ENTRADA (ISOLANDO O FATOR PSICOLÓGICO) | +//+------------------------------------------------------------------+ +input group "=== Gestão de Risco OCO ===" +input double InpLote = 1.0; // Lote (Tamanho da Posição) +input int InpStopLoss = 150; // Stop Loss (em pontos) +input int InpTakeProfit = 300; // Take Profit (em pontos) + +input group "=== Configurações do Robô ===" +input ulong InpMagicNumber = 123456; // Número Mágico (CPF do Robô) + +//+------------------------------------------------------------------+ +//| FUNÇÃO DE INICIALIZAÇÃO | +//+------------------------------------------------------------------+ +int OnInit() + { + // Configura o Número Mágico para que o EA gerencie apenas as próprias ordens + trade.SetExpertMagicNumber(InpMagicNumber); + + // Define o desvio máximo aceitável (Slippage) + trade.SetDeviationInPoints(5); + + Print("Roteador Institucional Iniciado. Fator emocional desativado."); + return(INIT_SUCCEEDED); + } + +//+------------------------------------------------------------------+ +//| FUNÇÃO DE EXECUÇÃO DE COMPRA A MERCADO | +//+------------------------------------------------------------------+ +void ExecutarCompra() + { + // Atualiza os dados de preço de mercado (Tick) + MqlTick tick; + SymbolInfoTick(_Symbol, tick); + + // Calcula os níveis de Stop Loss e Take Profit engessados + double sl = tick.ask - (InpStopLoss * _Point); + double tp = tick.ask + (InpTakeProfit * _Point); + + // Normaliza as casas decimais de acordo com o ativo (Tick Size) + sl = NormalizeDouble(sl, _Digits); + tp = NormalizeDouble(tp, _Digits); + + // Envia a ordem a mercado instantaneamente + if(trade.Buy(InpLote, _Symbol, tick.ask, sl, tp, "YuCluster: Compra na POC")) + { + Print("Compra executada com sucesso! SL e TP na pedra."); + } + else + { + Print("Erro ao executar compra. Código: ", trade.ResultRetcode()); + } + } + +//+------------------------------------------------------------------+ +//| FUNÇÃO DE EXECUÇÃO DE VENDA A MERCADO | +//+------------------------------------------------------------------+ +void ExecutarVenda() + { + // Atualiza os dados de preço de mercado (Tick) + MqlTick tick; + SymbolInfoTick(_Symbol, tick); + + // Calcula os níveis de Stop Loss e Take Profit engessados (invertido para venda) + double sl = tick.bid + (InpStopLoss * _Point); + double tp = tick.bid - (InpTakeProfit * _Point); + + // Normaliza os cálculos + sl = NormalizeDouble(sl, _Digits); + tp = NormalizeDouble(tp, _Digits); + + // Envia a ordem a mercado instantaneamente + if(trade.Sell(InpLote, _Symbol, tick.bid, sl, tp, "YuCluster: Venda no Imbalance")) + { + Print("Venda executada com sucesso! SL e TP na pedra."); + } + else + { + Print("Erro ao executar venda. Código: ", trade.ResultRetcode()); + } + } + +//+------------------------------------------------------------------+ +//| LAÇO PRINCIPAL (Aguardando o gatilho do Python) | +//+------------------------------------------------------------------+ +void OnTick() + { + // Aqui o EA fica "escutando" variáveis globais do terminal ou requisições + // que o nosso backend em Python enviará ao detectar o botão clicado no Painel Web. + + if(GlobalVariableGet("SINAL_PYTHON_COMPRA") == 1) + { + ExecutarCompra(); + GlobalVariableSet("SINAL_PYTHON_COMPRA", 0); // Reseta o sinal + } + + if(GlobalVariableGet("SINAL_PYTHON_VENDA") == 1) + { + ExecutarVenda(); + GlobalVariableSet("SINAL_PYTHON_VENDA", 0); // Reseta o sinal + } + }