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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user