"""Unit tests for services.signal_engine.signals.base helper functions. Tests swing high/low detection, lookback validation, and SMA computation. Requirements: 2.6, 2.7 """ from __future__ import annotations from datetime import datetime, timezone from services.signal_engine.models import OHLCVBar from services.signal_engine.signals.base import ( compute_sma, find_swing_high, find_swing_low, validate_lookback, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _bar( close: float, high: float | None = None, low: float | None = None, ts_offset: int = 0, ) -> OHLCVBar: """Create a minimal OHLCVBar for testing.""" return OHLCVBar( timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc), open=close, high=high if high is not None else close, low=low if low is not None else close, close=close, volume=1000.0, ) # --------------------------------------------------------------------------- # find_swing_high # --------------------------------------------------------------------------- def test_find_swing_high_basic() -> None: bars = [_bar(10, high=12), _bar(10, high=15), _bar(10, high=11)] result = find_swing_high(bars, lookback=3) assert result is not None idx, price = result assert idx == 1 assert price == 15.0 def test_find_swing_high_lookback_subset() -> None: bars = [_bar(10, high=20), _bar(10, high=12), _bar(10, high=15), _bar(10, high=11)] # lookback=2 → only last 2 bars (index 2 and 3 in original) result = find_swing_high(bars, lookback=2) assert result is not None idx, price = result assert idx == 2 # bar at index 2 has high=15 assert price == 15.0 def test_find_swing_high_insufficient_data() -> None: bars = [_bar(10, high=12)] assert find_swing_high(bars, lookback=5) is None def test_find_swing_high_zero_lookback() -> None: bars = [_bar(10, high=12)] assert find_swing_high(bars, lookback=0) is None def test_find_swing_high_negative_lookback() -> None: bars = [_bar(10, high=12)] assert find_swing_high(bars, lookback=-1) is None def test_find_swing_high_tie_takes_last() -> None: """When multiple bars share the same high, the last one wins (>=).""" bars = [_bar(10, high=15), _bar(10, high=15), _bar(10, high=10)] result = find_swing_high(bars, lookback=3) assert result is not None idx, price = result assert idx == 1 assert price == 15.0 # --------------------------------------------------------------------------- # find_swing_low # --------------------------------------------------------------------------- def test_find_swing_low_basic() -> None: bars = [_bar(10, low=8), _bar(10, low=5), _bar(10, low=9)] result = find_swing_low(bars, lookback=3) assert result is not None idx, price = result assert idx == 1 assert price == 5.0 def test_find_swing_low_lookback_subset() -> None: bars = [_bar(10, low=2), _bar(10, low=8), _bar(10, low=5), _bar(10, low=9)] # lookback=2 → only last 2 bars (index 2 and 3) result = find_swing_low(bars, lookback=2) assert result is not None idx, price = result assert idx == 2 # bar at index 2 has low=5 assert price == 5.0 def test_find_swing_low_insufficient_data() -> None: bars = [_bar(10, low=8)] assert find_swing_low(bars, lookback=5) is None def test_find_swing_low_zero_lookback() -> None: bars = [_bar(10, low=8)] assert find_swing_low(bars, lookback=0) is None def test_find_swing_low_tie_takes_last() -> None: """When multiple bars share the same low, the last one wins (<=).""" bars = [_bar(10, low=5), _bar(10, low=5), _bar(10, low=10)] result = find_swing_low(bars, lookback=3) assert result is not None idx, price = result assert idx == 1 assert price == 5.0 # --------------------------------------------------------------------------- # validate_lookback # --------------------------------------------------------------------------- def test_validate_lookback_sufficient() -> None: bars = [_bar(10)] * 20 assert validate_lookback(bars, min_bars=20) is True def test_validate_lookback_more_than_enough() -> None: bars = [_bar(10)] * 50 assert validate_lookback(bars, min_bars=20) is True def test_validate_lookback_insufficient() -> None: bars = [_bar(10)] * 5 assert validate_lookback(bars, min_bars=20) is False def test_validate_lookback_empty() -> None: assert validate_lookback([], min_bars=1) is False def test_validate_lookback_zero_min() -> None: assert validate_lookback([], min_bars=0) is True # --------------------------------------------------------------------------- # compute_sma # --------------------------------------------------------------------------- def test_compute_sma_basic() -> None: bars = [_bar(10), _bar(20), _bar(30)] result = compute_sma(bars, period=3) assert result is not None assert result == 20.0 def test_compute_sma_subset() -> None: bars = [_bar(100), _bar(10), _bar(20), _bar(30)] # period=3 → average of last 3 bars: (10+20+30)/3 = 20 result = compute_sma(bars, period=3) assert result is not None assert result == 20.0 def test_compute_sma_single_bar() -> None: bars = [_bar(42)] result = compute_sma(bars, period=1) assert result is not None assert result == 42.0 def test_compute_sma_insufficient_data() -> None: bars = [_bar(10), _bar(20)] assert compute_sma(bars, period=5) is None def test_compute_sma_zero_period() -> None: bars = [_bar(10)] assert compute_sma(bars, period=0) is None def test_compute_sma_negative_period() -> None: bars = [_bar(10)] assert compute_sma(bars, period=-1) is None