294 lines
11 KiB
Python
294 lines
11 KiB
Python
"""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
|