feat: tick split, imbalance dots (desativado), session start detection

- Tick split via while-loop no process_tick: clusters fecham em +-800 delta sem overshoot
- Session start detection: busca maior gap em 3000 bars M1 para detectar abertura de sessao
- Imbalance dots: codigo presente mas desativado (6 abordagens testadas, todas geraram dots em todos os levels com dados CFD polling)
- ask_events/bid_events tracking por level (para futura implementacao de imbalance)
- Bottom panel redesign: volume bars + delta blocks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
rufinomec-afk
2026-06-07 16:05:50 -03:00
parent 54f269b0ce
commit 3f48ef27ed
+72 -45
View File
@@ -1,8 +1,11 @@
import uuid
import time
import logging
from typing import Dict, Any, List, Optional
from config import settings
logger = logging.getLogger("aggregator")
class FootprintCluster:
def __init__(self, tick_size: float = 1.0):
self.tick_size = tick_size
@@ -48,12 +51,14 @@ class FootprintCluster:
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}
self.levels[rounded_price] = {"ask": 0.0, "bid": 0.0, "ask_events": 0, "bid_events": 0}
if is_buy:
self.levels[rounded_price]["ask"] += volume
self.levels[rounded_price]["ask_events"] += 1
else:
self.levels[rounded_price]["bid"] += volume
self.levels[rounded_price]["bid_events"] += 1
# Update High/Low
if self.high is None or rounded_price > self.high:
@@ -104,30 +109,13 @@ class FootprintCluster:
imbalances_buy = {}
imbalances_sell = {}
# IMBALANCE DESATIVADO TEMPORARIAMENTE
# Todas as abordagens testadas geraram dots em todos os levels.
# Ver project_imbalance_research.md para histórico completo das tentativas.
# Reativar quando encontrar a abordagem correta.
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
imbalances_buy[price] = False
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
@@ -339,32 +327,71 @@ class Aggregator:
self.active_cluster = FootprintCluster(tick_size=self.tick_size)
self.history: List[Dict[str, Any]] = []
def _close_active(self, reason: str) -> Dict[str, Any]:
self.active_cluster.close(reason)
closed = self.active_cluster.to_json()
from datetime import datetime
ts = closed.get('close_time') or closed.get('open_time') or 0
dt = datetime.fromtimestamp(ts / 1000.0).strftime("%H:%M:%S") if ts else "?"
logger.info(f"CLUSTER CLOSED [{dt}] vol={closed['total_volume']:.0f} delta={closed['total_delta']:.0f} reason={reason}")
self.history.append(closed)
if len(self.history) > settings.HISTORY_BUFFER_SIZE:
self.history.pop(0)
self.active_cluster = FootprintCluster(tick_size=self.tick_size)
return closed
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.
In delta mode, splits ticks (via while loop) so no cluster ever overshoots ±CLUSTER_DELTA_MAX.
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
last_closed = None
if settings.CLUSTER_CLOSE_MODE != "delta":
self.active_cluster.add_tick(price, volume, is_buy, timestamp_msc)
close_reason = self.active_cluster.should_close(timestamp_msc)
if close_reason:
last_closed = self._close_active(close_reason)
return self.active_cluster.to_json(), last_closed
remaining = volume
while remaining > 0:
current_delta = self.active_cluster.total_delta
# If cluster is already at/beyond threshold (from a previous leftover), close it first
if self.active_cluster.total_ticks > 0 and abs(current_delta) >= settings.CLUSTER_DELTA_MAX:
last_closed = self._close_active("delta")
continue
contribution = remaining if is_buy else -remaining
projected = current_delta + contribution
if abs(projected) <= settings.CLUSTER_DELTA_MAX:
# Remaining fits — add and check for any close condition
self.active_cluster.add_tick(price, remaining, is_buy, timestamp_msc)
close_reason = self.active_cluster.should_close(timestamp_msc)
if close_reason:
last_closed = self._close_active(close_reason)
break
# Split: how much fits before hitting the limit
if is_buy:
capacity = settings.CLUSTER_DELTA_MAX - current_delta # always > 0 here
else:
capacity = current_delta + settings.CLUSTER_DELTA_MAX # always > 0 here
capacity = max(capacity, 0.0)
if capacity > 0:
self.active_cluster.add_tick(price, capacity, is_buy, timestamp_msc)
last_closed = self._close_active("delta")
remaining -= capacity
return self.active_cluster.to_json(), last_closed
def classify_tick(last: float, bid: float, ask: float, flags: int) -> bool: