"""Unit tests for the multi-timeframe confluence engine. Validates compute_confluence against requirements 3.1–3.6. """ from services.signal_engine.confluence import ( HIGHER_TIMEFRAME_ANCHORS, MIN_TIMEFRAME_COUNT, compute_confluence, ) from services.signal_engine.models import ( SignalDirection, SignalResult, ) # Default timeframe weights from the design (Requirement 3.1) DEFAULT_WEIGHTS: dict[str, float] = { "M30": 0.03, "H1": 0.07, "H4": 0.15, "D": 0.30, "W": 0.30, "M": 0.15, } def _make_signal( signal_type: str = "fibonacci", timeframe: str = "D", strength: float = 0.8, direction: SignalDirection = SignalDirection.BULLISH, confidence: float = 0.9, ) -> SignalResult: """Build a minimal SignalResult with sensible defaults.""" return SignalResult( signal_type=signal_type, timeframe=timeframe, strength=strength, direction=direction, confidence=confidence, ) class TestMinimumConfluenceThreshold: """Requirement 3.3: signals triggering on < 2 timeframes are discarded.""" def test_single_timeframe_discarded(self): signal_results = { "fibonacci": { "D": _make_signal(timeframe="D"), } } result = compute_confluence(signal_results, DEFAULT_WEIGHTS) assert result == [] def test_zero_timeframes_discarded(self): signal_results = {"fibonacci": {}} result = compute_confluence(signal_results, DEFAULT_WEIGHTS) assert result == [] def test_two_timeframes_passes_minimum(self): signal_results = { "fibonacci": { "D": _make_signal(timeframe="D"), "W": _make_signal(timeframe="W"), } } result = compute_confluence(signal_results, DEFAULT_WEIGHTS) assert len(result) == 1 assert result[0].signal_type == "fibonacci" class TestHigherTimeframeAnchor: """Requirement 3.4: signals without at least one of D, W, M are discarded.""" def test_only_intraday_timeframes_discarded(self): """M30 + H1 = 2 timeframes but no D/W/M anchor → discarded.""" signal_results = { "rsi": { "M30": _make_signal(timeframe="M30"), "H1": _make_signal(timeframe="H1"), } } result = compute_confluence(signal_results, DEFAULT_WEIGHTS) assert result == [] def test_intraday_plus_h4_discarded(self): """M30 + H1 + H4 = 3 timeframes but no D/W/M → discarded.""" signal_results = { "rsi": { "M30": _make_signal(timeframe="M30"), "H1": _make_signal(timeframe="H1"), "H4": _make_signal(timeframe="H4"), } } result = compute_confluence(signal_results, DEFAULT_WEIGHTS) assert result == [] def test_with_daily_anchor_passes(self): signal_results = { "rsi": { "H4": _make_signal(timeframe="H4"), "D": _make_signal(timeframe="D"), } } result = compute_confluence(signal_results, DEFAULT_WEIGHTS) assert len(result) == 1 def test_with_weekly_anchor_passes(self): signal_results = { "rsi": { "H1": _make_signal(timeframe="H1"), "W": _make_signal(timeframe="W"), } } result = compute_confluence(signal_results, DEFAULT_WEIGHTS) assert len(result) == 1 def test_with_monthly_anchor_passes(self): signal_results = { "rsi": { "H4": _make_signal(timeframe="H4"), "M": _make_signal(timeframe="M"), } } result = compute_confluence(signal_results, DEFAULT_WEIGHTS) assert len(result) == 1 class TestConfluenceScoreComputation: """Requirement 3.2: C_confluence = Σ(w_tf · s_tf).""" def test_two_timeframes_score(self): """D(0.30) * 0.8 + W(0.30) * 0.6 = 0.24 + 0.18 = 0.42.""" signal_results = { "fibonacci": { "D": _make_signal(timeframe="D", strength=0.8), "W": _make_signal(timeframe="W", strength=0.6), } } result = compute_confluence(signal_results, DEFAULT_WEIGHTS) assert len(result) == 1 assert abs(result[0].confluence_score - 0.42) < 1e-9 def test_all_timeframes_score(self): """All six timeframes with strength 1.0 → sum of all weights.""" signal_results = { "ma_stack": { tf: _make_signal(timeframe=tf, strength=1.0) for tf in DEFAULT_WEIGHTS } } result = compute_confluence(signal_results, DEFAULT_WEIGHTS) assert len(result) == 1 expected = sum(DEFAULT_WEIGHTS.values()) assert abs(result[0].confluence_score - expected) < 1e-9 def test_zero_strength_contributes_zero(self): """D(0.30) * 0.0 + W(0.30) * 1.0 = 0.0 + 0.30 = 0.30.""" signal_results = { "rsi": { "D": _make_signal(timeframe="D", strength=0.0), "W": _make_signal(timeframe="W", strength=1.0), } } result = compute_confluence(signal_results, DEFAULT_WEIGHTS) assert len(result) == 1 assert abs(result[0].confluence_score - 0.30) < 1e-9 def test_unknown_timeframe_weight_defaults_to_zero(self): """A timeframe not in the weights dict contributes 0 to the score.""" signal_results = { "fibonacci": { "D": _make_signal(timeframe="D", strength=0.5), "UNKNOWN": _make_signal(timeframe="UNKNOWN", strength=1.0), } } # UNKNOWN is not in DEFAULT_WEIGHTS, so its weight is 0.0 # But we still need a D/W/M anchor and >= 2 timeframes result = compute_confluence(signal_results, DEFAULT_WEIGHTS) assert len(result) == 1 assert abs(result[0].confluence_score - 0.15) < 1e-9 # 0.30 * 0.5 class TestPerTimeframeStrengths: """Verify per_timeframe dict contains correct strength values.""" def test_per_timeframe_populated(self): signal_results = { "fibonacci": { "D": _make_signal(timeframe="D", strength=0.7), "W": _make_signal(timeframe="W", strength=0.9), } } result = compute_confluence(signal_results, DEFAULT_WEIGHTS) assert len(result) == 1 assert result[0].per_timeframe == {"D": 0.7, "W": 0.9} def test_active_timeframes_match_per_timeframe_keys(self): signal_results = { "ma_stack": { "H4": _make_signal(timeframe="H4", strength=0.5), "D": _make_signal(timeframe="D", strength=0.6), "W": _make_signal(timeframe="W", strength=0.8), } } result = compute_confluence(signal_results, DEFAULT_WEIGHTS) assert len(result) == 1 assert set(result[0].active_timeframes) == set(result[0].per_timeframe.keys()) class TestDominantDirection: """Verify direction is determined by majority vote across timeframes.""" def test_all_bullish(self): signal_results = { "fibonacci": { "D": _make_signal(direction=SignalDirection.BULLISH), "W": _make_signal(direction=SignalDirection.BULLISH), } } result = compute_confluence(signal_results, DEFAULT_WEIGHTS) assert result[0].direction == SignalDirection.BULLISH def test_all_bearish(self): signal_results = { "fibonacci": { "D": _make_signal(direction=SignalDirection.BEARISH), "W": _make_signal(direction=SignalDirection.BEARISH), } } result = compute_confluence(signal_results, DEFAULT_WEIGHTS) assert result[0].direction == SignalDirection.BEARISH def test_majority_bullish(self): signal_results = { "fibonacci": { "D": _make_signal(direction=SignalDirection.BULLISH), "W": _make_signal(direction=SignalDirection.BULLISH), "M": _make_signal(direction=SignalDirection.BEARISH), } } result = compute_confluence(signal_results, DEFAULT_WEIGHTS) assert result[0].direction == SignalDirection.BULLISH def test_tie_resolves_to_neutral(self): signal_results = { "fibonacci": { "D": _make_signal(direction=SignalDirection.BULLISH), "W": _make_signal(direction=SignalDirection.BEARISH), } } result = compute_confluence(signal_results, DEFAULT_WEIGHTS) assert result[0].direction == SignalDirection.NEUTRAL def test_neutral_votes_do_not_count(self): """2 bullish + 1 neutral → bullish wins.""" signal_results = { "fibonacci": { "D": _make_signal(direction=SignalDirection.BULLISH), "W": _make_signal(direction=SignalDirection.BULLISH), "M": _make_signal(direction=SignalDirection.NEUTRAL), } } result = compute_confluence(signal_results, DEFAULT_WEIGHTS) assert result[0].direction == SignalDirection.BULLISH class TestMultipleSignalTypes: """Verify that multiple signal types are processed independently.""" def test_two_signals_both_pass(self): signal_results = { "fibonacci": { "D": _make_signal(signal_type="fibonacci", timeframe="D"), "W": _make_signal(signal_type="fibonacci", timeframe="W"), }, "rsi": { "H4": _make_signal(signal_type="rsi", timeframe="H4"), "D": _make_signal(signal_type="rsi", timeframe="D"), }, } result = compute_confluence(signal_results, DEFAULT_WEIGHTS) assert len(result) == 2 types = {cs.signal_type for cs in result} assert types == {"fibonacci", "rsi"} def test_one_passes_one_discarded(self): signal_results = { "fibonacci": { "D": _make_signal(signal_type="fibonacci", timeframe="D"), "W": _make_signal(signal_type="fibonacci", timeframe="W"), }, "rsi": { # Only 1 timeframe → discarded "D": _make_signal(signal_type="rsi", timeframe="D"), }, } result = compute_confluence(signal_results, DEFAULT_WEIGHTS) assert len(result) == 1 assert result[0].signal_type == "fibonacci" def test_one_passes_one_no_anchor(self): signal_results = { "fibonacci": { "D": _make_signal(signal_type="fibonacci", timeframe="D"), "W": _make_signal(signal_type="fibonacci", timeframe="W"), }, "rsi": { # 2 timeframes but no D/W/M → discarded "M30": _make_signal(signal_type="rsi", timeframe="M30"), "H1": _make_signal(signal_type="rsi", timeframe="H1"), }, } result = compute_confluence(signal_results, DEFAULT_WEIGHTS) assert len(result) == 1 assert result[0].signal_type == "fibonacci" class TestEmptyInputs: """Edge cases with empty inputs.""" def test_empty_signal_results(self): result = compute_confluence({}, DEFAULT_WEIGHTS) assert result == [] def test_empty_weights(self): """Signals pass filters but all weights are 0 → score is 0.0.""" signal_results = { "fibonacci": { "D": _make_signal(timeframe="D", strength=0.8), "W": _make_signal(timeframe="W", strength=0.6), } } result = compute_confluence(signal_results, {}) assert len(result) == 1 assert result[0].confluence_score == 0.0 class TestConfluenceScoreMonotonicity: """Requirement 3.6: more timeframes with higher weights → higher score.""" def test_adding_timeframe_increases_score(self): """Adding a third timeframe with non-zero strength increases the score.""" two_tf = { "fibonacci": { "D": _make_signal(timeframe="D", strength=0.8), "W": _make_signal(timeframe="W", strength=0.6), } } three_tf = { "fibonacci": { "D": _make_signal(timeframe="D", strength=0.8), "W": _make_signal(timeframe="W", strength=0.6), "H4": _make_signal(timeframe="H4", strength=0.5), } } result_2 = compute_confluence(two_tf, DEFAULT_WEIGHTS) result_3 = compute_confluence(three_tf, DEFAULT_WEIGHTS) assert result_3[0].confluence_score > result_2[0].confluence_score def test_higher_weight_timeframe_contributes_more(self): """D (weight 0.30) contributes more than M30 (weight 0.03) at same strength.""" with_d = { "fibonacci": { "D": _make_signal(timeframe="D", strength=0.5), "W": _make_signal(timeframe="W", strength=0.5), } } with_m30 = { "fibonacci": { "M30": _make_signal(timeframe="M30", strength=0.5), "W": _make_signal(timeframe="W", strength=0.5), } } result_d = compute_confluence(with_d, DEFAULT_WEIGHTS) result_m30 = compute_confluence(with_m30, DEFAULT_WEIGHTS) assert result_d[0].confluence_score > result_m30[0].confluence_score class TestConstants: """Verify module-level constants match the design.""" def test_higher_timeframe_anchors(self): assert HIGHER_TIMEFRAME_ANCHORS == frozenset({"D", "W", "M"}) def test_min_timeframe_count(self): assert MIN_TIMEFRAME_COUNT == 2