first commit
This commit is contained in:
205
tests/unit/test_indicator_service.py
Normal file
205
tests/unit/test_indicator_service.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""Unit tests for app.services.indicator_service pure computation functions."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.exceptions import ValidationError
|
||||
from app.services.indicator_service import (
|
||||
compute_adx,
|
||||
compute_atr,
|
||||
compute_ema,
|
||||
compute_ema_cross,
|
||||
compute_pivot_points,
|
||||
compute_rsi,
|
||||
compute_volume_profile,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers: generate synthetic OHLCV data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _rising_closes(n: int, start: float = 100.0, step: float = 1.0) -> list[float]:
|
||||
return [start + i * step for i in range(n)]
|
||||
|
||||
|
||||
def _flat_closes(n: int, price: float = 100.0) -> list[float]:
|
||||
return [price] * n
|
||||
|
||||
|
||||
def _ohlcv_from_closes(closes: list[float], spread: float = 2.0):
|
||||
"""Generate highs/lows/volumes from a close series."""
|
||||
highs = [c + spread for c in closes]
|
||||
lows = [c - spread for c in closes]
|
||||
volumes = [1000] * len(closes)
|
||||
return highs, lows, closes, volumes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EMA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComputeEMA:
|
||||
def test_basic_ema(self):
|
||||
closes = _rising_closes(25)
|
||||
result = compute_ema(closes, period=20)
|
||||
assert "ema" in result
|
||||
assert "score" in result
|
||||
assert 0 <= result["score"] <= 100
|
||||
|
||||
def test_insufficient_data_raises(self):
|
||||
closes = _rising_closes(5)
|
||||
with pytest.raises(ValidationError, match="EMA.*requires at least"):
|
||||
compute_ema(closes, period=20)
|
||||
|
||||
def test_price_above_ema_high_score(self):
|
||||
# Rising prices → latest close above EMA → score > 50
|
||||
closes = _rising_closes(30, start=100, step=2)
|
||||
result = compute_ema(closes, period=20)
|
||||
assert result["score"] > 50
|
||||
|
||||
def test_price_below_ema_low_score(self):
|
||||
# Falling prices → latest close below EMA → score < 50
|
||||
closes = list(reversed(_rising_closes(30, start=100, step=2)))
|
||||
result = compute_ema(closes, period=20)
|
||||
assert result["score"] < 50
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RSI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComputeRSI:
|
||||
def test_basic_rsi(self):
|
||||
closes = _rising_closes(20)
|
||||
result = compute_rsi(closes)
|
||||
assert "rsi" in result
|
||||
assert 0 <= result["score"] <= 100
|
||||
|
||||
def test_all_gains_rsi_100(self):
|
||||
closes = _rising_closes(20, step=1)
|
||||
result = compute_rsi(closes)
|
||||
assert result["rsi"] == 100.0
|
||||
|
||||
def test_all_losses_rsi_0(self):
|
||||
closes = list(reversed(_rising_closes(20, step=1)))
|
||||
result = compute_rsi(closes)
|
||||
assert result["rsi"] == pytest.approx(0.0, abs=0.5)
|
||||
|
||||
def test_insufficient_data_raises(self):
|
||||
with pytest.raises(ValidationError, match="RSI requires"):
|
||||
compute_rsi([100.0] * 5)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ATR
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComputeATR:
|
||||
def test_basic_atr(self):
|
||||
closes = _rising_closes(20)
|
||||
highs, lows, _, _ = _ohlcv_from_closes(closes)
|
||||
result = compute_atr(highs, lows, closes)
|
||||
assert "atr" in result
|
||||
assert result["atr"] > 0
|
||||
assert 0 <= result["score"] <= 100
|
||||
|
||||
def test_insufficient_data_raises(self):
|
||||
closes = [100.0] * 5
|
||||
highs, lows, _, _ = _ohlcv_from_closes(closes)
|
||||
with pytest.raises(ValidationError, match="ATR requires"):
|
||||
compute_atr(highs, lows, closes)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ADX
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComputeADX:
|
||||
def test_basic_adx(self):
|
||||
closes = _rising_closes(30)
|
||||
highs, lows, _, _ = _ohlcv_from_closes(closes)
|
||||
result = compute_adx(highs, lows, closes)
|
||||
assert "adx" in result
|
||||
assert "plus_di" in result
|
||||
assert "minus_di" in result
|
||||
assert 0 <= result["score"] <= 100
|
||||
|
||||
def test_insufficient_data_raises(self):
|
||||
closes = _rising_closes(10)
|
||||
highs, lows, _, _ = _ohlcv_from_closes(closes)
|
||||
with pytest.raises(ValidationError, match="ADX requires"):
|
||||
compute_adx(highs, lows, closes)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Volume Profile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComputeVolumeProfile:
|
||||
def test_basic_volume_profile(self):
|
||||
closes = _rising_closes(25)
|
||||
highs, lows, _, volumes = _ohlcv_from_closes(closes)
|
||||
result = compute_volume_profile(highs, lows, closes, volumes)
|
||||
assert "poc" in result
|
||||
assert "value_area_low" in result
|
||||
assert "value_area_high" in result
|
||||
assert "hvn" in result
|
||||
assert "lvn" in result
|
||||
assert 0 <= result["score"] <= 100
|
||||
|
||||
def test_insufficient_data_raises(self):
|
||||
closes = [100.0] * 10
|
||||
highs, lows, _, volumes = _ohlcv_from_closes(closes)
|
||||
with pytest.raises(ValidationError, match="Volume Profile requires"):
|
||||
compute_volume_profile(highs, lows, closes, volumes)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pivot Points
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComputePivotPoints:
|
||||
def test_basic_pivot_points(self):
|
||||
# Create data with clear swing highs/lows
|
||||
closes = [10, 15, 20, 15, 10, 15, 20, 15, 10, 15]
|
||||
highs = [c + 1 for c in closes]
|
||||
lows = [c - 1 for c in closes]
|
||||
result = compute_pivot_points(highs, lows, closes)
|
||||
assert "swing_highs" in result
|
||||
assert "swing_lows" in result
|
||||
assert 0 <= result["score"] <= 100
|
||||
|
||||
def test_insufficient_data_raises(self):
|
||||
with pytest.raises(ValidationError, match="Pivot Points requires"):
|
||||
compute_pivot_points([1, 2], [0, 1], [0.5, 1.5])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EMA Cross
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComputeEMACross:
|
||||
def test_bullish_signal(self):
|
||||
# Rising prices → short EMA > long EMA → bullish
|
||||
closes = _rising_closes(60, step=2)
|
||||
result = compute_ema_cross(closes, short_period=20, long_period=50)
|
||||
assert result["signal"] == "bullish"
|
||||
assert result["short_ema"] > result["long_ema"]
|
||||
|
||||
def test_bearish_signal(self):
|
||||
# Falling prices → short EMA < long EMA → bearish
|
||||
closes = list(reversed(_rising_closes(60, step=2)))
|
||||
result = compute_ema_cross(closes, short_period=20, long_period=50)
|
||||
assert result["signal"] == "bearish"
|
||||
assert result["short_ema"] < result["long_ema"]
|
||||
|
||||
def test_neutral_signal(self):
|
||||
# Flat prices → EMAs converge → neutral
|
||||
closes = _flat_closes(60)
|
||||
result = compute_ema_cross(closes, short_period=20, long_period=50)
|
||||
assert result["signal"] == "neutral"
|
||||
|
||||
def test_insufficient_data_raises(self):
|
||||
closes = _rising_closes(30)
|
||||
with pytest.raises(ValidationError, match="EMA Cross requires"):
|
||||
compute_ema_cross(closes, short_period=20, long_period=50)
|
||||
Reference in New Issue
Block a user