245 lines
8.9 KiB
Python
245 lines
8.9 KiB
Python
"""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
|