b35fd2eb5a
- 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>
236 lines
7.6 KiB
Python
236 lines
7.6 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
|
|
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")
|
|
|
|
# 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], 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.
|
|
closed_json is None between cluster closes — frontend handles that gracefully.
|
|
"""
|
|
if not active_connections:
|
|
return
|
|
message = {
|
|
"type": "tick",
|
|
"active": active_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:
|
|
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_history_ready():
|
|
"""Notify all clients that historical replay is complete — they should refetch /history."""
|
|
if not active_connections:
|
|
return
|
|
async def _send():
|
|
for ws in list(active_connections):
|
|
try:
|
|
await ws.send_json({"type": "history_ready"})
|
|
except Exception:
|
|
active_connections.discard(ws)
|
|
asyncio.create_task(_send())
|
|
|
|
# Initialize MT5 Collector
|
|
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."""
|
|
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,
|
|
"symbol": settings.MT5_SYMBOL,
|
|
}
|
|
|
|
@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}")
|
|
|
|
# Notify clients to clear history, then reset and replay with new config
|
|
for ws in list(active_connections):
|
|
try:
|
|
await ws.send_json({"type": "reset"})
|
|
except Exception:
|
|
active_connections.discard(ws)
|
|
collector.request_replay_reset()
|
|
|
|
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."""
|
|
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)}")
|
|
|
|
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}")
|
|
|
|
async def heartbeat():
|
|
"""Ping every 20s to keep the connection alive through proxies/browsers."""
|
|
try:
|
|
while True:
|
|
await asyncio.sleep(20)
|
|
await websocket.send_json({"type": "ping"})
|
|
except Exception:
|
|
pass
|
|
|
|
ping_task = asyncio.create_task(heartbeat())
|
|
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)
|
|
finally:
|
|
ping_task.cancel()
|
|
|
|
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",
|
|
)
|