"""Unit tests for technical indicators module.""" import pytest import pandas as pd import numpy as np from openclaw.indicators import ( sma, ema, rsi, macd, bollinger_bands, ) @pytest.fixture def sample_prices() -> pd.Series: """Generate sample stock prices for testing.""" np.random.seed(42) # Generate 100 days of synthetic price data returns = np.random.normal(0.001, 0.02, 100) prices = 100 * np.exp(np.cumsum(returns)) return pd.Series(prices) @pytest.fixture def real_stock_data() -> pd.Series: """Simulate realistic stock price data.""" # Create prices that resemble real stock movement dates = pd.date_range(start="2024-01-01", periods=60, freq="D") base_price = 150.0 trend = np.linspace(0, 10, 60) noise = np.random.normal(0, 2, 60) prices = base_price + trend + np.cumsum(noise * 0.1) return pd.Series(prices, index=dates) class TestSMA: """Tests for Simple Moving Average.""" def test_sma_basic(self, sample_prices: pd.Series) -> None: """Test SMA calculation with basic parameters.""" period = 20 result = sma(sample_prices, period) # First (period-1) values should be NaN assert result.iloc[: period - 1].isna().all() # Remaining values should not be NaN assert result.iloc[period - 1 :].notna().all() def test_sma_known_values(self) -> None: """Test SMA against known calculated values.""" prices = pd.Series([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) result = sma(prices, period=3) # Expected values: NaN, NaN, 2, 3, 4, 5, 6, 7, 8, 9 expected = pd.Series([np.nan, np.nan, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]) pd.testing.assert_series_equal(result, expected) def test_sma_with_real_data(self, real_stock_data: pd.Series) -> None: """Test SMA with realistic stock data.""" result = sma(real_stock_data, period=20) assert len(result) == len(real_stock_data) # First 19 values should be NaN assert result.iloc[:19].isna().all() # From day 20 onwards should have values assert result.iloc[19:].notna().all() class TestEMA: """Tests for Exponential Moving Average.""" def test_ema_basic(self, sample_prices: pd.Series) -> None: """Test EMA calculation with basic parameters.""" period = 20 result = ema(sample_prices, period) assert len(result) == len(sample_prices) # First (period-1) values should be NaN assert result.iloc[: period - 1].isna().all() # Remaining values should not be NaN assert result.iloc[period - 1 :].notna().all() def test_ema_responds_faster_than_sma(self, sample_prices: pd.Series) -> None: """Test that EMA responds faster to price changes than SMA.""" period = 10 sma_result = sma(sample_prices, period) ema_result = ema(sample_prices, period) # Calculate the rate of change sma_change = sma_result.diff().abs().mean() ema_change = ema_result.diff().abs().mean() # EMA should generally change more than SMA assert ema_change >= sma_change * 0.9 # Allow some tolerance def test_ema_with_real_data(self, real_stock_data: pd.Series) -> None: """Test EMA with realistic stock data.""" period = 12 result = ema(real_stock_data, period) assert len(result) == len(real_stock_data) # First (period-1) values should be NaN assert result.iloc[: period - 1].isna().all() # Remaining values should not be NaN assert result.iloc[period - 1 :].notna().all() class TestRSI: """Tests for Relative Strength Index.""" def test_rsi_range(self, sample_prices: pd.Series) -> None: """Test that RSI values are within 0-100 range.""" result = rsi(sample_prices, period=14) valid_values = result.dropna() assert (valid_values >= 0).all() assert (valid_values <= 100).all() def test_rsi_strong_uptrend(self) -> None: """Test RSI in a strong uptrend (should approach 100).""" # Strong uptrend prices prices = pd.Series([100, 105, 110, 115, 120, 125, 130, 135, 140, 145]) result = rsi(prices, period=5) # Last RSI value should be high (strong uptrend) assert result.iloc[-1] > 70 def test_rsi_strong_downtrend(self) -> None: """Test RSI in a strong downtrend (should approach 0).""" # Strong downtrend prices prices = pd.Series([150, 145, 140, 135, 130, 125, 120, 115, 110, 105]) result = rsi(prices, period=5) # Last RSI value should be low (strong downtrend) assert result.iloc[-1] < 30 def test_rsi_with_real_data(self, real_stock_data: pd.Series) -> None: """Test RSI with realistic stock data.""" result = rsi(real_stock_data, period=14) assert len(result) == len(real_stock_data) valid_values = result.dropna() assert (valid_values >= 0).all() assert (valid_values <= 100).all() class TestMACD: """Tests for MACD indicator.""" def test_macd_structure(self, sample_prices: pd.Series) -> None: """Test that MACD returns correct structure.""" result = macd(sample_prices) assert "macd" in result assert "signal" in result assert "histogram" in result for key in ["macd", "signal", "histogram"]: assert isinstance(result[key], pd.Series) assert len(result[key]) == len(sample_prices) def test_macd_histogram_calculation(self, sample_prices: pd.Series) -> None: """Test that histogram is correctly calculated as MACD - Signal.""" result = macd(sample_prices) expected_histogram = result["macd"] - result["signal"] pd.testing.assert_series_equal(result["histogram"], expected_histogram) def test_macd_custom_periods(self, real_stock_data: pd.Series) -> None: """Test MACD with custom periods.""" result = macd(real_stock_data, fast_period=8, slow_period=17, signal_period=9) assert "macd" in result assert "signal" in result assert "histogram" in result # All series should have same length assert len(result["macd"]) == len(real_stock_data) assert len(result["signal"]) == len(real_stock_data) assert len(result["histogram"]) == len(real_stock_data) class TestBollingerBands: """Tests for Bollinger Bands indicator.""" def test_bollinger_structure(self, sample_prices: pd.Series) -> None: """Test that Bollinger Bands returns correct structure.""" result = bollinger_bands(sample_prices) assert "upper" in result assert "middle" in result assert "lower" in result for key in ["upper", "middle", "lower"]: assert isinstance(result[key], pd.Series) assert len(result[key]) == len(sample_prices) def test_bollinger_relationships(self, sample_prices: pd.Series) -> None: """Test the mathematical relationships between bands.""" result = bollinger_bands(sample_prices, period=20, std_dev=2.0) valid_idx = result["middle"].notna() # Upper band should always be >= middle band assert (result["upper"][valid_idx] >= result["middle"][valid_idx]).all() # Lower band should always be <= middle band assert (result["lower"][valid_idx] <= result["middle"][valid_idx]).all() # Upper band should be > lower band assert (result["upper"][valid_idx] > result["lower"][valid_idx]).all() def test_bollinger_middle_is_sma(self, sample_prices: pd.Series) -> None: """Test that middle band equals SMA.""" period = 20 result = bollinger_bands(sample_prices, period=period) sma_result = sma(sample_prices, period) pd.testing.assert_series_equal(result["middle"], sma_result) def test_bollinger_with_real_data(self, real_stock_data: pd.Series) -> None: """Test Bollinger Bands with realistic stock data.""" result = bollinger_bands(real_stock_data, period=20, std_dev=2.0) assert len(result["upper"]) == len(real_stock_data) assert len(result["middle"]) == len(real_stock_data) assert len(result["lower"]) == len(real_stock_data) # Check band width varies (not constant) band_width = result["upper"] - result["lower"] valid_width = band_width.dropna() assert valid_width.std() > 0 # Band width should vary class TestIndicatorsWithRealWorldScenarios: """Tests using real-world market scenario data.""" def test_trending_market_indicators(self) -> None: """Test indicators in a trending market scenario.""" # Create trending market data np.random.seed(123) trend = np.cumsum(np.random.normal(0.5, 1.5, 50)) prices = pd.Series(100 + trend) # Calculate all indicators sma_result = sma(prices, 10) ema_result = ema(prices, 10) rsi_result = rsi(prices, 14) macd_result = macd(prices) bb_result = bollinger_bands(prices, 20) # Verify all produced valid results assert sma_result.dropna().shape[0] > 0 assert ema_result.dropna().shape[0] > 0 assert rsi_result.dropna().shape[0] > 0 assert macd_result["macd"].dropna().shape[0] > 0 assert bb_result["upper"].dropna().shape[0] > 0 def test_volatile_market_indicators(self) -> None: """Test indicators in a volatile market scenario.""" # Create volatile market data np.random.seed(456) volatility = np.random.normal(0, 3, 50) prices = pd.Series(100 + np.cumsum(volatility)) # Calculate RSI - should show more variation in volatile markets rsi_result = rsi(prices, 14) # Calculate Bollinger Bands - should be wider in volatile markets bb_result = bollinger_bands(prices, 20) valid_rsi = rsi_result.dropna() assert valid_rsi.max() - valid_rsi.min() > 20 # RSI should vary significantly # Bollinger band width should be significant bb_width = (bb_result["upper"] - bb_result["lower"]).dropna() assert bb_width.mean() > 0 def test_sideways_market_indicators(self) -> None: """Test indicators in a sideways/ranging market.""" # Create sideways market data (mean-reverting) np.random.seed(789) noise = np.random.normal(0, 1, 50) prices = pd.Series(100 + noise) # In sideways market, RSI should tend toward middle (around 50) rsi_result = rsi(prices, 14) valid_rsi = rsi_result.dropna() # Mean should be close to 50 in sideways market assert 40 < valid_rsi.mean() < 60