Initial commit: orderflow analysis system with 5 pattern detectors

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>
This commit is contained in:
BlackboxAI
2026-03-08 21:38:25 +03:00
commit 0206ef7cbb
44 changed files with 12937 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
"""
Absorption Detector
Detects when aggressive orders are absorbed by passive liquidity — no price movement.
From Fabio:
"High effort from the buyers that received zero reward. This is the textbook
example for absorption — positive delta but negative closure."
"72 + 61 + 60 + 62 = ~300 contracts on this horizontal level... all this effort
is being absorbed. This is a perfect example of absorption."
Logic:
Effort (aggressive volume at a level) vs Result (price displacement)
HIGH effort + LOW result = ABSORPTION → Entry signal
Also detects repeated absorption: multiple attempts at the same level
(e.g., 105 contracts, then 101 contracts, all absorbed at same price).
"""
from __future__ import annotations
import time
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Optional
from orderflow_system.data.models import (
Tick, Candle, Signal, SignalType, Side, FootprintLevel,
)
from orderflow_system.analytics.footprint import FootprintBar, FootprintEngine
from orderflow_system.analytics.delta import DeltaResult
from orderflow_system.config.settings import AbsorptionConfig
@dataclass
class AbsorptionEvent:
"""Tracks absorption building at a price level."""
price: float
aggressive_volume: float = 0.0
price_displacement: float = 0.0
attempts: int = 0
absorbing_side: str = "" # 'buyers_absorbing' or 'sellers_absorbing'
first_seen_ms: int = 0
last_seen_ms: int = 0
class AbsorptionDetector:
"""
Detects absorption patterns in real-time.
Two detection methods:
1. Per-candle: High aggressive volume at a level but candle closes in opposite direction
(positive delta + negative close = buyers absorbed = bearish absorption)
2. Rolling-window: Aggressive volume accumulates at a price level with no displacement
Multiple attempts at the same level increase confidence.
"""
def __init__(self, config: AbsorptionConfig, tick_size: float = 0.1):
self.config = config
self.tick_size = tick_size
self._active_absorptions: dict[float, AbsorptionEvent] = {}
self._signal_history: list[Signal] = []
self._cleanup_interval_ms = 60_000 # Clean stale events every minute
def check_candle(
self,
candle: Candle,
footprint: FootprintBar,
delta: DeltaResult,
current_price: float,
) -> Optional[Signal]:
"""
Check a completed candle for absorption.
Absorption candle signatures:
- HIGH delta in one direction but candle closes in OPPOSITE direction
→ Positive delta (buy pressure) + red candle = sellers absorbing the buys
→ Negative delta (sell pressure) + green candle = buyers absorbing the sells
- High volume at a specific level with no price movement through it
"""
if candle.volume == 0:
return None
# ── Method 1: Delta vs Close Mismatch ──
signal = self._check_delta_close_mismatch(candle, delta, footprint)
if signal:
return signal
# ── Method 2: Level-based absorption ──
return self._check_level_absorption(candle, footprint, current_price)
def _check_delta_close_mismatch(
self,
candle: Candle,
delta: DeltaResult,
footprint: FootprintBar,
) -> Optional[Signal]:
"""
Fabio's textbook absorption:
"Positive delta but negative closure" = aggressive buyers absorbed by passive sellers.
The opposite direction wins.
"""
abs_delta = abs(delta.vertical_delta)
if abs_delta < self.config.min_aggressive_volume:
return None
# Positive delta (buy pressure) but bearish candle close
if delta.vertical_delta > 0 and not candle.is_green:
# Buyers were absorbed → bearish signal
strength = min(100.0, (abs_delta / self.config.min_aggressive_volume) * 40)
return self._create_signal(
candle=candle,
direction=Side.SELL,
price_level=candle.high, # Absorption happened at the high
strength=strength,
details={
"type": "delta_close_mismatch",
"delta": delta.vertical_delta,
"candle_close": "bearish",
"aggressive_buy_vol": delta.buy_volume,
"aggressive_sell_vol": delta.sell_volume,
},
)
# Negative delta (sell pressure) but bullish candle close
if delta.vertical_delta < 0 and candle.is_green:
# Sellers were absorbed → bullish signal
strength = min(100.0, (abs_delta / self.config.min_aggressive_volume) * 40)
return self._create_signal(
candle=candle,
direction=Side.BUY,
price_level=candle.low, # Absorption happened at the low
strength=strength,
details={
"type": "delta_close_mismatch",
"delta": delta.vertical_delta,
"candle_close": "bullish",
"aggressive_buy_vol": delta.buy_volume,
"aggressive_sell_vol": delta.sell_volume,
},
)
return None
def _check_level_absorption(
self,
candle: Candle,
footprint: FootprintBar,
current_price: float,
) -> Optional[Signal]:
"""
Check for absorption at specific price levels within the footprint.
High volume at a level + price didn't break through = absorption.
"""
if not footprint.levels:
return None
now_ms = int(time.time() * 1000)
tick_size = self.tick_size
for price, lv in footprint.levels.items():
total = lv.total_volume
if total < self.config.big_trade_filter:
continue
# Check: high volume at this level but price displaced little
price_disp = abs(current_price - price) / max(tick_size, 0.01)
effort_high = total >= self.config.min_aggressive_volume
result_low = price_disp <= self.config.max_price_displacement_ticks
if effort_high and result_low:
# Track repeated absorption
rounded = round(price, 4)
if rounded not in self._active_absorptions:
self._active_absorptions[rounded] = AbsorptionEvent(
price=rounded,
first_seen_ms=now_ms,
)
event = self._active_absorptions[rounded]
event.aggressive_volume += total
event.price_displacement = price_disp
event.attempts += 1
event.last_seen_ms = now_ms
# Determine who is absorbing
if lv.ask_volume > lv.bid_volume:
event.absorbing_side = "sellers_absorbing"
direction = Side.SELL
else:
event.absorbing_side = "buyers_absorbing"
direction = Side.BUY
# Signal threshold: enough volume or repeated attempts
if (
event.aggressive_volume >= self.config.min_aggressive_volume
and event.attempts >= self.config.min_attempts
):
strength = min(
100.0,
(event.aggressive_volume / self.config.min_aggressive_volume) * 30
+ event.attempts * 15,
)
signal = self._create_signal(
candle=candle,
direction=direction,
price_level=price,
strength=strength,
details={
"type": "level_absorption",
"total_aggressive_volume": event.aggressive_volume,
"attempts": event.attempts,
"absorbing_side": event.absorbing_side,
"duration_ms": now_ms - event.first_seen_ms,
},
)
# Reset after signal
del self._active_absorptions[rounded]
return signal
# Cleanup stale events
self._cleanup_stale(now_ms)
return None
def _cleanup_stale(self, now_ms: int):
"""Remove absorption events that are too old."""
stale_threshold = now_ms - (self.config.rolling_window_seconds * 3 * 1000)
stale_keys = [
k for k, v in self._active_absorptions.items()
if v.last_seen_ms < stale_threshold
]
for k in stale_keys:
del self._active_absorptions[k]
def _create_signal(
self,
candle: Candle,
direction: Side,
price_level: float,
strength: float,
details: dict,
) -> Signal:
signal = Signal(
timestamp_ms=candle.timestamp_ms,
signal_type=SignalType.ABSORPTION,
direction=direction,
price_level=price_level,
strength=strength,
details=details,
)
self._signal_history.append(signal)
return signal
@property
def active_absorptions(self) -> dict[float, AbsorptionEvent]:
return self._active_absorptions
+159
View File
@@ -0,0 +1,159 @@
"""
Delta Divergence Detector
Detects when price makes new extremes but cumulative delta fails to confirm.
From Fabio:
Delta divergence is a WARNING signal — it weakens conviction in the current trend.
"Price makes new high AND cumulative_delta < previous_delta_high → bearish divergence"
Logic:
Bearish divergence: Price new high + cumulative delta lower high
Bullish divergence: Price new low + cumulative delta higher low
→ REVERSAL warning or filter to reduce confidence in current direction
"""
from __future__ import annotations
from typing import Optional
from orderflow_system.data.models import Candle, Signal, SignalType, Side
from orderflow_system.analytics.delta import DeltaEngine
from orderflow_system.config.settings import DivergenceConfig
class DivergenceDetector:
"""
Detects bearish and bullish delta divergences.
Compares price peaks/troughs with cumulative delta peaks/troughs.
If they disagree, the move is weakening.
"""
def __init__(self, config: DivergenceConfig):
self.config = config
self._price_history: list[tuple[int, float, float]] = []
# (timestamp_ms, high, low)
self._signal_history: list[Signal] = []
self._max_history = 100
def check_candle(
self,
candle: Candle,
delta_engine: DeltaEngine,
) -> Optional[Signal]:
"""Check for delta divergence after a completed candle."""
self._price_history.append((candle.timestamp_ms, candle.high, candle.low))
if len(self._price_history) > self._max_history:
self._price_history = self._price_history[-self._max_history:]
lookback = self.config.lookback_bars
if len(self._price_history) < lookback:
return None
# Get delta peaks and troughs
peaks, troughs = delta_engine.detect_delta_peaks(lookback=lookback)
# ── Bearish divergence: price higher high, delta lower high ──
bear_signal = self._check_bearish_divergence(candle, peaks)
if bear_signal:
return bear_signal
# ── Bullish divergence: price lower low, delta higher low ──
return self._check_bullish_divergence(candle, troughs)
def _check_bearish_divergence(
self, candle: Candle, delta_peaks: list[tuple[int, float]]
) -> Optional[Signal]:
"""Price new high but delta peak is lower than previous."""
if len(delta_peaks) < 2:
return None
recent_prices = self._price_history[-self.config.lookback_bars:]
prev_highs = [h for _, h, _ in recent_prices[:-1]]
if not prev_highs:
return None
max_prev_high = max(prev_highs)
tick = self.config.min_price_new_extreme_ticks * 0.1 # Approx tick
# Price must make new high
if candle.high < max_prev_high + tick:
return None
# Delta peak must be lower than previous peak
latest_delta_peak = delta_peaks[-1][1]
prev_delta_peak = delta_peaks[-2][1]
if latest_delta_peak >= prev_delta_peak * self.config.delta_failure_pct:
return None # Delta confirmed the move — no divergence
strength = min(100.0, (
30 # Base divergence
+ (1 - latest_delta_peak / max(prev_delta_peak, 0.01)) * 40
+ (candle.high - max_prev_high) / max(tick, 0.01) * 10
))
signal = Signal(
timestamp_ms=candle.timestamp_ms,
signal_type=SignalType.DIVERGENCE,
direction=Side.SELL, # Bearish divergence → weakening buyers
price_level=candle.high,
strength=strength,
details={
"type": "bearish_divergence",
"price_high": candle.high,
"prev_price_high": max_prev_high,
"delta_peak": round(latest_delta_peak, 2),
"prev_delta_peak": round(prev_delta_peak, 2),
},
)
self._signal_history.append(signal)
return signal
def _check_bullish_divergence(
self, candle: Candle, delta_troughs: list[tuple[int, float]]
) -> Optional[Signal]:
"""Price new low but delta trough is higher than previous."""
if len(delta_troughs) < 2:
return None
recent_prices = self._price_history[-self.config.lookback_bars:]
prev_lows = [l for _, _, l in recent_prices[:-1]]
if not prev_lows:
return None
min_prev_low = min(prev_lows)
tick = self.config.min_price_new_extreme_ticks * 0.1
if candle.low > min_prev_low - tick:
return None
latest_delta_trough = delta_troughs[-1][1]
prev_delta_trough = delta_troughs[-2][1]
# Trough should be HIGHER (less negative) than previous — divergence
if latest_delta_trough <= prev_delta_trough * self.config.delta_failure_pct:
return None
strength = min(100.0, (
30
+ (1 - abs(latest_delta_trough) / max(abs(prev_delta_trough), 0.01)) * 40
+ (min_prev_low - candle.low) / max(tick, 0.01) * 10
))
signal = Signal(
timestamp_ms=candle.timestamp_ms,
signal_type=SignalType.DIVERGENCE,
direction=Side.BUY, # Bullish divergence → weakening sellers
price_level=candle.low,
strength=strength,
details={
"type": "bullish_divergence",
"price_low": candle.low,
"prev_price_low": min_prev_low,
"delta_trough": round(latest_delta_trough, 2),
"prev_delta_trough": round(prev_delta_trough, 2),
},
)
self._signal_history.append(signal)
return signal
+228
View File
@@ -0,0 +1,228 @@
"""
Exhaustion Detector
Detects declining volume/delta while price continues making new extremes.
From Fabio:
"Decreasing volume — the aggression of market participants from the volume standpoint
is getting lower and lower. And we have a contrarian imbalance at the top from the sellers.
Price going up up up not being followed by the volume — this divergence."
"The market is pushing really strong, printing another green candle, but the volume
is getting lower and lower. This is a dry up in volume. Usually what you see is
a sudden reversal in price."
Logic:
Price making new extremes + DECLINING effort (volume & delta) = EXHAUSTION
→ EXIT signal or REVERSAL setup
"""
from __future__ import annotations
from typing import Optional
from orderflow_system.data.models import Candle, Signal, SignalType, Side
from orderflow_system.analytics.delta import DeltaEngine, DeltaResult
from orderflow_system.analytics.footprint import FootprintBar
from orderflow_system.config.settings import ExhaustionConfig
class ExhaustionDetector:
"""
Detects exhaustion patterns — the current move is running out of steam.
Checked on each new candle by analyzing recent history:
1. Price trend: making new highs/lows over N bars
2. Volume trend: declining volume over same bars
3. Delta trend: declining delta (less conviction)
4. Optional: contrarian imbalance at extreme
"""
def __init__(self, config: ExhaustionConfig):
self.config = config
self._signal_history: list[Signal] = []
def check_candle(
self,
candle: Candle,
delta: DeltaResult,
delta_engine: DeltaEngine,
footprint: FootprintBar,
recent_candles: list[Candle],
) -> Optional[Signal]:
"""Check for exhaustion after a completed candle."""
n = self.config.min_bars_declining
if len(recent_candles) < n + 1:
return None
lookback = recent_candles[-(n + 1):]
# ── Check for BULLISH exhaustion (price up, volume/delta declining) ──
bull_exhaustion = self._check_bullish_exhaustion(
lookback, candle, delta, delta_engine, footprint
)
if bull_exhaustion:
return bull_exhaustion
# ── Check for BEARISH exhaustion (price down, volume/delta declining) ──
return self._check_bearish_exhaustion(
lookback, candle, delta, delta_engine, footprint
)
def _check_bullish_exhaustion(
self,
candles: list[Candle],
current: Candle,
delta: DeltaResult,
delta_engine: DeltaEngine,
footprint: FootprintBar,
) -> Optional[Signal]:
"""
Price making new highs but volume and delta are declining.
→ Buyers exhausted → potential reversal downward.
"""
n = self.config.min_bars_declining
# Price must be making higher highs
highs = [c.high for c in candles]
price_trending_up = all(
highs[i] >= highs[i - 1] for i in range(1, len(highs))
)
if not price_trending_up:
# Relaxed check: at least recent high is higher than N bars ago
if highs[-1] <= highs[0]:
return None
# Volume must be declining
volumes = [c.volume for c in candles]
vol_declining = self._is_declining(volumes, self.config.volume_decline_pct)
if not vol_declining:
return None
# Delta trend should also be declining (less buying conviction)
vol_trend = delta_engine.get_volume_trend(lookback=n)
delta_roc = delta_engine.get_delta_roc(lookback=n)
if vol_trend >= 0 and delta_roc >= 0:
return None # Both must show some weakness
# Optional: contrarian imbalance at extreme (sellers at the top)
contrarian_bonus = 0
if self.config.requires_contrarian_imbalance and footprint.levels:
imbalances = footprint.imbalance_levels(threshold=2.5)
sell_imbalances_at_high = sum(
1 for price, d in imbalances
if d == "sell" and price >= current.high - current.range_size * 0.3
)
if sell_imbalances_at_high > 0:
contrarian_bonus = 20
elif self.config.requires_contrarian_imbalance:
return None # Required but not found
strength = min(100.0, (
40 # Base: volume declining while price up
+ abs(vol_trend) * 5 # Volume slope strength
+ abs(delta_roc) * 5 # Delta weakening strength
+ contrarian_bonus # Contrarian imbalance bonus
))
signal = Signal(
timestamp_ms=current.timestamp_ms,
signal_type=SignalType.EXHAUSTION,
direction=Side.SELL, # Exhausted buyers → bearish reversal
price_level=current.high,
strength=strength,
details={
"type": "bullish_exhaustion",
"declining_bars": n,
"volume_slope": round(vol_trend, 2),
"delta_roc": round(delta_roc, 2),
"has_contrarian_imbalance": contrarian_bonus > 0,
"high_at_exhaustion": current.high,
},
)
self._signal_history.append(signal)
return signal
def _check_bearish_exhaustion(
self,
candles: list[Candle],
current: Candle,
delta: DeltaResult,
delta_engine: DeltaEngine,
footprint: FootprintBar,
) -> Optional[Signal]:
"""
Price making new lows but volume and delta declining.
→ Sellers exhausted → potential reversal upward.
"""
n = self.config.min_bars_declining
lows = [c.low for c in candles]
price_trending_down = all(
lows[i] <= lows[i - 1] for i in range(1, len(lows))
)
if not price_trending_down:
if lows[-1] >= lows[0]:
return None
volumes = [c.volume for c in candles]
vol_declining = self._is_declining(volumes, self.config.volume_decline_pct)
if not vol_declining:
return None
vol_trend = delta_engine.get_volume_trend(lookback=n)
delta_roc = delta_engine.get_delta_roc(lookback=n)
if vol_trend >= 0 and delta_roc <= 0:
return None
contrarian_bonus = 0
if self.config.requires_contrarian_imbalance and footprint.levels:
imbalances = footprint.imbalance_levels(threshold=2.5)
buy_imbalances_at_low = sum(
1 for price, d in imbalances
if d == "buy" and price <= current.low + current.range_size * 0.3
)
if buy_imbalances_at_low > 0:
contrarian_bonus = 20
elif self.config.requires_contrarian_imbalance:
return None
strength = min(100.0, (
40 + abs(vol_trend) * 5 + abs(delta_roc) * 5 + contrarian_bonus
))
signal = Signal(
timestamp_ms=current.timestamp_ms,
signal_type=SignalType.EXHAUSTION,
direction=Side.BUY, # Exhausted sellers → bullish reversal
price_level=current.low,
strength=strength,
details={
"type": "bearish_exhaustion",
"declining_bars": n,
"volume_slope": round(vol_trend, 2),
"delta_roc": round(delta_roc, 2),
"has_contrarian_imbalance": contrarian_bonus > 0,
"low_at_exhaustion": current.low,
},
)
self._signal_history.append(signal)
return signal
@staticmethod
def _is_declining(values: list[float], min_decline_pct: float) -> bool:
"""Check if a series shows consistent decline."""
if len(values) < 2:
return False
if values[0] == 0:
return False
# Overall decline from first to last
overall_decline = (values[0] - values[-1]) / values[0]
if overall_decline < min_decline_pct:
return False
# Check mostly declining (allow 1 up-tick)
declining_count = sum(
1 for i in range(1, len(values)) if values[i] < values[i - 1]
)
return declining_count >= len(values) // 2
+133
View File
@@ -0,0 +1,133 @@
"""
Initiative Auction Detector
Detects aggressive momentum with follow-through — effort + result aligned.
From Fabio:
"Strong delta and a candle that closes on the upside — delta is leading the price.
This is the best example of aggressive momentum — initiative auction."
"Constant aggression of the buyer, consistent pressure on the upside,
one-side imbalance prints... you can use as a really strong point to join
the trend when it's developing and you have a strong delta."
Logic:
HIGH effort + HIGH result + directional alignment = INITIATIVE AUCTION
- Strong delta in one direction
- Candle closes in same direction as delta
- Volume above average (acceleration)
- One-sided imbalance prints in the footprint
"""
from __future__ import annotations
from typing import Optional
from orderflow_system.data.models import Candle, Signal, SignalType, Side
from orderflow_system.analytics.delta import DeltaResult, DeltaEngine
from orderflow_system.analytics.footprint import FootprintBar, FootprintEngine
from orderflow_system.config.settings import InitiativeConfig
class InitiativeDetector:
"""
Detects initiative auction patterns.
Used for:
1. Break-even trigger (first initiative after absorption → move SL to BE)
2. Trailing trigger (each new initiative print → trail SL)
3. Trend joining signal (strong initiative = join the move)
"""
def __init__(self, config: InitiativeConfig, tick_size: float = 0.1):
self.config = config
self.tick_size = tick_size
self._avg_volume_window: list[float] = []
self._max_window = 50
self._signal_history: list[Signal] = []
def check_candle(
self,
candle: Candle,
delta: DeltaResult,
footprint: FootprintBar,
) -> Optional[Signal]:
"""Check a completed candle for initiative auction pattern."""
if candle.volume == 0:
return None
# Track rolling average volume
self._avg_volume_window.append(candle.volume)
if len(self._avg_volume_window) > self._max_window:
self._avg_volume_window = self._avg_volume_window[-self._max_window:]
avg_vol = (
sum(self._avg_volume_window) / len(self._avg_volume_window)
if self._avg_volume_window
else candle.volume
)
# ── Check criteria ──
# 1. Strong delta exceeding threshold
abs_delta = abs(delta.vertical_delta)
if abs_delta < self.config.min_delta_threshold:
return None
# 2. Volume acceleration (above average)
vol_accel = candle.volume / avg_vol if avg_vol > 0 else 1.0
if vol_accel < self.config.volume_acceleration_min:
return None
# 3. Price displacement (candle body must be meaningful)
tick_size = self.tick_size # Use instrument tick size
price_displacement = candle.body_size / max(tick_size, 0.01)
if price_displacement < self.config.min_price_displacement_ticks:
return None
# 4. Delta and price must be directionally aligned
delta_bullish = delta.vertical_delta > 0
candle_bullish = candle.is_green
if self.config.delta_price_alignment and delta_bullish != candle_bullish:
return None
# ── Direction and signal ──
direction = Side.BUY if delta_bullish else Side.SELL
# 5. Check for one-sided imbalance prints (bonus strength)
imbalance_count = 0
if footprint.levels:
imbalances = footprint.imbalance_levels(threshold=3.0)
dir_str = "buy" if delta_bullish else "sell"
imbalance_count = sum(1 for _, d in imbalances if d == dir_str)
# Compute strength score
strength = min(100.0, (
(abs_delta / self.config.min_delta_threshold) * 20 # Delta strength
+ vol_accel * 15 # Volume acceleration
+ price_displacement * 5 # Price follow-through
+ imbalance_count * 10 # Imbalance bonus
))
signal = Signal(
timestamp_ms=candle.timestamp_ms,
signal_type=SignalType.INITIATIVE,
direction=direction,
price_level=candle.close,
strength=strength,
details={
"delta": delta.vertical_delta,
"volume": candle.volume,
"avg_volume": round(avg_vol, 1),
"vol_acceleration": round(vol_accel, 2),
"body_ticks": round(price_displacement, 1),
"imbalance_levels": imbalance_count,
"candle_close": "green" if candle.is_green else "red",
},
)
self._signal_history.append(signal)
return signal
@property
def signal_history(self) -> list[Signal]:
return self._signal_history
+142
View File
@@ -0,0 +1,142 @@
"""
Book Sweep Detector
Detects when price moves rapidly through multiple price levels with low volume.
From Fabio:
"Low effort and high result. A lot of executed orders on the bottom side of the
candle and then the candle closes with an amazing reward... there is movement
of the candle but an absence of participants. No sell limit players in all this area."
Logic:
LOW effort + HIGH displacement = BOOK SWEEP
- Multiple orderbook levels consumed rapidly
- Low volume per level (vacuum / no resistance)
- Price jumps through thin areas
"""
from __future__ import annotations
import time
from typing import Optional
from orderflow_system.data.models import Candle, Signal, SignalType, Side
from orderflow_system.analytics.orderbook import OrderbookTracker, BookState
from orderflow_system.analytics.footprint import FootprintBar
from orderflow_system.config.settings import SweepConfig
class SweepDetector:
"""
Detects book sweeping by monitoring orderbook level consumption.
Sweep = price moves through multiple thin levels quickly with little
resistance. The market finds a vacuum and jets through it.
"""
def __init__(self, config: SweepConfig):
self.config = config
self._signal_history: list[Signal] = []
self._last_signal_ms: int = 0
self._cooldown_ms: int = 5000 # Min 5s between sweep signals
def check(
self,
orderbook_tracker: OrderbookTracker,
candle: Candle,
footprint: FootprintBar,
) -> Optional[Signal]:
"""
Check for book sweep based on recent level consumptions.
"""
now_ms = int(time.time() * 1000)
if now_ms - self._last_signal_ms < self._cooldown_ms:
return None
# Check asks swept (bullish sweep — price going UP through thin asks)
bull_signal = self._check_side(
orderbook_tracker, candle, footprint, side="ask", direction=Side.BUY
)
if bull_signal:
return bull_signal
# Check bids swept (bearish sweep — price going DOWN through thin bids)
bear_signal = self._check_side(
orderbook_tracker, candle, footprint, side="bid", direction=Side.SELL
)
return bear_signal
def _check_side(
self,
tracker: OrderbookTracker,
candle: Candle,
footprint: FootprintBar,
side: str,
direction: Side,
) -> Optional[Signal]:
"""Check sweep on one side of the book."""
levels_swept = tracker.count_swept_levels(
time_window_ms=int(self.config.max_time_ms), side=side
)
total_vol = tracker.total_consumed_volume(
time_window_ms=int(self.config.max_time_ms), side=side
)
if levels_swept < self.config.min_levels_swept:
return None
# Compute efficiency: levels per unit of volume
vol_per_level = total_vol / levels_swept if levels_swept > 0 else float("inf")
# Low effort = low volume per level
if vol_per_level > self.config.max_volume_per_level:
return None
# Also verify with footprint: check that the candle body is large
# relative to volume (high displacement, low effort)
if candle.volume > 0:
displacement_per_vol = candle.range_size / candle.volume
else:
displacement_per_vol = 0
# Compute strength
efficiency = levels_swept / max(vol_per_level, 0.01)
strength = min(100.0, (
levels_swept * 15
+ efficiency * 20
+ displacement_per_vol * 1000
))
# Check thin book confirmation from current state
book_state = tracker.latest_state
thin_confirm = False
if book_state:
if direction == Side.BUY and len(book_state.thin_asks) >= 2:
thin_confirm = True
elif direction == Side.SELL and len(book_state.thin_bids) >= 2:
thin_confirm = True
if thin_confirm:
strength = min(100.0, strength + 15)
if strength < 30:
return None
self._last_signal_ms = int(time.time() * 1000)
signal = Signal(
timestamp_ms=candle.timestamp_ms,
signal_type=SignalType.SWEEP,
direction=direction,
price_level=candle.close,
strength=strength,
details={
"levels_swept": levels_swept,
"total_volume_consumed": round(total_vol, 1),
"vol_per_level": round(vol_per_level, 2),
"efficiency": round(efficiency, 2),
"thin_book_confirmed": thin_confirm,
"candle_range": round(candle.range_size, 4),
},
)
self._signal_history.append(signal)
return signal