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:
co-authored by
Claude Sonnet 4.6
parent
0a185c0223
commit
11a0921e20
+28
-22
@@ -17,6 +17,7 @@ class FootprintCluster:
|
||||
self.poc: Optional[float] = None
|
||||
self.total_delta: float = 0.0
|
||||
self.total_volume: float = 0.0
|
||||
self.total_ticks: int = 0
|
||||
|
||||
# levels: price_float -> { 'ask': float, 'bid': float }
|
||||
# internally we keep float keys to make sorting and arithmetic easy
|
||||
@@ -39,7 +40,8 @@ class FootprintCluster:
|
||||
self.open_price = price
|
||||
|
||||
self.close_price = price
|
||||
|
||||
self.total_ticks += 1
|
||||
|
||||
# Round price to the nearest tick_size to avoid float precision issues
|
||||
rounded_price = round(price / self.tick_size) * self.tick_size
|
||||
|
||||
@@ -259,29 +261,32 @@ class FootprintCluster:
|
||||
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
|
||||
mode = settings.CLUSTER_CLOSE_MODE
|
||||
|
||||
# Volume safety cap — always applied (prevents runaway cluster if market halts)
|
||||
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"
|
||||
|
||||
|
||||
# Primary closing condition based on configured mode
|
||||
if mode == "delta":
|
||||
if abs(self.total_delta) >= settings.CLUSTER_DELTA_MAX:
|
||||
return "delta"
|
||||
|
||||
elif mode == "range":
|
||||
if self.high is not None and self.low is not None:
|
||||
if (self.high - self.low) >= settings.CLUSTER_RANGE_POINTS:
|
||||
return "range"
|
||||
|
||||
elif mode == "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"
|
||||
|
||||
elif mode == "volume":
|
||||
if self.total_volume >= settings.CLUSTER_VOLUME_MAX:
|
||||
return "volume"
|
||||
|
||||
return None
|
||||
|
||||
def close(self, reason: str) -> None:
|
||||
@@ -313,6 +318,7 @@ class FootprintCluster:
|
||||
"poc": float(self.poc) if self.poc is not None else None,
|
||||
"total_delta": float(self.total_delta),
|
||||
"total_volume": float(self.total_volume),
|
||||
"total_ticks": int(self.total_ticks),
|
||||
"levels": levels_str,
|
||||
"stacked": {
|
||||
"buy": bool(self.stacked.get("buy", False)),
|
||||
|
||||
+43
-12
@@ -23,6 +23,8 @@ class MT5Collector:
|
||||
self.connected = False
|
||||
self.last_tick_time_msc = 0
|
||||
self.seen_ticks_buffer = set()
|
||||
self.last_mid_price = 0.0
|
||||
self.last_is_buy = True
|
||||
|
||||
async def connect_mt5(self) -> bool:
|
||||
"""
|
||||
@@ -30,8 +32,8 @@ class MT5Collector:
|
||||
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)
|
||||
# Try to connect to any running MT5 terminal without specifying path
|
||||
initialized = await asyncio.to_thread(mt5.initialize)
|
||||
if not initialized:
|
||||
err = await asyncio.to_thread(mt5.last_error)
|
||||
logger.error(f"MT5 initialize failed: {err}")
|
||||
@@ -98,9 +100,9 @@ class MT5Collector:
|
||||
else:
|
||||
backoff = 1.0
|
||||
|
||||
# Fetch from 6 hours ago so the chart isn't empty when started
|
||||
# Fetch from 48 hours ago so the chart isn't empty when started (covers weekends)
|
||||
from datetime import datetime, timedelta
|
||||
start_time_dt = datetime.now() - timedelta(hours=6)
|
||||
start_time_dt = datetime.now() - timedelta(hours=48)
|
||||
ticks = await asyncio.to_thread(
|
||||
mt5.copy_ticks_from, self.symbol, start_time_dt, 100000, mt5.COPY_TICKS_ALL
|
||||
)
|
||||
@@ -146,20 +148,49 @@ class MT5Collector:
|
||||
|
||||
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']
|
||||
flags = int(tick['flags'])
|
||||
bid_price = float(tick['bid'])
|
||||
ask_price = float(tick['ask'])
|
||||
last_price = float(tick['last'])
|
||||
|
||||
# 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:
|
||||
mid_price = (bid_price + ask_price) / 2.0 if (bid_price > 0 and ask_price > 0) else 0.0
|
||||
prev_mid = self.last_mid_price
|
||||
|
||||
# Update direction tracker from mid movement
|
||||
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
|
||||
|
||||
# Determine price (use mid as best proxy for CFD quote feed)
|
||||
price = last_price if last_price > 0 else (mid_price if mid_price > 0 else (bid_price if bid_price > 0 else ask_price))
|
||||
|
||||
# Volume = price movement in tick-size units (how the YuCluster measures activity)
|
||||
tick_sz = self.aggregator.tick_size if self.aggregator.tick_size > 0 else 0.01
|
||||
if prev_mid > 0 and mid_price > 0:
|
||||
price_steps = abs(mid_price - prev_mid) / tick_sz
|
||||
volume = max(price_steps, 1.0)
|
||||
else:
|
||||
volume = 1.0
|
||||
|
||||
is_buy = classify_tick(price, tick['bid'], tick['ask'], flags)
|
||||
# Determine direction
|
||||
if flags & 32:
|
||||
is_buy = True
|
||||
elif flags & 64:
|
||||
is_buy = False
|
||||
else:
|
||||
is_buy = self.last_is_buy
|
||||
|
||||
active_json, closed_json = self.aggregator.process_tick(price, volume, is_buy, msc)
|
||||
|
||||
self.on_update_callback(active_json, closed_json)
|
||||
# Only broadcast during live trading (within 10s of now) to avoid
|
||||
# flooding the WebSocket during historical replay
|
||||
import time as _time
|
||||
is_live = ((_time.time() * 1000) - msc) < 10_000
|
||||
if is_live:
|
||||
self.on_update_callback(active_json, closed_json)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
+43
-5
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user