merge: integra fase5 do Thiago (DOM, VWAP, big trades, trading, historical browser) mantendo fixes de ontem
- Combina on_history_ready_callback (replay reset) com on_big_trade_callback (Thiago) - DOM subscription + visualização no canvas - VWAP e delta profile no volume profile - Big trades com alerta visual no canvas - Endpoints /trade/buy, /trade/sell para execução via MQL5 - Historical Browser: date picker no header, /history/load e /history/live - Trading panel no sidebar com botões BUY/SELL - config/settings.py: mantém USTEC como padrão, adiciona BIG_TRADE_THRESHOLD - Todos os fixes de ontem preservados: broadcast live, history_ready, replay reset Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+124
-6
@@ -15,13 +15,15 @@ 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], on_history_ready_callback: Callable = 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
|
||||
self.connected = False
|
||||
self.historical_mode = False
|
||||
self.last_tick_time_msc = 0
|
||||
self.seen_ticks_buffer = set()
|
||||
self.last_mid_price = 0.0
|
||||
@@ -77,6 +79,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
|
||||
@@ -86,6 +93,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}")
|
||||
@@ -172,6 +180,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(
|
||||
@@ -262,6 +274,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
|
||||
@@ -284,15 +308,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."""
|
||||
|
||||
+52
-3
@@ -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 on every live tick.
|
||||
Always broadcasts the active cluster state so the frontend updates in real-time.
|
||||
@@ -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:
|
||||
@@ -57,7 +74,7 @@ def broadcast_history_ready():
|
||||
asyncio.create_task(_send())
|
||||
|
||||
# Initialize MT5 Collector
|
||||
collector = MT5Collector(aggregator, on_update_callback=broadcast_update, on_history_ready_callback=broadcast_history_ready)
|
||||
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."""
|
||||
@@ -108,6 +125,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")
|
||||
@@ -134,6 +152,37 @@ async def update_config(update: ConfigUpdate):
|
||||
|
||||
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."""
|
||||
|
||||
@@ -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
|
||||
+2
-1
@@ -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))
|
||||
|
||||
+167
-12
@@ -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) {}
|
||||
@@ -101,8 +117,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);
|
||||
|
||||
@@ -113,6 +137,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];
|
||||
@@ -138,7 +192,7 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
{/* Premium Header */}
|
||||
<header className="flex items-center justify-between px-6 py-4 bg-[#151B26] border-b border-slate-800 shadow-md">
|
||||
<header className="flex items-center justify-between px-6 py-4 bg-[#151B26] border-b border-slate-800 shadow-md relative z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 bg-gradient-to-tr from-[#00E676] to-[#00B0FF] rounded-lg flex items-center justify-center font-bold text-lg text-white shadow-lg">
|
||||
Yu
|
||||
@@ -151,8 +205,26 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Status indicator */}
|
||||
{/* Connection Status & History indicator */}
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-2 mr-4 bg-[#0B0E14] border border-slate-800 p-1 rounded-lg">
|
||||
<input
|
||||
type="date"
|
||||
value={historicalDate}
|
||||
onChange={(e) => 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 && (
|
||||
<button
|
||||
onClick={returnToLive}
|
||||
className="bg-[#FF4081] text-white text-[10px] font-bold px-2 py-1 rounded hover:bg-[#F50057] transition-colors"
|
||||
>
|
||||
LIVE
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lastTickTime && (
|
||||
<div className="text-slate-400 text-xs hidden sm:block">
|
||||
Last Update: <span className="font-mono text-slate-300">{lastTickTime.toLocaleTimeString()}</span>
|
||||
@@ -180,7 +252,17 @@ export default function App() {
|
||||
{/* Main Body Layout */}
|
||||
<main className="flex-1 flex overflow-hidden p-6 gap-6">
|
||||
{/* Footprint Chart Panel */}
|
||||
<div className="flex-1 flex flex-col h-full bg-[#151B26]/30 rounded-xl overflow-hidden">
|
||||
<div className="flex-1 flex flex-col h-full bg-[#151B26]/30 rounded-xl overflow-hidden relative">
|
||||
|
||||
{isLoadingHistory && (
|
||||
<div className="absolute inset-0 bg-[#0B0E14]/80 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-10 h-10 border-4 border-[#00B0FF] border-t-transparent rounded-full animate-spin"></div>
|
||||
<span className="mt-4 text-[#00B0FF] font-bold text-sm tracking-widest uppercase">Fetching History...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FootprintCanvas
|
||||
clusters={allClusters}
|
||||
tickSize={dynamicTickSize}
|
||||
@@ -190,6 +272,8 @@ export default function App() {
|
||||
onStepChange={setStepMultiplier}
|
||||
bid={lastBid}
|
||||
ask={lastAsk}
|
||||
domData={domData}
|
||||
bigTrades={bigTrades}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -205,7 +289,7 @@ export default function App() {
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-slate-400">Target Symbol:</span>
|
||||
<span className="font-mono font-semibold text-[#00B0FF] bg-[#00B0FF]/10 px-2 py-0.5 rounded">
|
||||
USTEC
|
||||
{targetSymbol}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-6">
|
||||
@@ -296,7 +380,7 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delta threshold — only relevant when mode=delta */}
|
||||
{/* Range thresholds */}
|
||||
{closeMode === 'delta' && (
|
||||
<div>
|
||||
<div className="flex justify-between mb-1">
|
||||
@@ -309,15 +393,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"
|
||||
/>
|
||||
<div className="flex justify-between text-[9px] text-slate-600 mt-0.5">
|
||||
<span>100</span><span>10 000</span>
|
||||
</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)}
|
||||
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'}`}
|
||||
>
|
||||
@@ -363,6 +486,38 @@ export default function App() {
|
||||
Use scroll wheel to move vertical scale.<br />
|
||||
Hold Shift + scroll wheel to scroll horizontal.
|
||||
</footer>
|
||||
{/* Trading Panel (Phase 5) */}
|
||||
<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">
|
||||
Trading Execution
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const res = await fetch(`${API_URL}/trade/buy`, { method: 'POST' });
|
||||
if (res.ok) pushToast("Buy Signal Sent (MQL5)", "info");
|
||||
}}
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white font-bold py-2 rounded shadow-md transition-colors text-sm"
|
||||
>
|
||||
BUY MKT
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const res = await fetch(`${API_URL}/trade/sell`, { method: 'POST' });
|
||||
if (res.ok) pushToast("Sell Signal Sent (MQL5)", "warning");
|
||||
}}
|
||||
className="flex-1 bg-red-600 hover:bg-red-500 text-white font-bold py-2 rounded shadow-md transition-colors text-sm"
|
||||
>
|
||||
SELL MKT
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 text-center leading-tight">
|
||||
Executes via MQL5 CTrade API with OCO brackets.<br/>Latência estimada: < 20ms
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
@@ -398,20 +398,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;
|
||||
@@ -511,13 +532,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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 <Trade\Trade.mqh>
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user