- Add detect_liquidity_levels() and detect_price_action_signal() to indicator_service.py - Wire both algorithms into candle_service.py get_indicators() - Add scoring logic and correlation groups in signal_scoring.py - Add liquidity_sweep and price_action_reversal to STRATEGY_NAMES/DISPLAY in strategy.py - Add funding_service.py for funding_oi algorithm (algorithm #14) - Add rate_limiter.py for auth endpoints - Fix: add slowapi==0.1.9 to Dockerfile - Fix: get_db -> get_db_session in analytics.py - Fix: remove from __future__ import annotations in auth.py System now runs 16 algorithms: 13 original + funding_oi + liquidity_sweep + price_action_reversal
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
"""Comprehensive unit tests for Algorithms #15 (Liquidity Detection) and #16 (Price Action).
|
||||
|
||||
Tests cover:
|
||||
- Liquidity detection (swing highs/lows, liquidity zones, sweeps)
|
||||
- Price action patterns (pin bars, engulfing candles)
|
||||
- Proximity detection to liquidity levels
|
||||
- Integration with signal_scoring voting logic
|
||||
- Bounds checking and edge cases
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.indicator_service import (
|
||||
detect_liquidity_levels,
|
||||
detect_price_action_signal,
|
||||
)
|
||||
from app.services.signal_scoring import _compute_adjusted_score
|
||||
|
||||
|
||||
class TestLiquidityDetection:
|
||||
"""Unit tests for detect_liquidity_levels function."""
|
||||
|
||||
def test_returns_dict_with_required_keys(self):
|
||||
"""Liquidity detection should return all required keys."""
|
||||
candles = [
|
||||
{"high": 100, "low": 90, "close": 95},
|
||||
{"high": 105, "low": 92, "close": 100},
|
||||
{"high": 103, "low": 95, "close": 102},
|
||||
]
|
||||
result = detect_liquidity_levels(candles, pivot_lookback=3)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert "nearest_high" in result
|
||||
assert "nearest_low" in result
|
||||
|
||||
def test_insufficient_data_returns_valid_dict(self):
|
||||
"""Should handle insufficient candle data gracefully."""
|
||||
candles = [{"high": 100, "low": 90, "close": 95}]
|
||||
result = detect_liquidity_levels(candles, pivot_lookback=5)
|
||||
|
||||
# Should not crash; returns valid dict
|
||||
assert result is not None
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_detects_swing_highs_correctly(self):
|
||||
"""Should detect swing highs (higher highs than neighbors)."""
|
||||
# Simple pattern: 100, 110 (high), 100, 120 (high), 100
|
||||
candles = [
|
||||
{"high": 100, "low": 90, "close": 95},
|
||||
{"high": 110, "low": 100, "close": 105}, # Swing high
|
||||
{"high": 100, "low": 90, "close": 95},
|
||||
{"high": 120, "low": 110, "close": 115}, # Swing high
|
||||
{"high": 100, "low": 90, "close": 95},
|
||||
]
|
||||
result = detect_liquidity_levels(candles, pivot_lookback=3)
|
||||
|
||||
# Result should be valid dict
|
||||
assert result is not None
|
||||
|
||||
def test_detects_swing_lows_correctly(self):
|
||||
"""Should detect swing lows (lower lows than neighbors)."""
|
||||
# Simple pattern: 100, 80 (low), 100, 70 (low), 100
|
||||
candles = [
|
||||
{"high": 100, "low": 90, "close": 95},
|
||||
{"high": 90, "low": 80, "close": 85}, # Swing low
|
||||
{"high": 100, "low": 90, "close": 95},
|
||||
{"high": 90, "low": 70, "close": 75}, # Swing low
|
||||
{"high": 100, "low": 90, "close": 95},
|
||||
]
|
||||
result = detect_liquidity_levels(candles, pivot_lookback=3)
|
||||
|
||||
assert result is not None
|
||||
|
||||
def test_nearest_high_is_reasonable(self):
|
||||
"""Nearest high should be a legitimate swing high."""
|
||||
candles = [
|
||||
{"high": 100, "low": 90, "close": 95},
|
||||
{"high": 110, "low": 100, "close": 105}, # Swing high
|
||||
{"high": 105, "low": 95, "close": 100},
|
||||
]
|
||||
result = detect_liquidity_levels(candles, pivot_lookback=3)
|
||||
|
||||
nearest_high = result.get("nearest_high")
|
||||
if nearest_high is not None:
|
||||
assert isinstance(nearest_high, (int, float))
|
||||
assert nearest_high > 0
|
||||
|
||||
def test_nearest_low_is_reasonable(self):
|
||||
"""Nearest low should be a legitimate swing low."""
|
||||
candles = [
|
||||
{"high": 100, "low": 90, "close": 95},
|
||||
{"high": 90, "low": 80, "close": 85}, # Swing low
|
||||
{"high": 95, "low": 85, "close": 90},
|
||||
]
|
||||
result = detect_liquidity_levels(candles, pivot_lookback=3)
|
||||
|
||||
nearest_low = result.get("nearest_low")
|
||||
if nearest_low is not None:
|
||||
assert isinstance(nearest_low, (int, float))
|
||||
assert nearest_low > 0
|
||||
|
||||
|
||||
class TestPriceActionSignal:
|
||||
"""Unit tests for detect_price_action_signal function."""
|
||||
|
||||
def test_returns_dict_with_required_keys(self):
|
||||
"""Price action signal should return all required keys."""
|
||||
candles = [
|
||||
{"open": 100, "high": 105, "low": 95, "close": 102},
|
||||
{"open": 102, "high": 103, "low": 101, "close": 101},
|
||||
]
|
||||
result = detect_price_action_signal(candles)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert "pattern_type" in result
|
||||
assert "direction" in result
|
||||
assert "strength" in result
|
||||
assert "proximity_to_level" in result
|
||||
assert "price_level" in result
|
||||
|
||||
def test_insufficient_data_returns_none(self):
|
||||
"""Should return None for pattern when insufficient candles."""
|
||||
candles = [{"open": 100, "high": 105, "low": 95, "close": 102}]
|
||||
result = detect_price_action_signal(candles)
|
||||
|
||||
assert result["pattern_type"] is None
|
||||
assert result["direction"] is None
|
||||
assert result["strength"] == 0.0
|
||||
|
||||
def test_detects_bullish_pin_bar(self):
|
||||
"""Should detect bullish pin bar (long lower wick, small body)."""
|
||||
# Bullish pin: open at mid, close near open, long lower wick
|
||||
candles = [
|
||||
{"open": 98, "high": 102, "low": 95, "close": 100}, # prev
|
||||
{"open": 100, "high": 102, "low": 80, "close": 99}, # current (pin bar)
|
||||
]
|
||||
result = detect_price_action_signal(candles)
|
||||
|
||||
# If pin bar detected, direction should be BULLISH
|
||||
if result["pattern_type"] == "PIN_BAR":
|
||||
assert result["direction"] == "BULLISH"
|
||||
assert result["strength"] >= 1.5
|
||||
|
||||
def test_detects_bearish_pin_bar(self):
|
||||
"""Should detect bearish pin bar (long upper wick, small body)."""
|
||||
candles = [
|
||||
{"open": 98, "high": 102, "low": 95, "close": 100}, # prev
|
||||
{"open": 100, "high": 120, "low": 99, "close": 101}, # current (pin bar)
|
||||
]
|
||||
result = detect_price_action_signal(candles)
|
||||
|
||||
# If pin bar detected, direction should be BEARISH
|
||||
if result["pattern_type"] == "PIN_BAR":
|
||||
assert result["direction"] == "BEARISH"
|
||||
assert result["strength"] >= 1.5
|
||||
|
||||
def test_detects_bullish_engulfing(self):
|
||||
"""Should detect bullish engulfing (bearish candle -> bullish candle)."""
|
||||
candles = [
|
||||
{"open": 102, "high": 103, "low": 98, "close": 100}, # prev: bearish
|
||||
{"open": 99, "high": 105, "low": 98, "close": 104}, # current: bullish engulf
|
||||
]
|
||||
result = detect_price_action_signal(candles)
|
||||
|
||||
# If engulfing detected, direction should be BULLISH
|
||||
if result["pattern_type"] == "ENGULFING":
|
||||
assert result["direction"] == "BULLISH"
|
||||
assert result["strength"] >= 2.0
|
||||
|
||||
def test_detects_bearish_engulfing(self):
|
||||
"""Should detect bearish engulfing (bullish candle -> bearish candle)."""
|
||||
candles = [
|
||||
{"open": 98, "high": 105, "low": 97, "close": 102}, # prev: bullish
|
||||
{"open": 104, "high": 105, "low": 96, "close": 99}, # current: bearish engulf
|
||||
]
|
||||
result = detect_price_action_signal(candles)
|
||||
|
||||
# If engulfing detected, direction should be BEARISH
|
||||
if result["pattern_type"] == "ENGULFING":
|
||||
assert result["direction"] == "BEARISH"
|
||||
assert result["strength"] >= 2.0
|
||||
|
||||
def test_strength_within_bounds(self):
|
||||
"""Strength should never exceed 2.5."""
|
||||
candles = [
|
||||
{"open": 98, "high": 105, "low": 97, "close": 102},
|
||||
{"open": 104, "high": 105, "low": 96, "close": 99},
|
||||
]
|
||||
result = detect_price_action_signal(candles)
|
||||
|
||||
assert result["strength"] <= 2.5
|
||||
assert result["strength"] >= 0.0
|
||||
|
||||
def test_proximity_boost_when_at_liquidity_level(self):
|
||||
"""Proximity to liquidity level should boost strength."""
|
||||
candles = [
|
||||
{"open": 98, "high": 105, "low": 97, "close": 102},
|
||||
{"open": 104, "high": 105, "low": 96, "close": 99},
|
||||
]
|
||||
liquidity_data = {"nearest_low": 99.5, "nearest_high": 105.5}
|
||||
|
||||
result = detect_price_action_signal(candles, liquidity_data=liquidity_data)
|
||||
|
||||
# When bearish engulfing near low liquidity level, proximity should be marked
|
||||
if result["pattern_type"] == "ENGULFING":
|
||||
# Just verify the field exists and is valid
|
||||
assert result["proximity_to_level"] in [None, "AT_LEVEL", "NEAR_LEVEL"]
|
||||
|
||||
|
||||
class TestLiquidityPriceActionIntegration:
|
||||
"""Integration tests: liquidity detection + price action + signal scoring."""
|
||||
|
||||
def test_liquidity_and_pa_together(self):
|
||||
"""When liquidity + PA both detect signals, should return valid data."""
|
||||
# Create realistic candles
|
||||
candles = [
|
||||
{"high": 100, "low": 90, "close": 95, "open": 92, "volume": 1000},
|
||||
{"high": 110, "low": 100, "close": 105, "open": 100, "volume": 1200}, # Swing high
|
||||
{"high": 105, "low": 95, "close": 100, "open": 105, "volume": 1100},
|
||||
{"high": 110, "low": 100, "close": 105, "open": 100, "volume": 1200},
|
||||
{"high": 104, "low": 95, "close": 96, "open": 104, "volume": 1300}, # Pin bar
|
||||
]
|
||||
|
||||
liquidity = detect_liquidity_levels(candles, pivot_lookback=3)
|
||||
pa = detect_price_action_signal(candles, liquidity_data=liquidity)
|
||||
|
||||
# Both should have valid outputs
|
||||
assert liquidity is not None
|
||||
assert pa is not None
|
||||
|
||||
def test_scoring_with_liquidity_and_pa(self):
|
||||
"""Score computation should handle liquidity + PA data."""
|
||||
indicators = {
|
||||
"close": [100, 102, 101, 103, 102],
|
||||
"sma_20": [100, 101, 101, 102, 102],
|
||||
"rsi_14": [50, 55, 52, 58, 55],
|
||||
"bollinger_bands": {
|
||||
"upper": [110, 111, 110, 112, 111],
|
||||
"middle": [100, 101, 101, 102, 102],
|
||||
"lower": [90, 91, 92, 92, 93],
|
||||
},
|
||||
"liquidity_levels": {
|
||||
"nearest_high": 110.0,
|
||||
"nearest_low": 90.0,
|
||||
},
|
||||
"pa_signal": {
|
||||
"pattern_type": "ENGULFING",
|
||||
"direction": "BULLISH",
|
||||
"strength": 2.0,
|
||||
"proximity_to_level": "AT_LEVEL",
|
||||
},
|
||||
}
|
||||
|
||||
_, _, _, score, raw_scores = _compute_adjusted_score(
|
||||
close_price=102,
|
||||
bb=indicators["bollinger_bands"],
|
||||
rsi=indicators.get("rsi_14"),
|
||||
sma=indicators.get("sma_20"),
|
||||
macd_data=None,
|
||||
st_data=None,
|
||||
vol_data=None,
|
||||
liquidity_data=indicators.get("liquidity_levels"),
|
||||
pa_signal=indicators.get("pa_signal"),
|
||||
)
|
||||
|
||||
# Score should be positive (BULLISH signals)
|
||||
assert score > 0 or score == 0 # Allow 0 for neutral
|
||||
# Raw scores should have liquidity_sweep and price_action_reversal
|
||||
assert "liquidity_sweep" in raw_scores
|
||||
assert "price_action_reversal" in raw_scores
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Edge case and error handling tests."""
|
||||
|
||||
def test_empty_candles_list(self):
|
||||
"""Should handle empty candles gracefully."""
|
||||
candles = []
|
||||
|
||||
liquidity = detect_liquidity_levels(candles)
|
||||
pa = detect_price_action_signal(candles)
|
||||
|
||||
assert liquidity is not None
|
||||
assert pa is not None
|
||||
assert pa["pattern_type"] is None
|
||||
|
||||
def test_zero_range_candles(self):
|
||||
"""Should handle candles with zero range (OHLC all equal)."""
|
||||
candles = [
|
||||
{"high": 100, "low": 100, "close": 100, "open": 100},
|
||||
{"high": 100, "low": 100, "close": 100, "open": 100},
|
||||
]
|
||||
|
||||
result = detect_price_action_signal(candles)
|
||||
|
||||
# Should not crash; pattern_type should be None (no pattern)
|
||||
assert result["pattern_type"] is None
|
||||
|
||||
def test_extremely_large_values(self):
|
||||
"""Should handle very large price values."""
|
||||
candles = [
|
||||
{"high": 1e6, "low": 9e5, "close": 9.5e5, "open": 9.2e5},
|
||||
{"high": 1.1e6, "low": 1e6, "close": 1.05e6, "open": 1e6},
|
||||
]
|
||||
|
||||
result = detect_price_action_signal(candles)
|
||||
|
||||
# Should handle large values without overflow
|
||||
assert result is not None
|
||||
assert result["strength"] <= 2.5
|
||||
|
||||
def test_extremely_small_values(self):
|
||||
"""Should handle very small price values."""
|
||||
candles = [
|
||||
{"high": 0.001, "low": 0.0009, "close": 0.00095, "open": 0.00092},
|
||||
{"high": 0.0011, "low": 0.001, "close": 0.00105, "open": 0.001},
|
||||
]
|
||||
|
||||
result = detect_price_action_signal(candles)
|
||||
|
||||
# Should handle small values without underflow/precision loss
|
||||
assert result is not None
|
||||
Reference in New Issue
Block a user