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>
517 lines
18 KiB
Python
517 lines
18 KiB
Python
"""
|
|
Signal Aggregator & State Machine
|
|
Combines all pattern signals with volume profile context into actionable trade alerts.
|
|
|
|
Implements Fabio's execution model as a state machine:
|
|
WATCHING → ABSORPTION_DETECTED → POSITION_OPEN → BREAK_EVEN → TRAILING → CLOSED
|
|
|
|
Signal weighting:
|
|
- Absorption: 30% (primary entry)
|
|
- Delta/Divergence: 25% (confirmation)
|
|
- Volume Profile context: 25% (level qualification)
|
|
- Initiative/Sweep: 20% (BE trigger / trail)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from typing import Optional
|
|
|
|
from orderflow_system.data.models import (
|
|
Signal, SignalType, Side, TradeState, TradePhase, Candle,
|
|
)
|
|
from orderflow_system.signals.profile_framing import DailyBias, QualifiedLevel, LevelType
|
|
from orderflow_system.config.settings import BiasDirection
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class AggregatedSignal:
|
|
"""Weighted combination of multiple signals at a qualified level."""
|
|
timestamp_ms: int
|
|
direction: Side
|
|
composite_score: float = 0.0 # 0-100
|
|
qualified_level: Optional[QualifiedLevel] = None
|
|
signals: list[Signal] = field(default_factory=list)
|
|
action: str = "" # 'enter', 'break_even', 'trail', 'exit', 'alert_only'
|
|
suggested_sl: float = 0.0
|
|
suggested_tp: float = 0.0
|
|
notes: str = ""
|
|
|
|
|
|
class SignalAggregator:
|
|
"""
|
|
Combines pattern signals with profile context and manages the trade state machine.
|
|
|
|
Flow:
|
|
1. Profile framing qualifies levels and sets daily bias
|
|
2. When price reaches a qualified level, enter WATCHING state
|
|
3. Absorption at the level → ENTRY signal (composite score must pass threshold)
|
|
4. Initiative auction after entry → BREAK EVEN trigger
|
|
5. Subsequent initiative prints → TRAIL stop
|
|
6. Exhaustion or divergence → EXIT / reduce
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
min_composite_score: float = 60.0,
|
|
signal_cooldown_seconds: float = 60.0,
|
|
price_proximity_pct: float = 0.002, # 0.2% proximity to qualified level
|
|
):
|
|
self.min_composite_score = min_composite_score
|
|
self.signal_cooldown_seconds = signal_cooldown_seconds
|
|
self.price_proximity_pct = price_proximity_pct
|
|
self._active_trades: dict[str, TradeState] = {} # instrument → trade
|
|
self._watched_levels: dict[str, list[QualifiedLevel]] = {} # instrument → watched levels
|
|
self._last_signal_time: dict[str, int] = {} # instrument → timestamp_ms
|
|
self._signal_history: list[AggregatedSignal] = []
|
|
|
|
def process_signal(
|
|
self,
|
|
instrument: str,
|
|
signal: Signal,
|
|
bias: Optional[DailyBias],
|
|
current_price: float,
|
|
recent_candles: list[Candle],
|
|
) -> Optional[AggregatedSignal]:
|
|
"""
|
|
Process a new pattern signal against the current bias and trade state.
|
|
Returns an aggregated signal if action is needed.
|
|
"""
|
|
now_ms = int(time.time() * 1000)
|
|
|
|
# Cooldown check
|
|
last_ts = self._last_signal_time.get(instrument, 0)
|
|
if now_ms - last_ts < self.signal_cooldown_seconds * 1000:
|
|
return None
|
|
|
|
active_trade = self._active_trades.get(instrument)
|
|
|
|
# ── State machine routing ──
|
|
|
|
if active_trade is None or active_trade.phase == TradePhase.CLOSED:
|
|
# No active trade — check for new entry
|
|
return self._check_new_entry(
|
|
instrument, signal, bias, current_price, now_ms
|
|
)
|
|
|
|
elif active_trade.phase == TradePhase.WATCHING:
|
|
# Watching a qualified level — look for absorption or sweep
|
|
if signal.signal_type == SignalType.ABSORPTION:
|
|
return self._handle_absorption_at_level(
|
|
instrument, signal, bias, active_trade, current_price, now_ms
|
|
)
|
|
elif signal.signal_type == SignalType.SWEEP:
|
|
# Sweep at watched level — generate alert but don't enter
|
|
return self._handle_sweep_at_level(
|
|
instrument, signal, bias, active_trade, current_price, now_ms
|
|
)
|
|
|
|
elif active_trade.phase == TradePhase.POSITION_OPEN:
|
|
# Position open, waiting for BE trigger
|
|
if signal.signal_type == SignalType.INITIATIVE:
|
|
return self._handle_initiative_for_be(
|
|
instrument, signal, active_trade, now_ms
|
|
)
|
|
elif signal.signal_type in (SignalType.EXHAUSTION, SignalType.DIVERGENCE):
|
|
return self._handle_exit_warning(
|
|
instrument, signal, active_trade, now_ms
|
|
)
|
|
|
|
elif active_trade.phase in (TradePhase.BREAK_EVEN, TradePhase.TRAILING):
|
|
# Trailing — update trail or detect exit
|
|
if signal.signal_type == SignalType.INITIATIVE:
|
|
return self._handle_initiative_for_trail(
|
|
instrument, signal, active_trade, recent_candles, now_ms
|
|
)
|
|
elif signal.signal_type in (SignalType.EXHAUSTION, SignalType.DIVERGENCE):
|
|
return self._handle_exit_warning(
|
|
instrument, signal, active_trade, now_ms
|
|
)
|
|
|
|
return None
|
|
|
|
def set_watching(
|
|
self, instrument: str, level: QualifiedLevel, direction: Side
|
|
):
|
|
"""Begin watching a qualified level for entry signals."""
|
|
# Track multiple watched levels per instrument (don't overwrite)
|
|
if instrument not in self._watched_levels:
|
|
self._watched_levels[instrument] = []
|
|
|
|
# Avoid duplicate levels (same price within 0.01%)
|
|
for existing in self._watched_levels[instrument]:
|
|
if abs(existing.price - level.price) / max(level.price, 1) < 0.0001:
|
|
return # Already watching this level
|
|
|
|
self._watched_levels[instrument].append(level)
|
|
|
|
# Only create WATCHING trade if no active trade yet
|
|
active = self._active_trades.get(instrument)
|
|
if active is None or active.phase == TradePhase.CLOSED:
|
|
trade = TradeState(
|
|
instrument=instrument,
|
|
direction=direction,
|
|
phase=TradePhase.WATCHING,
|
|
qualified_level=level.price,
|
|
)
|
|
self._active_trades[instrument] = trade
|
|
logger.info(
|
|
f"[{instrument}] WATCHING {level.level_type.value} "
|
|
f"@ {level.price:.2f} for {'LONG' if direction == Side.BUY else 'SHORT'}"
|
|
)
|
|
|
|
def _check_new_entry(
|
|
self,
|
|
instrument: str,
|
|
signal: Signal,
|
|
bias: Optional[DailyBias],
|
|
current_price: float,
|
|
now_ms: int,
|
|
) -> Optional[AggregatedSignal]:
|
|
"""Check if a new signal qualifies for entry at a profile level."""
|
|
if signal.signal_type != SignalType.ABSORPTION:
|
|
return None # Only absorption triggers new entries
|
|
|
|
if bias is None or not bias.qualified_levels:
|
|
return None
|
|
|
|
# Find nearest qualified level to current price
|
|
nearest = None
|
|
min_dist = float("inf")
|
|
|
|
for level in bias.qualified_levels:
|
|
dist = abs(current_price - level.price) / max(current_price, 1.0)
|
|
if dist < min_dist and dist < self.price_proximity_pct:
|
|
min_dist = dist
|
|
nearest = level
|
|
|
|
if nearest is None:
|
|
return None # Not near any qualified level
|
|
|
|
# Check direction alignment
|
|
if nearest.direction != signal.direction:
|
|
return None
|
|
|
|
# Compute composite score
|
|
score = self._compute_composite_score(signal, nearest, bias)
|
|
|
|
if score < self.min_composite_score:
|
|
return None
|
|
|
|
# Create trade state
|
|
trade = TradeState(
|
|
instrument=instrument,
|
|
direction=signal.direction,
|
|
qualified_level=nearest.price,
|
|
)
|
|
trade.advance_to_absorption(signal)
|
|
|
|
# Compute SL/TP and advance to position
|
|
sl, tp = self._compute_sl_tp(signal.direction, nearest, bias, current_price)
|
|
trade.advance_to_position(current_price, sl, tp)
|
|
self._active_trades[instrument] = trade
|
|
|
|
agg = AggregatedSignal(
|
|
timestamp_ms=now_ms,
|
|
direction=signal.direction,
|
|
composite_score=score,
|
|
qualified_level=nearest,
|
|
signals=[signal],
|
|
action="enter",
|
|
suggested_sl=sl,
|
|
suggested_tp=tp,
|
|
notes=(
|
|
f"ENTRY SIGNAL: Absorption at {nearest.level_type.value} "
|
|
f"({nearest.price:.2f}). Score: {score:.0f}. "
|
|
f"SL: {sl:.2f}, TP: {tp:.2f}"
|
|
),
|
|
)
|
|
self._last_signal_time[instrument] = now_ms
|
|
self._signal_history.append(agg)
|
|
return agg
|
|
|
|
def _handle_absorption_at_level(
|
|
self,
|
|
instrument: str,
|
|
signal: Signal,
|
|
bias: Optional[DailyBias],
|
|
trade: TradeState,
|
|
current_price: float,
|
|
now_ms: int,
|
|
) -> Optional[AggregatedSignal]:
|
|
"""Handle absorption signal while watching a level."""
|
|
# Find nearest watched level matching signal direction
|
|
watched = self._watched_levels.get(instrument, [])
|
|
nearest_level = None
|
|
min_dist = float("inf")
|
|
for wl in watched:
|
|
if wl.direction != signal.direction:
|
|
continue
|
|
dist = abs(current_price - wl.price) / max(current_price, 1.0)
|
|
if dist < min_dist and dist < self.price_proximity_pct:
|
|
min_dist = dist
|
|
nearest_level = wl
|
|
|
|
if nearest_level is None:
|
|
# Fall back to original logic
|
|
if signal.direction != trade.direction:
|
|
return None
|
|
nearest_level = self._find_qualified_level(bias, trade.qualified_level)
|
|
|
|
trade.advance_to_absorption(signal)
|
|
|
|
score = self._compute_composite_score(signal, nearest_level, bias)
|
|
|
|
if score < self.min_composite_score:
|
|
return None
|
|
|
|
sl, tp = self._compute_sl_tp(
|
|
signal.direction, nearest_level, bias, current_price
|
|
)
|
|
|
|
# Advance to position with SL/TP
|
|
trade.advance_to_position(current_price, sl, tp)
|
|
|
|
agg = AggregatedSignal(
|
|
timestamp_ms=now_ms,
|
|
direction=signal.direction,
|
|
composite_score=score,
|
|
qualified_level=nearest_level,
|
|
signals=[signal],
|
|
action="enter",
|
|
suggested_sl=sl,
|
|
suggested_tp=tp,
|
|
notes=(
|
|
f"ENTRY: Absorption confirmed at watched level "
|
|
f"{nearest_level.price:.2f}. Attempts: {len(trade.absorption_signals)}"
|
|
),
|
|
)
|
|
self._last_signal_time[instrument] = now_ms
|
|
self._signal_history.append(agg)
|
|
return agg
|
|
|
|
def _handle_initiative_for_be(
|
|
self,
|
|
instrument: str,
|
|
signal: Signal,
|
|
trade: TradeState,
|
|
now_ms: int,
|
|
) -> Optional[AggregatedSignal]:
|
|
"""Initiative after entry → move to break even."""
|
|
if signal.direction != trade.direction:
|
|
return None
|
|
|
|
trade.advance_to_break_even(signal)
|
|
|
|
agg = AggregatedSignal(
|
|
timestamp_ms=now_ms,
|
|
direction=trade.direction,
|
|
composite_score=signal.strength,
|
|
signals=[signal],
|
|
action="break_even",
|
|
notes=(
|
|
f"BREAK EVEN: Initiative auction confirmed. "
|
|
f"Move SL to entry {trade.break_even_price:.2f}"
|
|
),
|
|
)
|
|
self._last_signal_time[instrument] = now_ms
|
|
self._signal_history.append(agg)
|
|
return agg
|
|
|
|
def _handle_initiative_for_trail(
|
|
self,
|
|
instrument: str,
|
|
signal: Signal,
|
|
trade: TradeState,
|
|
recent_candles: list[Candle],
|
|
now_ms: int,
|
|
) -> Optional[AggregatedSignal]:
|
|
"""Subsequent initiative prints → trail stop."""
|
|
if signal.direction != trade.direction:
|
|
return None
|
|
|
|
# Trail to the low of the initiative candle (for longs) or high (for shorts)
|
|
if recent_candles:
|
|
last_candle = recent_candles[-1]
|
|
if trade.direction == Side.BUY:
|
|
new_trail = last_candle.low
|
|
else:
|
|
new_trail = last_candle.high
|
|
else:
|
|
new_trail = signal.price_level
|
|
|
|
trade.update_trail(new_trail, signal)
|
|
|
|
agg = AggregatedSignal(
|
|
timestamp_ms=now_ms,
|
|
direction=trade.direction,
|
|
composite_score=signal.strength,
|
|
signals=[signal],
|
|
action="trail",
|
|
notes=(
|
|
f"TRAIL: New initiative print. "
|
|
f"Move SL to {trade.trail_stop:.2f}"
|
|
),
|
|
)
|
|
self._last_signal_time[instrument] = now_ms
|
|
self._signal_history.append(agg)
|
|
return agg
|
|
|
|
def _handle_exit_warning(
|
|
self,
|
|
instrument: str,
|
|
signal: Signal,
|
|
trade: TradeState,
|
|
now_ms: int,
|
|
) -> Optional[AggregatedSignal]:
|
|
"""Exhaustion or divergence → warning to exit/tighten."""
|
|
# Only warn if signal is AGAINST current trade direction
|
|
if signal.direction == trade.direction:
|
|
return None # Same direction exhaustion/divergence = less relevant
|
|
|
|
action = "exit_warning"
|
|
if signal.strength >= 70:
|
|
action = "exit"
|
|
# Auto-close trade on strong exit signal
|
|
trade.close_trade(signal.price_level, f"{signal.signal_type.value} exit (strength {signal.strength:.0f})")
|
|
|
|
agg = AggregatedSignal(
|
|
timestamp_ms=now_ms,
|
|
direction=signal.direction,
|
|
composite_score=signal.strength,
|
|
signals=[signal],
|
|
action=action,
|
|
notes=(
|
|
f"{'EXIT' if action == 'exit' else 'WARNING'}: "
|
|
f"{signal.signal_type.value} detected against position. "
|
|
f"Strength: {signal.strength:.0f}"
|
|
),
|
|
)
|
|
self._last_signal_time[instrument] = now_ms
|
|
self._signal_history.append(agg)
|
|
return agg
|
|
|
|
def _handle_sweep_at_level(
|
|
self,
|
|
instrument: str,
|
|
signal: Signal,
|
|
bias: Optional[DailyBias],
|
|
trade: TradeState,
|
|
current_price: float,
|
|
now_ms: int,
|
|
) -> Optional[AggregatedSignal]:
|
|
"""Handle sweep signal while watching a level — alert only, adds context."""
|
|
agg = AggregatedSignal(
|
|
timestamp_ms=now_ms,
|
|
direction=signal.direction,
|
|
composite_score=signal.strength,
|
|
signals=[signal],
|
|
action="alert_only",
|
|
notes=(
|
|
f"SWEEP detected near watched level @ {trade.qualified_level:.2f}. "
|
|
f"Strength: {signal.strength:.0f} — watch for absorption follow-up"
|
|
),
|
|
)
|
|
self._last_signal_time[instrument] = now_ms
|
|
self._signal_history.append(agg)
|
|
return agg
|
|
|
|
def _compute_composite_score(
|
|
self,
|
|
signal: Signal,
|
|
level: Optional[QualifiedLevel],
|
|
bias: Optional[DailyBias],
|
|
) -> float:
|
|
"""
|
|
Weighted composite score:
|
|
Absorption: 30%
|
|
Delta/Divergence: 25%
|
|
VP context (level strength): 25%
|
|
Initiative/Sweep: 20%
|
|
"""
|
|
score = 0.0
|
|
|
|
# Signal strength component (30-40% depending on type)
|
|
if signal.signal_type == SignalType.ABSORPTION:
|
|
score += signal.strength * 0.30
|
|
elif signal.signal_type in (SignalType.DIVERGENCE,):
|
|
score += signal.strength * 0.25
|
|
elif signal.signal_type == SignalType.INITIATIVE:
|
|
score += signal.strength * 0.20
|
|
elif signal.signal_type == SignalType.SWEEP:
|
|
score += signal.strength * 0.20
|
|
else:
|
|
score += signal.strength * 0.15
|
|
|
|
# Volume profile context (25%)
|
|
if level:
|
|
score += level.strength * 0.25
|
|
|
|
# Bias alignment (remaining %)
|
|
if bias:
|
|
bias_aligned = (
|
|
(bias.direction == BiasDirection.LONG and signal.direction == Side.BUY)
|
|
or (bias.direction == BiasDirection.SHORT and signal.direction == Side.SELL)
|
|
)
|
|
if bias_aligned:
|
|
score += bias.confidence * 0.20
|
|
elif bias.direction == BiasDirection.WARNING:
|
|
score -= 10 # Penalty for trading against warning
|
|
|
|
return min(100.0, max(0.0, score))
|
|
|
|
def _compute_sl_tp(
|
|
self,
|
|
direction: Side,
|
|
level: Optional[QualifiedLevel],
|
|
bias: Optional[DailyBias],
|
|
current_price: float,
|
|
) -> tuple[float, float]:
|
|
"""Compute suggested stop loss and take profit."""
|
|
if bias is None:
|
|
# Default: 0.3% SL, 0.6% TP
|
|
if direction == Side.BUY:
|
|
return current_price * 0.997, current_price * 1.006
|
|
else:
|
|
return current_price * 1.003, current_price * 0.994
|
|
|
|
if direction == Side.BUY:
|
|
# SL below VAL or absorption zone
|
|
sl = bias.val - (bias.vah - bias.val) * 0.1
|
|
# TP at POC first, then VAH
|
|
tp = bias.vah
|
|
else:
|
|
# SL above VAH
|
|
sl = bias.vah + (bias.vah - bias.val) * 0.1
|
|
# TP at POC first, then VAL
|
|
tp = bias.val
|
|
|
|
return sl, tp
|
|
|
|
def _find_qualified_level(
|
|
self, bias: Optional[DailyBias], price: float
|
|
) -> Optional[QualifiedLevel]:
|
|
"""Find the qualified level closest to a price."""
|
|
if bias is None or not bias.qualified_levels:
|
|
return None
|
|
return min(
|
|
bias.qualified_levels,
|
|
key=lambda lv: abs(lv.price - price),
|
|
)
|
|
|
|
def get_active_trade(self, instrument: str) -> Optional[TradeState]:
|
|
return self._active_trades.get(instrument)
|
|
|
|
def close_trade(self, instrument: str, exit_price: float, reason: str = ""):
|
|
trade = self._active_trades.get(instrument)
|
|
if trade:
|
|
trade.close_trade(exit_price, reason)
|
|
|
|
@property
|
|
def signal_history(self) -> list[AggregatedSignal]:
|
|
return self._signal_history
|