"""Multi-Timeframe Confluence Engine. Evaluates signals across multiple timeframes and computes weighted confluence scores. Signals must trigger on at least 2 timeframes **and** include at least one higher-timeframe anchor (D, W, or M) to pass the confluence filter. The weighted confluence score is: C_confluence = Σ(w_tf · s_tf) where ``w_tf`` is the timeframe weight and ``s_tf`` is the signal strength on that timeframe (only summed over timeframes where the signal triggered). Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6 """ from __future__ import annotations import logging from collections import Counter from services.signal_engine.models import ( ConfluenceSignal, SignalDirection, SignalResult, ) logger = logging.getLogger(__name__) # Higher-timeframe anchors — at least one must be present for a signal to pass. HIGHER_TIMEFRAME_ANCHORS: frozenset[str] = frozenset({"D", "W", "M"}) # Minimum number of timeframes a signal must trigger on. MIN_TIMEFRAME_COUNT: int = 2 def _dominant_direction(results: dict[str, SignalResult]) -> SignalDirection: """Determine the dominant direction from a set of per-timeframe results. Counts bullish vs bearish votes across active timeframes. Ties resolve to NEUTRAL. """ counts: Counter[SignalDirection] = Counter() for sr in results.values(): counts[sr.direction] += 1 bullish = counts.get(SignalDirection.BULLISH, 0) bearish = counts.get(SignalDirection.BEARISH, 0) if bullish > bearish: return SignalDirection.BULLISH if bearish > bullish: return SignalDirection.BEARISH return SignalDirection.NEUTRAL def compute_confluence( signal_results: dict[str, dict[str, SignalResult]], weights: dict[str, float], ) -> list[ConfluenceSignal]: """Compute weighted confluence scores across timeframes. Args: signal_results: ``{signal_type: {timeframe: SignalResult}}``. Each inner dict maps timeframe labels (e.g. ``"D"``, ``"H4"``) to the :class:`SignalResult` produced by the signal evaluator on that timeframe. weights: ``{timeframe: weight}`` e.g. ``{"M30": 0.03, "H1": 0.07, "H4": 0.15, "D": 0.30, "W": 0.30, "M": 0.15}``. Returns: List of :class:`ConfluenceSignal` objects that pass **both** filters: 1. **Minimum confluence threshold** — the signal must trigger on at least :data:`MIN_TIMEFRAME_COUNT` (2) timeframes. 2. **Higher-timeframe anchor** — at least one of D, W, or M must be among the active timeframes. Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6 """ confluence_signals: list[ConfluenceSignal] = [] for signal_type, tf_results in signal_results.items(): active_timeframes = list(tf_results.keys()) # 3.3 — Minimum confluence threshold: discard if < 2 timeframes if len(active_timeframes) < MIN_TIMEFRAME_COUNT: logger.debug( "Signal %s discarded: only %d timeframe(s) triggered (need >= %d)", signal_type, len(active_timeframes), MIN_TIMEFRAME_COUNT, ) continue # 3.4 — Higher-timeframe anchor: discard if none of D, W, M present if not HIGHER_TIMEFRAME_ANCHORS.intersection(active_timeframes): logger.debug( "Signal %s discarded: no higher-timeframe anchor (D/W/M) " "among active timeframes %s", signal_type, active_timeframes, ) continue # 3.2 — Compute weighted confluence score per_timeframe: dict[str, float] = {} confluence_score = 0.0 for tf, sr in tf_results.items(): w = weights.get(tf, 0.0) per_timeframe[tf] = sr.strength confluence_score += w * sr.strength # Determine dominant direction across active timeframes direction = _dominant_direction(tf_results) confluence_signals.append( ConfluenceSignal( signal_type=signal_type, direction=direction, confluence_score=confluence_score, active_timeframes=active_timeframes, per_timeframe=per_timeframe, ) ) logger.debug( "Signal %s passed confluence: score=%.4f, direction=%s, " "timeframes=%s", signal_type, confluence_score, direction.value, active_timeframes, ) return confluence_signals