Commit inicial: Projeto YuClusters

This commit is contained in:
Thiago Moura
2026-06-04 18:12:47 -03:00
commit 0a185c0223
28 changed files with 5093 additions and 0 deletions
+389
View File
@@ -0,0 +1,389 @@
import uuid
import time
from typing import Dict, Any, List, Optional
from config import settings
class FootprintCluster:
def __init__(self, tick_size: float = 1.0):
self.tick_size = tick_size
self.cluster_id = str(uuid.uuid4())
self.status = "active"
self.open_time: Optional[int] = None
self.close_reason: Optional[str] = None
self.open_price: Optional[float] = None
self.close_price: Optional[float] = None
self.high: Optional[float] = None
self.low: Optional[float] = None
self.poc: Optional[float] = None
self.total_delta: float = 0.0
self.total_volume: float = 0.0
# levels: price_float -> { 'ask': float, 'bid': float }
# internally we keep float keys to make sorting and arithmetic easy
self.levels: Dict[float, Dict[str, float]] = {}
self.stacked: Dict[str, Any] = {
"buy": False,
"sell": False,
"price_range": []
}
self.advanced_metrics: Dict[str, Any] = {
"poc_position": None,
"pattern": None,
"top_extreme": None,
"bottom_extreme": None
}
def add_tick(self, price: float, volume: float, is_buy: bool, timestamp_msc: int) -> None:
if self.open_time is None:
self.open_time = timestamp_msc
self.open_price = price
self.close_price = price
# Round price to the nearest tick_size to avoid float precision issues
rounded_price = round(price / self.tick_size) * self.tick_size
if rounded_price not in self.levels:
self.levels[rounded_price] = {"ask": 0.0, "bid": 0.0}
if is_buy:
self.levels[rounded_price]["ask"] += volume
else:
self.levels[rounded_price]["bid"] += volume
# Update High/Low
if self.high is None or rounded_price > self.high:
self.high = rounded_price
if self.low is None or rounded_price < self.low:
self.low = rounded_price
# Recalculate totals, POC, imbalances and stacked imbalances
self._recalculate()
def _recalculate(self) -> None:
if not self.levels:
return
self.total_volume = 0.0
self.total_delta = 0.0
# First pass: calc totals and delta per level
for price, data in self.levels.items():
ask = data["ask"]
bid = data["bid"]
lvl_delta = ask - bid
lvl_total = ask + bid
self.total_volume += lvl_total
self.total_delta += lvl_delta
# Find POC: level with the highest total volume.
# Tie-breaker: choose the highest price level.
sorted_prices = sorted(self.levels.keys())
best_price = sorted_prices[0]
max_total = -1.0
for price in sorted_prices:
total_vol = self.levels[price]["ask"] + self.levels[price]["bid"]
if total_vol > max_total:
max_total = total_vol
best_price = price
elif total_vol == max_total:
if price > best_price:
best_price = price
self.poc = best_price
# Second pass: calculate imbalances diagonal and stacked imbalances
# To avoid division by zero:
# imbalance_buy[i] is True if ask_vol[i] >= R * bid_vol[i - tick_size]
# imbalance_sell[i] is True if bid_vol[i] >= R * ask_vol[i + tick_size]
R = settings.IMBALANCE_RATIO
imbalances_buy = {}
imbalances_sell = {}
for price in sorted_prices:
ask_val = self.levels[price]["ask"]
bid_val = self.levels[price]["bid"]
# Lower level price
lower_price = round((price - self.tick_size) / self.tick_size) * self.tick_size
bid_below = self.levels[lower_price]["bid"] if lower_price in self.levels else 0.0
# Upper level price
upper_price = round((price + self.tick_size) / self.tick_size) * self.tick_size
ask_above = self.levels[upper_price]["ask"] if upper_price in self.levels else 0.0
# Buy Imbalance (diagonal): ask vs bid_below
# Avoid triggering on 0 vs 0
if ask_val > 0 and ask_val >= R * bid_below:
imbalances_buy[price] = True
else:
imbalances_buy[price] = False
# Sell Imbalance (diagonal): bid vs ask_above
if bid_val > 0 and bid_val >= R * ask_above:
imbalances_sell[price] = True
else:
imbalances_sell[price] = False
# Detect stacked imbalances: 3+ consecutive levels with imbalance in the same direction
# Let's check contiguous price levels in steps of tick_size
stacked_buy = False
stacked_sell = False
stacked_buy_prices = []
stacked_sell_prices = []
min_consecutive = settings.STACKED_MIN_COUNT
# We need to check all possible price steps from low to high
if self.low is not None and self.high is not None:
current_price = self.low
consec_buy = []
consec_sell = []
while current_price <= self.high:
rounded_p = round(current_price / self.tick_size) * self.tick_size
# Check Buy
if imbalances_buy.get(rounded_p, False):
consec_buy.append(rounded_p)
else:
if len(consec_buy) >= min_consecutive:
stacked_buy = True
stacked_buy_prices.extend(consec_buy)
consec_buy = []
# Check Sell
if imbalances_sell.get(rounded_p, False):
consec_sell.append(rounded_p)
else:
if len(consec_sell) >= min_consecutive:
stacked_sell = True
stacked_sell_prices.extend(consec_sell)
consec_sell = []
current_price += self.tick_size
# Final check at the end of the loop
if len(consec_buy) >= min_consecutive:
stacked_buy = True
stacked_buy_prices.extend(consec_buy)
if len(consec_sell) >= min_consecutive:
stacked_sell = True
stacked_sell_prices.extend(consec_sell)
# Build output properties for stacked
self.stacked = {
"buy": stacked_buy,
"sell": stacked_sell,
"price_range": sorted(list(set(stacked_buy_prices + stacked_sell_prices)))
}
# Store imbalances back in levels for JSON output
for price in sorted_prices:
imb = None
if imbalances_buy.get(price, False) and imbalances_sell.get(price, False):
imb = "both"
elif imbalances_buy.get(price, False):
imb = "buy"
elif imbalances_sell.get(price, False):
imb = "sell"
self.levels[price]["delta"] = self.levels[price]["ask"] - self.levels[price]["bid"]
self.levels[price]["total"] = self.levels[price]["ask"] + self.levels[price]["bid"]
self.levels[price]["imbalance"] = imb
# Advanced Metrics Calculation
if self.high is not None and self.low is not None and self.high > self.low and self.total_volume > 0:
# 1. POC Position
poc_percent = (self.poc - self.low) / (self.high - self.low)
if poc_percent >= 0.65:
self.advanced_metrics["poc_position"] = "top"
elif poc_percent <= 0.35:
self.advanced_metrics["poc_position"] = "bottom"
else:
self.advanced_metrics["poc_position"] = "middle"
# 2. P and B Patterns
mid_price = (self.high + self.low) / 2.0
vol_above = sum(self.levels[p]["total"] for p in sorted_prices if p >= mid_price)
vol_below = sum(self.levels[p]["total"] for p in sorted_prices if p < mid_price)
if vol_above / self.total_volume > 0.65:
self.advanced_metrics["pattern"] = "P"
elif vol_below / self.total_volume > 0.65:
self.advanced_metrics["pattern"] = "B"
else:
self.advanced_metrics["pattern"] = "normal"
# 3. Extremes (Exhaustion / Absorption)
top_vol = self.levels[self.high]["total"]
next_top_price = round((self.high - self.tick_size) / self.tick_size) * self.tick_size
next_top_vol = self.levels.get(next_top_price, {}).get("total", 0.0)
avg_vol = self.total_volume / len(self.levels)
if top_vol < avg_vol * 0.2 and next_top_vol > avg_vol * 0.5:
self.advanced_metrics["top_extreme"] = "exhaustion"
elif top_vol > avg_vol * 2.5:
self.advanced_metrics["top_extreme"] = "absorption"
else:
self.advanced_metrics["top_extreme"] = "normal"
bottom_vol = self.levels[self.low]["total"]
next_bot_price = round((self.low + self.tick_size) / self.tick_size) * self.tick_size
next_bot_vol = self.levels.get(next_bot_price, {}).get("total", 0.0)
if bottom_vol < avg_vol * 0.2 and next_bot_vol > avg_vol * 0.5:
self.advanced_metrics["bottom_extreme"] = "exhaustion"
elif bottom_vol > avg_vol * 2.5:
self.advanced_metrics["bottom_extreme"] = "absorption"
else:
self.advanced_metrics["bottom_extreme"] = "normal"
# 4. Ratios (Exhaustion/Absorption quantification at extremes)
if next_top_vol > 0:
self.advanced_metrics["high_ratio"] = round(top_vol / next_top_vol, 2)
else:
self.advanced_metrics["high_ratio"] = round(top_vol, 2) if top_vol > 0 else 0.0
if next_bot_vol > 0:
self.advanced_metrics["low_ratio"] = round(bottom_vol / next_bot_vol, 2)
else:
self.advanced_metrics["low_ratio"] = round(bottom_vol, 2) if bottom_vol > 0 else 0.0
# 5. Delta Divergence (Price direction vs Order Flow Delta)
self.advanced_metrics["delta_divergence"] = False
if self.open_price is not None and self.close_price is not None:
is_bull = self.close_price > self.open_price
is_bear = self.close_price < self.open_price
if (is_bull and self.total_delta < 0) or (is_bear and self.total_delta > 0):
self.advanced_metrics["delta_divergence"] = True
def should_close(self, current_time_msc: int) -> Optional[str]:
# Check range
if self.high is not None and self.low is not None:
points = self.high - self.low
# If tick_size is 1.0, range is in points. Let's compare directly.
# (or points/tick_size >= CLUSTER_RANGE_POINTS)
# Standard MT5 points. If range in points exceeds setting:
if points >= settings.CLUSTER_RANGE_POINTS:
return "range"
# Check volume
if self.total_volume >= settings.CLUSTER_VOLUME_MAX:
return "volume"
# Check delta
if abs(self.total_delta) >= settings.CLUSTER_DELTA_MAX:
return "delta"
# Check time
if self.open_time is not None:
elapsed = (current_time_msc - self.open_time) / 1000.0
if elapsed >= settings.CLUSTER_TIME_SECONDS:
return "time"
return None
def close(self, reason: str) -> None:
self.status = "closed"
self.close_reason = reason
def to_json(self) -> Dict[str, Any]:
# Convert keys in levels to string for JSON compatibility
levels_str = {}
for price, data in sorted(self.levels.items(), reverse=True):
levels_str[f"{price:.5f}"] = {
"ask": float(data.get("ask", 0.0)),
"bid": float(data.get("bid", 0.0)),
"delta": float(data.get("delta", 0.0)),
"total": float(data.get("total", 0.0)),
"imbalance": data.get("imbalance", None)
}
return {
"cluster_id": self.cluster_id,
"tick_size": self.tick_size,
"status": self.status,
"open_time": int(self.open_time) if self.open_time is not None else None,
"close_reason": self.close_reason,
"open_price": float(self.open_price) if self.open_price is not None else None,
"close_price": float(self.close_price) if self.close_price is not None else None,
"high": float(self.high) if self.high is not None else None,
"low": float(self.low) if self.low is not None else None,
"poc": float(self.poc) if self.poc is not None else None,
"total_delta": float(self.total_delta),
"total_volume": float(self.total_volume),
"levels": levels_str,
"stacked": {
"buy": bool(self.stacked.get("buy", False)),
"sell": bool(self.stacked.get("sell", False)),
"price_range": [float(p) for p in self.stacked.get("price_range", [])]
},
"advanced_metrics": self.advanced_metrics
}
class Aggregator:
def __init__(self, tick_size: float = 1.0):
self.tick_size = tick_size
self.active_cluster = FootprintCluster(tick_size=self.tick_size)
self.history: List[Dict[str, Any]] = []
def process_tick(self, price: float, volume: float, is_buy: bool, timestamp_msc: int) -> tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
"""
Process a single tick.
Returns:
(active_cluster_json, closed_cluster_json_if_just_closed)
"""
self.active_cluster.add_tick(price, volume, is_buy, timestamp_msc)
# Check if the active cluster should be closed
close_reason = self.active_cluster.should_close(timestamp_msc)
closed_json = None
if close_reason:
self.active_cluster.close(close_reason)
closed_json = self.active_cluster.to_json()
# Save to history
self.history.append(closed_json)
if len(self.history) > settings.HISTORY_BUFFER_SIZE:
self.history.pop(0)
# Start new cluster
self.active_cluster = FootprintCluster(tick_size=self.tick_size)
# The next tick will set the open_time of the new cluster.
return self.active_cluster.to_json(), closed_json
def classify_tick(last: float, bid: float, ask: float, flags: int) -> bool:
"""
Classify tick aggressiveness.
TICK_FLAG_BUY = 32 -> BUY (True)
TICK_FLAG_SELL = 64 -> SELL (False)
TICK_FLAG_ASK = 4 -> ASK (True)
TICK_FLAG_BID = 2 -> BID (False)
"""
if flags & 32:
return True
elif flags & 64:
return False
if last > 0:
if last >= ask:
return True
elif last <= bid:
return False
# Forex quote ticks fallback
if flags & 4:
return True
elif flags & 2:
return False
# Standard fallback if between spread: closer to ask is BUY
if ask > bid and last > 0:
return (last - bid) >= (ask - last)
return True
+175
View File
@@ -0,0 +1,175 @@
import asyncio
import logging
import time
import sys
import os
from typing import Callable, Optional, Any
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
import MetaTrader5 as mt5
from config import settings
from backend.aggregator import Aggregator, classify_tick
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("mt5_collector")
class MT5Collector:
def __init__(self, aggregator: Aggregator, on_update_callback: Callable[[dict, Optional[dict]], Any]):
self.aggregator = aggregator
self.on_update_callback = on_update_callback
self.symbol = settings.MT5_SYMBOL
self.running = False
self.connected = False
self.last_tick_time_msc = 0
self.seen_ticks_buffer = set()
async def connect_mt5(self) -> bool:
"""
Attempts to initialize and login to the MetaTrader 5 terminal.
All MT5 calls are blocking, so they run in a thread executor.
"""
try:
mt5_path = r"C:\Program Files\MetaTrader 5\terminal64.exe"
initialized = await asyncio.to_thread(mt5.initialize, path=mt5_path)
if not initialized:
err = await asyncio.to_thread(mt5.last_error)
logger.error(f"MT5 initialize failed: {err}")
return False
if settings.MT5_LOGIN > 0:
login_success = await asyncio.to_thread(
mt5.login,
settings.MT5_LOGIN,
password=settings.MT5_PASSWORD,
server=settings.MT5_SERVER,
)
if not login_success:
err = await asyncio.to_thread(mt5.last_error)
logger.error(f"MT5 login failed: {err}")
await asyncio.to_thread(mt5.shutdown)
return False
symbol_info = await asyncio.to_thread(mt5.symbol_info, self.symbol)
if symbol_info is None:
logger.error(f"Symbol {self.symbol} not found.")
await asyncio.to_thread(mt5.shutdown)
return False
if not symbol_info.visible:
selected = await asyncio.to_thread(mt5.symbol_select, self.symbol, True)
if not selected:
logger.error(f"Failed to select/make visible symbol {self.symbol}.")
await asyncio.to_thread(mt5.shutdown)
return False
tick_size = symbol_info.trade_tick_size
if tick_size > 0:
self.aggregator.tick_size = tick_size
self.aggregator.active_cluster.tick_size = tick_size
logger.info(f"Set aggregator tick size to {tick_size}")
logger.info("Successfully connected to MetaTrader 5 and logged in.")
self.connected = True
return True
except Exception as e:
logger.error(f"Exception during MT5 connection: {e}")
return False
async def disconnect_mt5(self):
try:
await asyncio.to_thread(mt5.shutdown)
except Exception as e:
logger.error(f"Error during MT5 shutdown: {e}")
self.connected = False
async def start(self):
self.running = True
backoff = 1.0
while self.running:
if not self.connected:
success = await self.connect_mt5()
if not success:
logger.info(f"Reconnecting to MT5 in {backoff:.1f}s...")
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 60.0)
continue
else:
backoff = 1.0
# Fetch from 6 hours ago so the chart isn't empty when started
from datetime import datetime, timedelta
start_time_dt = datetime.now() - timedelta(hours=6)
ticks = await asyncio.to_thread(
mt5.copy_ticks_from, self.symbol, start_time_dt, 100000, mt5.COPY_TICKS_ALL
)
if ticks is not None and len(ticks) > 0:
self.last_tick_time_msc = ticks[0]['time_msc']
else:
self.last_tick_time_msc = int(start_time_dt.timestamp() * 1000)
# Polling loop
try:
from datetime import datetime
polling_dt = datetime.fromtimestamp(self.last_tick_time_msc / 1000.0)
ticks = await asyncio.to_thread(
mt5.copy_ticks_from,
self.symbol,
polling_dt,
1000,
mt5.COPY_TICKS_ALL,
)
if ticks is None:
err = await asyncio.to_thread(mt5.last_error)
logger.error(f"MT5 copy_ticks_from returned None: {err}")
self.connected = False
await self.disconnect_mt5()
continue
if len(ticks) > 0:
logger.info(f"Fetched {len(ticks)} ticks starting at {ticks[0]['time_msc']}")
for tick in ticks:
msc = tick['time_msc']
if msc < self.last_tick_time_msc:
continue
tick_id = (msc, tick['bid'], tick['ask'], tick['last'], tick['volume_real'], tick['flags'])
if msc == self.last_tick_time_msc and tick_id in self.seen_ticks_buffer:
continue
if msc > self.last_tick_time_msc:
self.seen_ticks_buffer.clear()
self.last_tick_time_msc = msc
self.seen_ticks_buffer.add(tick_id)
price = tick['last'] if tick['last'] > 0 else (tick['bid'] if tick['bid'] > 0 else tick['ask'])
volume = tick['volume_real'] if tick['volume_real'] > 0 else float(tick['volume'])
flags = tick['flags']
# Forex ticks often have volume=0 (they are just quote updates).
# We count each quote update as 1 unit of tick volume to build the footprint.
if volume == 0:
volume = 1.0
is_buy = classify_tick(price, tick['bid'], tick['ask'], flags)
active_json, closed_json = self.aggregator.process_tick(price, volume, is_buy, msc)
self.on_update_callback(active_json, closed_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)
async def stop(self):
self.running = False
await self.disconnect_mt5()
logger.info("MT5 Collector stopped.")
+116
View File
@@ -0,0 +1,116 @@
import asyncio
import logging
import sys
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from typing import Set, Dict, Any, Optional
# Add project root to sys.path
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
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("server")
# Shared state
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]):
"""
Callback executed by MT5Collector when a new tick is processed.
Schedules WebSocket sends on the event loop.
"""
message = {
"type": "tick",
"active": active_json,
"closed": closed_json
}
for connection in list(active_connections):
try:
asyncio.create_task(connection.send_json(message))
except Exception as e:
logger.error(f"Error sending update to client: {e}")
# Initialize MT5 Collector
collector = MT5Collector(aggregator, on_update_callback=broadcast_update)
async def _start_collector_delayed():
"""Wait a moment for the server to fully start, then begin polling MT5."""
await asyncio.sleep(1)
logger.info("Starting MT5 collector background task...")
await collector.start()
@asynccontextmanager
async def lifespan(app: FastAPI):
global collector_task
# Fire-and-forget: start collector AFTER yielding so uvicorn is ready
collector_task = asyncio.create_task(_start_collector_delayed())
logger.info("Server is starting up...")
yield
# Shutdown
logger.info("Stopping MT5 collector task...")
collector.running = False
collector_task.cancel()
try:
await collector_task
except asyncio.CancelledError:
pass
await collector.disconnect_mt5()
app = FastAPI(title="YuClusters Local Server", lifespan=lifespan)
# Add CORS Middleware so local frontend can query history
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/history")
async def get_history():
"""Returns the buffer of historical closed clusters."""
return aggregator.history
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
active_connections.add(websocket)
logger.info(f"Client connected. Active connections: {len(active_connections)}")
# Send the current active cluster state on connection
try:
await websocket.send_json({
"type": "init",
"active": aggregator.active_cluster.to_json()
})
except Exception as e:
logger.error(f"Error sending init state: {e}")
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
active_connections.discard(websocket)
logger.info(f"Client disconnected. Active connections: {len(active_connections)}")
except Exception as e:
logger.error(f"WebSocket error: {e}")
active_connections.discard(websocket)
if __name__ == "__main__":
import uvicorn
logger.info(f"Starting YuClusters server on port {settings.WS_PORT}...")
uvicorn.run(
"backend.server:app",
host="0.0.0.0",
port=settings.WS_PORT,
log_level="info",
)