0206ef7cbb
Real-time orderflow trading system with absorption, initiative, sweep, exhaustion, and divergence detection. Features volume profile framing, state machine trade lifecycle, MT5 + Bybit feeds, FastAPI dashboard, and Telegram alerts for 30+ instruments. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
182 lines
6.1 KiB
Python
182 lines
6.1 KiB
Python
"""
|
|
Footprint Engine
|
|
Aggregates tick data into price-level bid/ask volume buckets.
|
|
Detects imbalances, strong levels, and unfinished auction levels.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass, field
|
|
from typing import Optional
|
|
|
|
from orderflow_system.data.models import Tick, Candle, FootprintLevel, Side
|
|
|
|
|
|
@dataclass
|
|
class FootprintBar:
|
|
"""Complete footprint for a single time bar."""
|
|
timestamp_ms: int = 0
|
|
open: float = 0.0
|
|
high: float = 0.0
|
|
low: float = 0.0
|
|
close: float = 0.0
|
|
levels: dict[float, FootprintLevel] = field(default_factory=dict)
|
|
|
|
@property
|
|
def total_buy_volume(self) -> float:
|
|
return sum(lv.ask_volume for lv in self.levels.values())
|
|
|
|
@property
|
|
def total_sell_volume(self) -> float:
|
|
return sum(lv.bid_volume for lv in self.levels.values())
|
|
|
|
@property
|
|
def delta(self) -> float:
|
|
return self.total_buy_volume - self.total_sell_volume
|
|
|
|
@property
|
|
def total_volume(self) -> float:
|
|
return self.total_buy_volume + self.total_sell_volume
|
|
|
|
def imbalance_levels(self, threshold: float = 3.0) -> list[tuple[float, str]]:
|
|
"""
|
|
Find price levels with strong imbalance (buy/sell ratio > threshold).
|
|
These are one-side-print levels — key for initiative auction detection.
|
|
Returns list of (price, 'buy'|'sell') for imbalanced levels.
|
|
"""
|
|
results = []
|
|
for price, lv in sorted(self.levels.items()):
|
|
if lv.bid_volume > 0 and lv.ask_volume / lv.bid_volume >= threshold:
|
|
results.append((price, "buy"))
|
|
elif lv.ask_volume > 0 and lv.bid_volume / lv.ask_volume >= threshold:
|
|
results.append((price, "sell"))
|
|
elif lv.bid_volume == 0 and lv.ask_volume > 0:
|
|
results.append((price, "buy"))
|
|
elif lv.ask_volume == 0 and lv.bid_volume > 0:
|
|
results.append((price, "sell"))
|
|
return results
|
|
|
|
def max_volume_level(self) -> Optional[tuple[float, FootprintLevel]]:
|
|
"""Price level with highest total volume = POC of this bar."""
|
|
if not self.levels:
|
|
return None
|
|
return max(self.levels.items(), key=lambda x: x[1].total_volume)
|
|
|
|
def absorption_at_level(self, price: float, tolerance: float = 0.0) -> Optional[FootprintLevel]:
|
|
"""Get footprint data at a specific price level."""
|
|
if price in self.levels:
|
|
return self.levels[price]
|
|
# Check with tolerance
|
|
for p, lv in self.levels.items():
|
|
if abs(p - price) <= tolerance:
|
|
return lv
|
|
return None
|
|
|
|
|
|
class FootprintEngine:
|
|
"""
|
|
Builds and analyzes footprint data from ticks or candles.
|
|
The footprint shows executed buy and sell orders at each price level,
|
|
revealing aggression, absorption, and imbalance.
|
|
"""
|
|
|
|
def __init__(self, tick_size: float = 0.1):
|
|
self.tick_size = tick_size
|
|
self._bar_history: list[FootprintBar] = []
|
|
self._max_history = 200
|
|
|
|
def build_from_candle(self, candle: Candle) -> FootprintBar:
|
|
"""Build footprint bar from a candle that already has footprint data."""
|
|
bar = FootprintBar(
|
|
timestamp_ms=candle.timestamp_ms,
|
|
open=candle.open,
|
|
high=candle.high,
|
|
low=candle.low,
|
|
close=candle.close,
|
|
levels=dict(candle.footprint),
|
|
)
|
|
self._bar_history.append(bar)
|
|
if len(self._bar_history) > self._max_history:
|
|
self._bar_history = self._bar_history[-self._max_history:]
|
|
return bar
|
|
|
|
def build_from_ticks(self, ticks: list[Tick], timestamp_ms: int = 0) -> FootprintBar:
|
|
"""Build footprint bar from raw ticks."""
|
|
if not ticks:
|
|
return FootprintBar(timestamp_ms=timestamp_ms)
|
|
|
|
levels: dict[float, FootprintLevel] = {}
|
|
prices = []
|
|
|
|
for t in ticks:
|
|
rounded = round(round(t.price / self.tick_size) * self.tick_size, 10)
|
|
prices.append(t.price)
|
|
|
|
if rounded not in levels:
|
|
levels[rounded] = FootprintLevel(price=rounded)
|
|
|
|
if t.is_buy:
|
|
levels[rounded].ask_volume += t.size
|
|
else:
|
|
levels[rounded].bid_volume += t.size
|
|
|
|
bar = FootprintBar(
|
|
timestamp_ms=timestamp_ms or ticks[0].timestamp_ms,
|
|
open=prices[0],
|
|
high=max(prices),
|
|
low=min(prices),
|
|
close=prices[-1],
|
|
levels=levels,
|
|
)
|
|
|
|
self._bar_history.append(bar)
|
|
if len(self._bar_history) > self._max_history:
|
|
self._bar_history = self._bar_history[-self._max_history:]
|
|
|
|
return bar
|
|
|
|
@property
|
|
def history(self) -> list[FootprintBar]:
|
|
return self._bar_history
|
|
|
|
def get_recent_bars(self, n: int) -> list[FootprintBar]:
|
|
return self._bar_history[-n:]
|
|
|
|
def get_aggressive_volume_at_level(
|
|
self, price: float, lookback_bars: int = 5
|
|
) -> tuple[float, float]:
|
|
"""
|
|
Get total aggressive buy and sell volume at a price level
|
|
across the last N bars. Used for absorption detection.
|
|
Returns (buy_volume, sell_volume) at that level.
|
|
"""
|
|
total_buy = 0.0
|
|
total_sell = 0.0
|
|
tolerance = self.tick_size * 0.5
|
|
|
|
for bar in self._bar_history[-lookback_bars:]:
|
|
lv = bar.absorption_at_level(price, tolerance)
|
|
if lv:
|
|
total_buy += lv.ask_volume
|
|
total_sell += lv.bid_volume
|
|
|
|
return total_buy, total_sell
|
|
|
|
def count_consecutive_imbalances(
|
|
self, direction: str, lookback: int = 5, threshold: float = 3.0
|
|
) -> int:
|
|
"""
|
|
Count consecutive bars with one-sided imbalance in a direction.
|
|
Used for initiative auction detection — "constant aggression" signal.
|
|
"""
|
|
count = 0
|
|
for bar in reversed(self._bar_history[-lookback:]):
|
|
imbalances = bar.imbalance_levels(threshold)
|
|
has_directional = any(d == direction for _, d in imbalances)
|
|
if has_directional:
|
|
count += 1
|
|
else:
|
|
break
|
|
return count
|