# Feature: dual-pipeline-signal-engine, Property: Confluence score monotonicity """Property-based tests for the Multi-Timeframe Confluence Engine. Feature: dual-pipeline-signal-engine Tests the confluence score monotonicity property from the design specification: activating a signal on an additional timeframe with non-zero weight always increases or maintains the confluence score. Requirements: 3.6, 17.5 """ from __future__ import annotations from hypothesis import given, settings from hypothesis import strategies as st from services.signal_engine.confluence import compute_confluence from services.signal_engine.models import SignalDirection, SignalResult # --------------------------------------------------------------------------- # Property: Confluence score monotonicity # Validates: Requirements 3.6, 17.5 # --------------------------------------------------------------------------- # Default timeframe weights per the design specification DEFAULT_WEIGHTS: dict[str, float] = { "M30": 0.03, "H1": 0.07, "H4": 0.15, "D": 0.30, "W": 0.30, "M": 0.15, } ALL_TIMEFRAMES = list(DEFAULT_WEIGHTS.keys()) ANCHOR_TIMEFRAMES = ["D", "W", "M"] NON_ANCHOR_TIMEFRAMES = ["M30", "H1", "H4"] # --------------------------------------------------------------------------- # Hypothesis strategies # --------------------------------------------------------------------------- _direction = st.sampled_from([SignalDirection.BULLISH, SignalDirection.BEARISH]) _nonzero_strength = st.floats( min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False, ) _confidence = st.floats( min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False, ) _signal_type = st.just("test_signal") def _signal_result( timeframe: str, strength: st.SearchStrategy[float] = _nonzero_strength, ) -> st.SearchStrategy[SignalResult]: """Build a SignalResult for a given timeframe with non-zero strength.""" return st.builds( SignalResult, signal_type=_signal_type, timeframe=st.just(timeframe), strength=strength, direction=_direction, confidence=_confidence, ) @st.composite def _base_and_extra_timeframe(draw: st.DrawFn) -> tuple[dict[str, SignalResult], str]: """Generate a base set of signal results that passes confluence, plus one extra timeframe. The base set has at least 2 timeframes including at least one D/W/M anchor. The extra timeframe is not in the base set and has a non-zero weight. """ # Pick 1 anchor timeframe (guaranteed) anchor = draw(st.sampled_from(ANCHOR_TIMEFRAMES)) # Pick 1-4 additional timeframes from the remaining (to get at least 2 total) remaining = [tf for tf in ALL_TIMEFRAMES if tf != anchor] additional_count = draw(st.integers(min_value=1, max_value=min(4, len(remaining)))) additional = draw( st.lists( st.sampled_from(remaining), min_size=additional_count, max_size=additional_count, unique=True, ) ) base_tfs = [anchor] + additional # Build signal results for the base set base_results: dict[str, SignalResult] = {} for tf in base_tfs: base_results[tf] = draw(_signal_result(tf)) # Pick an extra timeframe NOT in the base set unused = [tf for tf in ALL_TIMEFRAMES if tf not in base_tfs] if not unused: # All 6 timeframes used — remove one non-anchor from base to free it up removable = [tf for tf in base_tfs if tf not in ANCHOR_TIMEFRAMES] if not removable: # All are anchors — remove one that isn't the primary anchor removable = [tf for tf in base_tfs if tf != anchor] to_remove = draw(st.sampled_from(removable)) del base_results[to_remove] unused = [to_remove] extra_tf = draw(st.sampled_from(unused)) return base_results, extra_tf # --------------------------------------------------------------------------- # Property test # --------------------------------------------------------------------------- @given(data=st.data(), base_and_extra=_base_and_extra_timeframe()) @settings(max_examples=100) def test_confluence_score_monotonicity( data: st.DataObject, base_and_extra: tuple[dict[str, SignalResult], str], ) -> None: """**Validates: Requirements 3.6, 17.5** Given a signal that already passes confluence (≥2 timeframes, ≥1 D/W/M anchor), adding an additional timeframe with non-zero strength and non-zero weight SHALL always increase or maintain the confluence score. The weighted confluence score is C = Σ(w_tf · s_tf). Since both w_tf > 0 and s_tf > 0 for the added timeframe, the new term is strictly positive, so the score must increase. """ base_results, extra_tf = base_and_extra signal_type = "test_signal" # Compute confluence for the base set base_input = {signal_type: dict(base_results)} base_confluence = compute_confluence(base_input, DEFAULT_WEIGHTS) # The base set should pass confluence (≥2 TFs, ≥1 anchor) assert len(base_confluence) == 1, ( f"Expected base set to pass confluence but got {len(base_confluence)} signals.\n" f" Base timeframes: {list(base_results.keys())}" ) base_score = base_confluence[0].confluence_score # Add the extra timeframe with non-zero strength extra_result = data.draw(_signal_result(extra_tf)) extended_results = dict(base_results) extended_results[extra_tf] = extra_result # Compute confluence for the extended set extended_input = {signal_type: extended_results} extended_confluence = compute_confluence(extended_input, DEFAULT_WEIGHTS) # The extended set must also pass confluence (superset of a passing set) assert len(extended_confluence) == 1, ( f"Expected extended set to pass confluence but got " f"{len(extended_confluence)} signals.\n" f" Extended timeframes: {list(extended_results.keys())}" ) new_score = extended_confluence[0].confluence_score # Monotonicity: new_score >= base_score assert new_score >= base_score, ( f"Confluence score decreased when adding timeframe {extra_tf}!\n" f" Base score: {base_score:.6f} (timeframes: {list(base_results.keys())})\n" f" Extended score: {new_score:.6f} (timeframes: {list(extended_results.keys())})\n" f" Added TF weight: {DEFAULT_WEIGHTS[extra_tf]}, " f"strength: {extra_result.strength:.6f}" )