"""Unit tests for detect_sr_levels and related pure helpers.""" from __future__ import annotations from app.services.sr_service import ( MAX_LEVELS, _bar_respect_weight, _cap_levels, _merge_levels, _round_number_candidates, _strength_from_respects, detect_sr_levels, detect_sr_levels_legacy, ) def _make_series( n: int = 300, *, base: float = 100.0, support: float = 95.0, resistance: float = 110.0, ) -> tuple[list[float], list[float], list[float], list[int]]: """Synthetic OHLCV that repeatedly tests support/resistance.""" highs: list[float] = [] lows: list[float] = [] closes: list[float] = [] volumes: list[int] = [] price = base for i in range(n): phase = i % 40 if phase < 15: # Drift down toward support, bounce target = support price = price + (target - price) * 0.25 low = min(price, support) - 0.3 high = price + 1.0 close = max(price, support + 0.5) if phase > 12 else price elif phase < 30: # Drift up toward resistance, reject target = resistance price = price + (target - price) * 0.25 high = max(price, resistance) + 0.3 low = price - 1.0 close = min(price, resistance - 0.5) if phase > 27 else price else: price = base + (i % 7) * 0.2 high = price + 1.0 low = price - 1.0 close = price # Occasional clear swing extremes if i % 55 == 25: high = resistance + 1.0 close = resistance - 1.0 low = close - 1.0 if i % 55 == 50: low = support - 1.0 close = support + 1.0 high = close + 1.0 highs.append(high) lows.append(low) closes.append(close) volumes.append(1000 + (i % 10) * 50) price = close return highs, lows, closes, volumes class TestBarRespectWeight: def test_no_interaction(self): assert _bar_respect_weight(100.0, 90.0, 85.0, 88.0, 87.0, 0.005) == 0.0 def test_support_rejection(self): # Low probes at 100, closes above with recovery wick w = _bar_respect_weight(100.0, 103.0, 99.8, 102.0, 101.0, 0.005) assert w >= 0.9 def test_resistance_rejection(self): # High probes at 100, closes below w = _bar_respect_weight(100.0, 100.2, 97.0, 98.0, 99.0, 0.005) assert w >= 0.9 def test_pass_through_lower_weight(self): # Prev below, close above, bar spans through without probing extremes at level w = _bar_respect_weight(100.0, 105.0, 95.0, 104.0, 96.0, 0.005) assert w < 0.5 class TestStrengthFromRespects: def test_pass_through_not_maximal(self): """Central pass-through levels should not pin at strength 100.""" n = 200 # Trending series that passes through 100 many times closes = [80.0 + i * 0.25 for i in range(n)] highs = [c + 1.0 for c in closes] lows = [c - 1.0 for c in closes] strength = _strength_from_respects(100.0, highs, lows, closes, 0.005) assert strength < 100 def test_repeated_rejection_stronger_than_no_touch(self): n = 120 level = 100.0 # Bars that repeatedly probe support (low near level) and close above highs = [103.0] * n lows = [99.8] * n closes = [102.0] * n strong = _strength_from_respects(level, highs, lows, closes, 0.01, base=10) far_highs = [120.0] * n far_lows = [118.0] * n far_closes = [119.0] * n weak = _strength_from_respects(level, far_highs, far_lows, far_closes, 0.01, base=10) assert strong > weak class TestRoundNumbers: def test_near_spot(self): levels = _round_number_candidates(103.0) assert levels assert all(abs(p - 103.0) / 103.0 <= 0.15 + 1e-9 for p in levels) assert len(levels) <= 8 def test_non_positive_price(self): assert _round_number_candidates(0.0) == [] assert _round_number_candidates(-5.0) == [] class TestCapLevels: def test_interleaves_sides(self): levels = [ {"price_level": 90.0, "type": "support", "strength": 80, "detection_method": "x"}, {"price_level": 91.0, "type": "support", "strength": 70, "detection_method": "x"}, {"price_level": 92.0, "type": "support", "strength": 60, "detection_method": "x"}, {"price_level": 110.0, "type": "resistance", "strength": 50, "detection_method": "x"}, {"price_level": 111.0, "type": "resistance", "strength": 40, "detection_method": "x"}, ] capped = _cap_levels(levels, max_levels=4) assert len(capped) == 4 types = {lvl["type"] for lvl in capped} assert "support" in types assert "resistance" in types class TestLevelEvidence: def test_merge_preserves_sources_and_rejection_evidence(self): levels = [ { "price_level": 100.0, "type": "", "strength": 55, "detection_method": "pivot_point", "sources": ["pivot_point"], "rejection_count": 3, "last_rejection_age": 12, "weighted_respects": 1.5, }, { "price_level": 100.3, "type": "", "strength": 40, "detection_method": "round_number", "sources": ["round_number"], "rejection_count": 1, "last_rejection_age": 4, "weighted_respects": 0.5, }, ] merged = _merge_levels(levels, tolerance=0.005) assert len(merged) == 1 assert set(merged[0]["sources"]) == {"pivot_point", "round_number"} assert merged[0]["rejection_count"] == 3 assert merged[0]["last_rejection_age"] == 4 class TestDetectSrLevels: def test_returns_capped_tagged_levels(self): highs, lows, closes, volumes = _make_series() levels = detect_sr_levels(highs, lows, closes, volumes) assert levels assert len(levels) <= MAX_LEVELS for lvl in levels: assert lvl["type"] in ("support", "resistance") assert 0 <= lvl["strength"] <= 100 assert lvl["detection_method"] in ( "volume_profile", "pivot_point", "merged", "round_number", ) assert lvl["price_level"] > 0 assert lvl["sources"] assert lvl["rejection_count"] >= 0 # Sorted by strength desc strengths = [lvl["strength"] for lvl in levels] assert strengths == sorted(strengths, reverse=True) def test_far_fewer_than_old_grid(self): """Should not produce a near-1%-spacing grid of ~70 levels.""" highs, lows, closes, volumes = _make_series(n=500) levels = detect_sr_levels(highs, lows, closes, volumes) assert len(levels) <= MAX_LEVELS def test_empty_input(self): assert detect_sr_levels([], [], [], []) == [] def test_explicit_tolerance(self): highs, lows, closes, volumes = _make_series() tight = detect_sr_levels(highs, lows, closes, volumes, tolerance=0.001) wide = detect_sr_levels(highs, lows, closes, volumes, tolerance=0.05) # Wider merge should not produce more levels assert len(wide) <= len(tight) + 2 # allow small jitter from scoring def test_levels_near_structural_areas(self): """At least some levels should land near the synthetic S/R band.""" highs, lows, closes, volumes = _make_series( n=400, support=95.0, resistance=110.0 ) levels = detect_sr_levels(highs, lows, closes, volumes) prices = [lvl["price_level"] for lvl in levels] near_support = any(abs(p - 95.0) / 95.0 < 0.05 for p in prices) near_resist = any(abs(p - 110.0) / 110.0 < 0.05 for p in prices) # Round numbers / VP may dominate; require at least one structural band hit assert near_support or near_resist or any( abs(p - 100.0) / 100.0 < 0.08 for p in prices ) def test_strength_not_all_pinned_at_100(self): highs, lows, closes, volumes = _make_series(n=400) levels = detect_sr_levels(highs, lows, closes, volumes) if len(levels) >= 3: pinned = sum(1 for lvl in levels if lvl["strength"] == 100) assert pinned < len(levels) def test_legacy_control_retains_old_uncapped_grid(self): highs, lows, closes, volumes = _make_series(n=500) levels = detect_sr_levels_legacy(highs, lows, closes, volumes) assert levels assert all(level["sources"] for level in levels) # The research control intentionally keeps the deployed detector's much # denser output instead of borrowing the rewrite's presentation cap. assert len(levels) > MAX_LEVELS