stock/tests/unit/test_indicators.py
2026-02-27 03:17:12 +08:00

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