Files
yuclusters-mt5/backend/server.py
T
rufinomec-afk 11a0921e20 feat: visual e coleta de dados do footprint chart
- Footprint bars bid/ask por nível com cor dominante
- Painel inferior duplo: volume (barras) + delta (blocos azul/laranja)
- Linha de preço atual ciano dashed com tag no eixo
- Timestamp por cluster no eixo X
- Drag no eixo de tempo para zoom horizontal
- Drag no eixo de preço para ajustar step multiplier
- Coleta de volume via price-step method para delta correto
- Guard is_live para evitar flood de WebSocket no replay histórico
- total_ticks adicionado ao aggregator

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:57:02 -03:00

155 lines
5.0 KiB
Python

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 pydantic import BaseModel
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.
Only broadcasts when a cluster closes (not every tick) to avoid flooding during replay.
Removes dead connections automatically.
"""
if not active_connections or closed_json is None:
return
message = {
"type": "tick",
"active": active_json,
"closed": closed_json
}
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))
# 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=["*"],
)
class ConfigUpdate(BaseModel):
close_mode: Optional[str] = None
delta_max: Optional[float] = None
volume_max: Optional[float] = None
range_points: Optional[float] = None
time_seconds: Optional[float] = None
@app.get("/config")
async def get_config():
return {
"close_mode": settings.CLUSTER_CLOSE_MODE,
"delta_max": settings.CLUSTER_DELTA_MAX,
"volume_max": settings.CLUSTER_VOLUME_MAX,
"range_points": settings.CLUSTER_RANGE_POINTS,
"time_seconds": settings.CLUSTER_TIME_SECONDS,
}
@app.post("/config")
async def update_config(update: ConfigUpdate):
if update.close_mode is not None:
settings.CLUSTER_CLOSE_MODE = update.close_mode
if update.delta_max is not None:
settings.CLUSTER_DELTA_MAX = update.delta_max
if update.volume_max is not None:
settings.CLUSTER_VOLUME_MAX = update.volume_max
if update.range_points is not None:
settings.CLUSTER_RANGE_POINTS = update.range_points
if update.time_seconds is not None:
settings.CLUSTER_TIME_SECONDS = update.time_seconds
logger.info(f"Config updated: mode={settings.CLUSTER_CLOSE_MODE}, delta_max={settings.CLUSTER_DELTA_MAX}")
return {"ok": True}
@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",
)