"""Unit tests for services.signal_engine.signals.fibonacci — Fibonacci retracement evaluator. Requirements: 2.1, 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.fibonacci import ( DEFAULT_MIN_BARS, RETRACEMENT_RATIOS, FibonacciEvaluator, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _bar( close: float, high: float | None = None, low: float | None = None, ) -> 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, ) def _make_bars( n: int, close: float = 100.0, high: float | None = None, low: float | None = None, ) -> list[OHLCVBar]: """Create *n* identical bars.""" return [_bar(close, high=high, low=low) for _ in range(n)] # --------------------------------------------------------------------------- # Insufficient data → None # --------------------------------------------------------------------------- def test_returns_none_when_insufficient_bars() -> None: """Requirement 2.6: return None when fewer bars than lookback.""" evaluator = FibonacciEvaluator(min_bars=20) bars = _make_bars(10, close=100.0, high=110.0, low=90.0) assert evaluator.evaluate(bars, "D") is None def test_returns_none_with_empty_bars() -> None: evaluator = FibonacciEvaluator() assert evaluator.evaluate([], "D") is None def test_returns_none_when_flat_market() -> None: """When SH == SL there is no valid retracement range.""" evaluator = FibonacciEvaluator(min_bars=5) # All bars have the same high and low bars = _make_bars(5, close=100.0, high=100.0, low=100.0) assert evaluator.evaluate(bars, "D") is None # --------------------------------------------------------------------------- # Basic signal production # --------------------------------------------------------------------------- def test_produces_signal_with_sufficient_data() -> None: """Requirement 2.7: produces a valid SignalResult.""" evaluator = FibonacciEvaluator(min_bars=5) # Create bars with a clear swing high and swing low bars = [ _bar(100.0, high=100.0, low=95.0), _bar(105.0, high=110.0, low=100.0), # swing high _bar(95.0, high=100.0, low=90.0), # swing low _bar(98.0, high=100.0, low=95.0), _bar(100.0, high=102.0, low=98.0), # current price = 100 ] result = evaluator.evaluate(bars, "H4") assert result is not None assert result.signal_type == "fibonacci" 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_signal_metadata_contains_expected_keys() -> None: evaluator = FibonacciEvaluator(min_bars=5) bars = [ _bar(100.0, high=100.0, low=95.0), _bar(105.0, high=110.0, low=100.0), _bar(95.0, high=100.0, low=90.0), _bar(98.0, high=100.0, low=95.0), _bar(100.0, high=102.0, low=98.0), ] result = evaluator.evaluate(bars, "D") assert result is not None meta = result.metadata assert "swing_high" in meta assert "swing_low" in meta assert "retracement_levels" in meta assert "nearest_ratio" in meta assert "nearest_level" in meta assert "distance_to_nearest" in meta assert "current_price" in meta # --------------------------------------------------------------------------- # Retracement level formula: L(r) = SH - r * (SH - SL) # --------------------------------------------------------------------------- def test_retracement_levels_formula() -> None: """Requirement 2.1: L(r) = SH - r * (SH - SL) for all standard ratios.""" evaluator = FibonacciEvaluator(min_bars=5) # SH = 200, SL = 100 → range = 100 bars = [ _bar(150.0, high=200.0, low=100.0), # contains both SH and SL _bar(150.0, high=180.0, low=120.0), _bar(150.0, high=170.0, low=130.0), _bar(150.0, high=160.0, low=140.0), _bar(150.0, high=155.0, low=145.0), # current close = 150 ] result = evaluator.evaluate(bars, "D") assert result is not None levels = result.metadata["retracement_levels"] sh = result.metadata["swing_high"] sl = result.metadata["swing_low"] assert sh == 200.0 assert sl == 100.0 for ratio in RETRACEMENT_RATIOS: expected = sh - ratio * (sh - sl) assert abs(levels[ratio] - expected) < 1e-10, ( f"Level for ratio {ratio}: expected {expected}, got {levels[ratio]}" ) def test_retracement_ratios_constant() -> None: """Verify the RETRACEMENT_RATIOS constant matches the spec.""" assert RETRACEMENT_RATIOS == [0.236, 0.382, 0.5, 0.618, 0.786] # --------------------------------------------------------------------------- # Signal strength — proximity to nearest level # --------------------------------------------------------------------------- def test_strength_is_high_when_price_at_level() -> None: """When current price is exactly at a retracement level, strength ≈ 1.0.""" evaluator = FibonacciEvaluator(min_bars=5) # SH = 200, SL = 100, range = 100 # 0.5 level = 200 - 0.5 * 100 = 150 # Set current close exactly at the 0.5 level bars = [ _bar(150.0, high=200.0, low=100.0), _bar(160.0, high=180.0, low=120.0), _bar(140.0, high=170.0, low=110.0), _bar(155.0, high=165.0, low=130.0), _bar(150.0, high=155.0, low=145.0), # close = 150 = 0.5 level ] result = evaluator.evaluate(bars, "D") assert result is not None assert result.strength == 1.0 def test_strength_decreases_with_distance() -> None: """Strength should be lower when price is far from any retracement level.""" evaluator = FibonacciEvaluator(min_bars=5) # SH = 200, SL = 100 # Levels: 176.4, 161.8, 150.0, 138.2, 121.4 # Price at 110 → nearest is 121.4, distance = 11.4, strength = 1 - 11.4/100 = 0.886 bars = [ _bar(150.0, high=200.0, low=100.0), _bar(160.0, high=180.0, low=120.0), _bar(140.0, high=170.0, low=110.0), _bar(130.0, high=165.0, low=105.0), _bar(110.0, high=115.0, low=105.0), # close = 110 ] result = evaluator.evaluate(bars, "D") assert result is not None assert result.strength < 1.0 assert result.strength > 0.0 # --------------------------------------------------------------------------- # Direction logic # --------------------------------------------------------------------------- def test_direction_bullish_when_above_swing_low() -> None: """Price above SL → BULLISH (potential bounce).""" evaluator = FibonacciEvaluator(min_bars=5) # SL = 100, current close = 150 (above SL) bars = [ _bar(150.0, high=200.0, low=100.0), _bar(160.0, high=180.0, low=120.0), _bar(140.0, high=170.0, low=110.0), _bar(155.0, high=165.0, low=130.0), _bar(150.0, high=155.0, low=145.0), ] result = evaluator.evaluate(bars, "D") assert result is not None assert result.direction == SignalDirection.BULLISH def test_direction_bearish_when_below_swing_low() -> None: """Price below SL → BEARISH.""" evaluator = FibonacciEvaluator(min_bars=3) # SH = 200 (bar 0 high), SL = 100 (bar 1 low) # Current close = 95 (below SL of 100) # The last bar's low must not be lower than SL, otherwise SL shifts down bars = [ _bar(150.0, high=200.0, low=110.0), _bar(120.0, high=150.0, low=100.0), _bar(95.0, high=100.0, low=100.0), # close = 95 < SL=100, but low stays at 100 ] result = evaluator.evaluate(bars, "D") assert result is not None assert result.direction == SignalDirection.BEARISH # --------------------------------------------------------------------------- # Confidence — key ratios boost confidence # --------------------------------------------------------------------------- def test_confidence_higher_at_key_ratio() -> None: """Confidence should be boosted when nearest level is 0.5 or 0.618.""" evaluator = FibonacciEvaluator(min_bars=5) # SH = 200, SL = 100 # 0.5 level = 150, 0.618 level = 138.2 # Price at 150 → nearest is 0.5 (key ratio) bars_at_key = [ _bar(150.0, high=200.0, low=100.0), _bar(160.0, high=180.0, low=120.0), _bar(140.0, high=170.0, low=110.0), _bar(155.0, high=165.0, low=130.0), _bar(150.0, high=155.0, low=145.0), # at 0.5 level ] result_key = evaluator.evaluate(bars_at_key, "D") # Price at 176.4 → nearest is 0.236 (non-key ratio) bars_at_nonkey = [ _bar(150.0, high=200.0, low=100.0), _bar(160.0, high=180.0, low=120.0), _bar(170.0, high=175.0, low=165.0), _bar(175.0, high=178.0, low=172.0), _bar(176.4, high=178.0, low=174.0), # at 0.236 level ] result_nonkey = evaluator.evaluate(bars_at_nonkey, "D") assert result_key is not None assert result_nonkey is not None # Both at their respective levels (distance ≈ 0), but key ratio gets higher confidence assert result_key.confidence > result_nonkey.confidence # --------------------------------------------------------------------------- # Configurable min_bars # --------------------------------------------------------------------------- def test_custom_min_bars() -> None: """The lookback is configurable via constructor.""" evaluator = FibonacciEvaluator(min_bars=3) bars = [ _bar(100.0, high=120.0, low=80.0), _bar(110.0, high=115.0, low=90.0), _bar(105.0, high=110.0, low=95.0), ] result = evaluator.evaluate(bars, "M30") assert result is not None def test_default_min_bars_value() -> None: assert DEFAULT_MIN_BARS == 20