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>
This commit is contained in:
rufinomec-afk
2026-06-06 14:57:02 -03:00
co-authored by Claude Sonnet 4.6
parent 0a185c0223
commit 11a0921e20
7 changed files with 464 additions and 258 deletions
+43 -5
View File
@@ -5,6 +5,7 @@ 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
@@ -25,18 +26,23 @@ 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.
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
}
for connection in list(active_connections):
async def safe_send(ws: WebSocket, msg: dict):
try:
asyncio.create_task(connection.send_json(message))
except Exception as e:
logger.error(f"Error sending update to client: {e}")
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)
@@ -75,6 +81,38 @@ app.add_middleware(
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."""