- Updated all technical indicators to return pandas DataFrames instead of lists, improving consistency and usability. - Modified the `calculate` method in `TechnicalIndicators` to directly return DataFrames with relevant indicator values. - Enhanced the `data_integration.py` to utilize the new DataFrame outputs for better integration with charting. - Updated documentation to reflect the new DataFrame-centric approach, including usage examples and output structures. - Improved error handling to ensure empty DataFrames are returned when insufficient data is available. These changes streamline the indicator calculations and improve the overall architecture, aligning with project standards for maintainability and performance.
323 lines
12 KiB
Python
323 lines
12 KiB
Python
"""
|
|
Safety net tests for technical indicators module.
|
|
|
|
These tests ensure that the core functionality of the indicators module
|
|
remains intact during refactoring.
|
|
"""
|
|
|
|
import pytest
|
|
from datetime import datetime, timezone, timedelta
|
|
from decimal import Decimal
|
|
import pandas as pd
|
|
import numpy as np
|
|
|
|
from data.common.indicators import (
|
|
TechnicalIndicators,
|
|
IndicatorResult,
|
|
create_default_indicators_config,
|
|
validate_indicator_config
|
|
)
|
|
from data.common.data_types import OHLCVCandle
|
|
|
|
|
|
class TestTechnicalIndicatorsSafety:
|
|
"""Safety net test suite for TechnicalIndicators class."""
|
|
|
|
@pytest.fixture
|
|
def sample_candles(self):
|
|
"""Create sample OHLCV candles for testing."""
|
|
candles = []
|
|
base_time = datetime(2024, 1, 1, 9, 0, 0, tzinfo=timezone.utc)
|
|
|
|
# Create 30 candles with realistic price movement
|
|
prices = [100.0, 101.0, 102.5, 101.8, 103.0, 104.2, 103.8, 105.0, 104.5, 106.0,
|
|
107.5, 108.0, 107.2, 109.0, 108.5, 110.0, 109.8, 111.0, 110.5, 112.0,
|
|
111.8, 113.0, 112.5, 114.0, 113.2, 115.0, 114.8, 116.0, 115.5, 117.0]
|
|
|
|
for i, price in enumerate(prices):
|
|
candle = OHLCVCandle(
|
|
symbol='BTC-USDT',
|
|
timeframe='1m',
|
|
start_time=base_time + timedelta(minutes=i),
|
|
end_time=base_time + timedelta(minutes=i+1),
|
|
open=Decimal(str(price - 0.2)),
|
|
high=Decimal(str(price + 0.5)),
|
|
low=Decimal(str(price - 0.5)),
|
|
close=Decimal(str(price)),
|
|
volume=Decimal('1000'),
|
|
trade_count=10,
|
|
exchange='test',
|
|
is_complete=True
|
|
)
|
|
candles.append(candle)
|
|
|
|
return candles
|
|
|
|
@pytest.fixture
|
|
def sparse_candles(self):
|
|
"""Create sample OHLCV candles with time gaps for testing."""
|
|
candles = []
|
|
base_time = datetime(2024, 1, 1, 9, 0, 0, tzinfo=timezone.utc)
|
|
|
|
# Create 15 candles with gaps (every other minute)
|
|
prices = [100.0, 102.5, 104.2, 105.0, 106.0,
|
|
108.0, 109.0, 110.0, 111.0, 112.0,
|
|
113.0, 114.0, 115.0, 116.0, 117.0]
|
|
|
|
for i, price in enumerate(prices):
|
|
# Create 2-minute gaps between candles
|
|
candle = OHLCVCandle(
|
|
symbol='BTC-USDT',
|
|
timeframe='1m',
|
|
start_time=base_time + timedelta(minutes=i*2),
|
|
end_time=base_time + timedelta(minutes=(i*2)+1),
|
|
open=Decimal(str(price - 0.2)),
|
|
high=Decimal(str(price + 0.5)),
|
|
low=Decimal(str(price - 0.5)),
|
|
close=Decimal(str(price)),
|
|
volume=Decimal('1000'),
|
|
trade_count=10,
|
|
exchange='test',
|
|
is_complete=True
|
|
)
|
|
candles.append(candle)
|
|
|
|
return candles
|
|
|
|
@pytest.fixture
|
|
def indicators(self):
|
|
"""Create TechnicalIndicators instance."""
|
|
return TechnicalIndicators()
|
|
|
|
def test_initialization(self, indicators):
|
|
"""Test indicator calculator initialization."""
|
|
assert isinstance(indicators, TechnicalIndicators)
|
|
|
|
def test_prepare_dataframe_from_list(self, indicators, sample_candles):
|
|
"""Test DataFrame preparation from OHLCV candles."""
|
|
df = indicators._prepare_dataframe_from_list(sample_candles)
|
|
assert isinstance(df, pd.DataFrame)
|
|
assert not df.empty
|
|
assert len(df) == len(sample_candles)
|
|
assert 'close' in df.columns
|
|
assert 'timestamp' in df.index.names
|
|
|
|
def test_prepare_dataframe_empty(self, indicators):
|
|
"""Test DataFrame preparation with empty candles list."""
|
|
df = indicators._prepare_dataframe_from_list([])
|
|
assert isinstance(df, pd.DataFrame)
|
|
assert df.empty
|
|
|
|
def test_sma_calculation(self, indicators, sample_candles):
|
|
"""Test Simple Moving Average calculation."""
|
|
period = 5
|
|
df = indicators._prepare_dataframe_from_list(sample_candles)
|
|
results = indicators.sma(df, period)
|
|
|
|
assert len(results) > 0
|
|
assert isinstance(results[0], IndicatorResult)
|
|
assert 'sma' in results[0].values
|
|
assert results[0].metadata['period'] == period
|
|
|
|
def test_sma_insufficient_data(self, indicators, sample_candles):
|
|
"""Test SMA with insufficient data."""
|
|
period = 50 # More than available candles
|
|
df = indicators._prepare_dataframe_from_list(sample_candles)
|
|
results = indicators.sma(df, period)
|
|
assert len(results) == 0
|
|
|
|
def test_ema_calculation(self, indicators, sample_candles):
|
|
"""Test Exponential Moving Average calculation."""
|
|
period = 10
|
|
df = indicators._prepare_dataframe_from_list(sample_candles)
|
|
results = indicators.ema(df, period)
|
|
|
|
assert len(results) > 0
|
|
assert isinstance(results[0], IndicatorResult)
|
|
assert 'ema' in results[0].values
|
|
assert results[0].metadata['period'] == period
|
|
|
|
def test_rsi_calculation(self, indicators, sample_candles):
|
|
"""Test Relative Strength Index calculation."""
|
|
period = 14
|
|
df = indicators._prepare_dataframe_from_list(sample_candles)
|
|
results = indicators.rsi(df, period)
|
|
|
|
assert len(results) > 0
|
|
assert isinstance(results[0], IndicatorResult)
|
|
assert 'rsi' in results[0].values
|
|
assert results[0].metadata['period'] == period
|
|
assert 0 <= results[0].values['rsi'] <= 100
|
|
|
|
def test_macd_calculation(self, indicators, sample_candles):
|
|
"""Test MACD calculation."""
|
|
fast_period = 12
|
|
slow_period = 26
|
|
signal_period = 9
|
|
df = indicators._prepare_dataframe_from_list(sample_candles)
|
|
results = indicators.macd(df, fast_period, slow_period, signal_period)
|
|
|
|
# MACD should start producing results after slow_period periods
|
|
assert len(results) > 0
|
|
|
|
if results: # Only test if we have results
|
|
first_result = results[0]
|
|
assert isinstance(first_result, IndicatorResult)
|
|
assert 'macd' in first_result.values
|
|
assert 'signal' in first_result.values
|
|
assert 'histogram' in first_result.values
|
|
|
|
# Histogram should equal MACD - Signal
|
|
expected_histogram = first_result.values['macd'] - first_result.values['signal']
|
|
assert abs(first_result.values['histogram'] - expected_histogram) < 0.001
|
|
|
|
def test_bollinger_bands_calculation(self, indicators, sample_candles):
|
|
"""Test Bollinger Bands calculation."""
|
|
period = 20
|
|
std_dev = 2.0
|
|
df = indicators._prepare_dataframe_from_list(sample_candles)
|
|
results = indicators.bollinger_bands(df, period, std_dev)
|
|
|
|
assert len(results) > 0
|
|
assert isinstance(results[0], IndicatorResult)
|
|
assert 'upper_band' in results[0].values
|
|
assert 'middle_band' in results[0].values
|
|
assert 'lower_band' in results[0].values
|
|
assert results[0].metadata['period'] == period
|
|
assert results[0].metadata['std_dev'] == std_dev
|
|
|
|
def test_sparse_data_handling(self, indicators, sparse_candles):
|
|
"""Test indicators with sparse data (time gaps)."""
|
|
period = 5
|
|
df = indicators._prepare_dataframe_from_list(sparse_candles)
|
|
sma_df = indicators.sma(df, period)
|
|
assert not sma_df.empty
|
|
timestamps = sma_df.index.to_list()
|
|
for i in range(1, len(timestamps)):
|
|
time_diff = timestamps[i] - timestamps[i-1]
|
|
assert time_diff >= timedelta(minutes=1)
|
|
|
|
def test_calculate_multiple_indicators(self, indicators, sample_candles):
|
|
"""Test calculating multiple indicators at once."""
|
|
config = {
|
|
'sma_10': {'type': 'sma', 'period': 10},
|
|
'ema_12': {'type': 'ema', 'period': 12},
|
|
'rsi_14': {'type': 'rsi', 'period': 14},
|
|
'macd': {'type': 'macd'},
|
|
'bb_20': {'type': 'bollinger_bands', 'period': 20}
|
|
}
|
|
|
|
df = indicators._prepare_dataframe_from_list(sample_candles)
|
|
results = indicators.calculate_multiple_indicators(df, config)
|
|
|
|
assert len(results) == len(config)
|
|
assert 'sma_10' in results
|
|
assert 'ema_12' in results
|
|
assert 'rsi_14' in results
|
|
assert 'macd' in results
|
|
assert 'bb_20' in results
|
|
|
|
# Check that each indicator has appropriate results
|
|
assert len(results['sma_10']) > 0
|
|
assert len(results['ema_12']) > 0
|
|
assert len(results['rsi_14']) > 0
|
|
assert len(results['macd']) > 0
|
|
assert len(results['bb_20']) > 0
|
|
|
|
def test_different_price_columns(self, indicators, sample_candles):
|
|
"""Test indicators with different price columns."""
|
|
df = indicators._prepare_dataframe_from_list(sample_candles)
|
|
|
|
# Test SMA with 'high' price column
|
|
sma_high = indicators.sma(df, 5, price_column='high')
|
|
assert len(sma_high) > 0
|
|
|
|
# Test SMA with 'low' price column
|
|
sma_low = indicators.sma(df, 5, price_column='low')
|
|
assert len(sma_low) > 0
|
|
|
|
# Values should be different
|
|
assert sma_high[0].values['sma'] != sma_low[0].values['sma']
|
|
|
|
|
|
class TestIndicatorHelperFunctions:
|
|
"""Test suite for indicator helper functions."""
|
|
|
|
def test_create_default_indicators_config(self):
|
|
"""Test default indicator configuration creation."""
|
|
config = create_default_indicators_config()
|
|
assert isinstance(config, dict)
|
|
assert len(config) > 0
|
|
assert 'sma_20' in config
|
|
assert 'ema_12' in config
|
|
assert 'rsi_14' in config
|
|
assert 'macd_default' in config
|
|
assert 'bollinger_bands_20' in config
|
|
|
|
def test_validate_indicator_config_valid(self):
|
|
"""Test indicator configuration validation with valid config."""
|
|
valid_configs = [
|
|
{'type': 'sma', 'period': 20},
|
|
{'type': 'ema', 'period': 12},
|
|
{'type': 'rsi', 'period': 14},
|
|
{'type': 'macd'},
|
|
{'type': 'bollinger_bands', 'period': 20, 'std_dev': 2.0}
|
|
]
|
|
|
|
for config in valid_configs:
|
|
assert validate_indicator_config(config)
|
|
|
|
def test_validate_indicator_config_invalid(self):
|
|
"""Test indicator configuration validation with invalid config."""
|
|
invalid_configs = [
|
|
{}, # Empty config
|
|
{'type': 'unknown'}, # Invalid type
|
|
{'type': 'sma', 'period': -1}, # Invalid period
|
|
{'type': 'bollinger_bands', 'std_dev': -1}, # Invalid std_dev
|
|
{'type': 'sma', 'period': 'not_a_number'} # Wrong type for period
|
|
]
|
|
|
|
for config in invalid_configs:
|
|
assert not validate_indicator_config(config)
|
|
|
|
|
|
class TestIndicatorResultDataClass:
|
|
"""Test suite for IndicatorResult dataclass."""
|
|
|
|
def test_indicator_result_creation(self):
|
|
"""Test IndicatorResult creation with all fields."""
|
|
timestamp = datetime.now(timezone.utc)
|
|
values = {'sma': 100.0}
|
|
metadata = {'period': 20}
|
|
|
|
result = IndicatorResult(
|
|
timestamp=timestamp,
|
|
symbol='BTC-USDT',
|
|
timeframe='1m',
|
|
values=values,
|
|
metadata=metadata
|
|
)
|
|
|
|
assert result.timestamp == timestamp
|
|
assert result.symbol == 'BTC-USDT'
|
|
assert result.timeframe == '1m'
|
|
assert result.values == values
|
|
assert result.metadata == metadata
|
|
|
|
def test_indicator_result_without_metadata(self):
|
|
"""Test IndicatorResult creation without optional metadata."""
|
|
timestamp = datetime.now(timezone.utc)
|
|
values = {'sma': 100.0}
|
|
|
|
result = IndicatorResult(
|
|
timestamp=timestamp,
|
|
symbol='BTC-USDT',
|
|
timeframe='1m',
|
|
values=values
|
|
)
|
|
|
|
assert result.timestamp == timestamp
|
|
assert result.symbol == 'BTC-USDT'
|
|
assert result.timeframe == '1m'
|
|
assert result.values == values
|
|
assert result.metadata is None |