"""Unit tests for services.signal_engine.signals.ma_stack — Moving average stack evaluator. Requirements: 2.2, 2.6, 2.7 """ from __future__ import annotations from datetime import datetime, timezone from services.signal_engine.models import OHLCVBar, SignalDirection from services.signal_engine.signals.ma_stack import ( MA_PERIODS, MIN_BARS, MAStackEvaluator, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _bar(close: float) -> OHLCVBar: """Create a minimal OHLCVBar for testing.""" return OHLCVBar( timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc), open=close, high=close, low=close, close=close, volume=1000.0, ) def _make_bars(n: int, close: float = 100.0) -> list[OHLCVBar]: """Create *n* identical bars with the same close price.""" return [_bar(close) for _ in range(n)] def _trending_bars(n: int, start: float, step: float) -> list[OHLCVBar]: """Create *n* bars with linearly increasing/decreasing close prices. ``start`` is the first bar's close; each subsequent bar adds ``step``. """ return [_bar(start + i * step) for i in range(n)] # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- def test_ma_periods_constant() -> None: assert MA_PERIODS == [10, 20, 50, 200] def test_min_bars_constant() -> None: assert MIN_BARS == 200 # --------------------------------------------------------------------------- # Insufficient data → None # --------------------------------------------------------------------------- def test_returns_none_when_insufficient_bars() -> None: """Requirement 2.6: return None when fewer bars than 200.""" evaluator = MAStackEvaluator() bars = _make_bars(199) assert evaluator.evaluate(bars, "D") is None def test_returns_none_with_empty_bars() -> None: evaluator = MAStackEvaluator() assert evaluator.evaluate([], "D") is None def test_returns_none_with_one_bar() -> None: evaluator = MAStackEvaluator() assert evaluator.evaluate([_bar(100.0)], "D") is None # --------------------------------------------------------------------------- # No alignment → None # --------------------------------------------------------------------------- def test_returns_none_when_all_mas_equal() -> None: """When all bars have the same close, all MAs are equal — no alignment.""" evaluator = MAStackEvaluator() bars = _make_bars(200, close=100.0) assert evaluator.evaluate(bars, "D") is None # --------------------------------------------------------------------------- # Full bullish alignment # --------------------------------------------------------------------------- def test_full_bullish_alignment() -> None: """Requirement 2.2: MA_10 > MA_20 > MA_50 > MA_200 → bullish, strength 1.0.""" evaluator = MAStackEvaluator() # Strongly uptrending: recent prices much higher than old prices # This ensures MA_10 > MA_20 > MA_50 > MA_200 bars = _trending_bars(200, start=50.0, step=1.0) result = evaluator.evaluate(bars, "D") assert result is not None assert result.signal_type == "ma_stack" assert result.direction == SignalDirection.BULLISH assert result.strength == 1.0 assert result.confidence == 1.0 * 0.9 assert result.metadata["alignment"] == "full_bullish" # --------------------------------------------------------------------------- # Full bearish alignment # --------------------------------------------------------------------------- def test_full_bearish_alignment() -> None: """Requirement 2.2: MA_10 < MA_20 < MA_50 < MA_200 → bearish, strength 1.0.""" evaluator = MAStackEvaluator() # Strongly downtrending: recent prices much lower than old prices bars = _trending_bars(200, start=250.0, step=-1.0) result = evaluator.evaluate(bars, "D") assert result is not None assert result.signal_type == "ma_stack" assert result.direction == SignalDirection.BEARISH assert result.strength == 1.0 assert result.confidence == 1.0 * 0.9 assert result.metadata["alignment"] == "full_bearish" # --------------------------------------------------------------------------- # Partial bullish alignment (3/4) # --------------------------------------------------------------------------- def test_partial_bullish_alignment() -> None: """3 out of 4 MAs in bullish order → strength 0.6.""" evaluator = MAStackEvaluator() # Build bars where MA_10 > MA_20 > MA_50 but MA_50 < MA_200 # Use flat early prices (high MA_200) then a moderate uptrend at the end bars = _make_bars(150, close=200.0) + _trending_bars(50, start=100.0, step=2.0) result = evaluator.evaluate(bars, "D") # The recent uptrend should give MA_10 > MA_20 > MA_50 # but MA_200 includes the high early prices, so MA_50 < MA_200 if result is not None: assert result.direction == SignalDirection.BULLISH assert result.strength == 0.6 assert result.confidence == 0.6 * 0.9 assert result.metadata["alignment"] == "partial_bullish" # --------------------------------------------------------------------------- # Partial bearish alignment (3/4) # --------------------------------------------------------------------------- def test_partial_bearish_alignment() -> None: """3 out of 4 MAs in bearish order → strength 0.6.""" evaluator = MAStackEvaluator() # Build bars where MA_10 < MA_20 < MA_50 but MA_50 > MA_200 # Use flat low early prices (low MA_200) then a moderate downtrend at the end bars = _make_bars(150, close=50.0) + _trending_bars(50, start=200.0, step=-2.0) result = evaluator.evaluate(bars, "D") if result is not None: assert result.direction == SignalDirection.BEARISH assert result.strength == 0.6 assert result.confidence == 0.6 * 0.9 assert result.metadata["alignment"] == "partial_bearish" # --------------------------------------------------------------------------- # Signal result structure (Requirement 2.7) # --------------------------------------------------------------------------- def test_signal_result_structure() -> None: """Requirement 2.7: SignalResult has all required fields.""" evaluator = MAStackEvaluator() bars = _trending_bars(200, start=50.0, step=1.0) result = evaluator.evaluate(bars, "H4") assert result is not None assert result.signal_type == "ma_stack" assert result.timeframe == "H4" assert 0.0 <= result.strength <= 1.0 assert 0.0 <= result.confidence <= 1.0 assert result.direction in (SignalDirection.BULLISH, SignalDirection.BEARISH) def test_metadata_contains_all_ma_values() -> None: """Metadata should include all four MA values and alignment type.""" evaluator = MAStackEvaluator() bars = _trending_bars(200, start=50.0, step=1.0) result = evaluator.evaluate(bars, "D") assert result is not None meta = result.metadata assert "ma_10" in meta assert "ma_20" in meta assert "ma_50" in meta assert "ma_200" in meta assert "alignment" in meta # --------------------------------------------------------------------------- # Timeframe passthrough # --------------------------------------------------------------------------- def test_timeframe_passthrough() -> None: """The timeframe label is passed through to the result.""" evaluator = MAStackEvaluator() bars = _trending_bars(200, start=50.0, step=1.0) for tf in ("M30", "H1", "H4", "D", "W", "M"): result = evaluator.evaluate(bars, tf) assert result is not None assert result.timeframe == tf # --------------------------------------------------------------------------- # Exactly 200 bars (boundary) # --------------------------------------------------------------------------- def test_exactly_200_bars_works() -> None: """Exactly 200 bars should be sufficient (boundary condition).""" evaluator = MAStackEvaluator() bars = _trending_bars(200, start=50.0, step=1.0) result = evaluator.evaluate(bars, "D") assert result is not None def test_199_bars_returns_none() -> None: """199 bars is insufficient.""" evaluator = MAStackEvaluator() bars = _trending_bars(199, start=50.0, step=1.0) result = evaluator.evaluate(bars, "D") assert result is None