#!/usr/bin/env python3 """ Comprehensive Unit Tests for Chart Layer Components Tests for all chart layer functionality including: - Error handling system - Base layer components (CandlestickLayer, VolumeLayer, LayerManager) - Indicator layers (SMA, EMA, Bollinger Bands) - Subplot layers (RSI, MACD) - Integration and error recovery """ import pytest import pandas as pd import plotly.graph_objects as go from datetime import datetime, timezone, timedelta from unittest.mock import Mock, patch, MagicMock from typing import List, Dict, Any from decimal import Decimal import sys from pathlib import Path project_root = Path(__file__).parent.parent sys.path.insert(0, str(project_root)) # Import components to test from components.charts.error_handling import ( ChartErrorHandler, ChartError, ErrorSeverity, DataRequirements, ErrorRecoveryStrategies, check_data_sufficiency, get_error_message ) from components.charts.layers.base import ( LayerConfig, BaseLayer, CandlestickLayer, VolumeLayer, LayerManager ) from components.charts.layers.indicators import ( IndicatorLayerConfig, BaseIndicatorLayer, SMALayer, EMALayer, BollingerBandsLayer ) from components.charts.layers.subplots import ( SubplotLayerConfig, BaseSubplotLayer, RSILayer, MACDLayer ) class TestErrorHandlingSystem: """Test suite for chart error handling system""" @pytest.fixture def sample_data(self): """Sample market data for testing""" base_time = datetime.now(timezone.utc) - timedelta(hours=24) return [ { 'timestamp': base_time + timedelta(minutes=i), 'open': 50000 + i * 10, 'high': 50100 + i * 10, 'low': 49900 + i * 10, 'close': 50050 + i * 10, 'volume': 1000 + i * 5 } for i in range(50) # 50 data points ] @pytest.fixture def insufficient_data(self): """Insufficient market data for testing""" base_time = datetime.now(timezone.utc) return [ { 'timestamp': base_time + timedelta(minutes=i), 'open': 50000, 'high': 50100, 'low': 49900, 'close': 50050, 'volume': 1000 } for i in range(5) # Only 5 data points ] def test_chart_error_creation(self): """Test ChartError dataclass creation""" error = ChartError( code='TEST_ERROR', message='Test error message', severity=ErrorSeverity.ERROR, context={'test': 'value'}, recovery_suggestion='Fix the test' ) assert error.code == 'TEST_ERROR' assert error.message == 'Test error message' assert error.severity == ErrorSeverity.ERROR assert error.context == {'test': 'value'} assert error.recovery_suggestion == 'Fix the test' # Test dict conversion error_dict = error.to_dict() assert error_dict['code'] == 'TEST_ERROR' assert error_dict['severity'] == 'error' def test_data_requirements_candlestick(self): """Test data requirements checking for candlestick charts""" # Test sufficient data error = DataRequirements.check_candlestick_requirements(50) assert error.severity == ErrorSeverity.INFO assert error.code == 'SUFFICIENT_DATA' # Test insufficient data error = DataRequirements.check_candlestick_requirements(5) assert error.severity == ErrorSeverity.WARNING assert error.code == 'INSUFFICIENT_CANDLESTICK_DATA' # Test no data error = DataRequirements.check_candlestick_requirements(0) assert error.severity == ErrorSeverity.CRITICAL assert error.code == 'NO_DATA' def test_data_requirements_indicators(self): """Test data requirements checking for indicators""" # Test SMA with sufficient data error = DataRequirements.check_indicator_requirements('sma', 50, {'period': 20}) assert error.severity == ErrorSeverity.INFO # Test SMA with insufficient data error = DataRequirements.check_indicator_requirements('sma', 15, {'period': 20}) assert error.severity == ErrorSeverity.WARNING assert error.code == 'INSUFFICIENT_INDICATOR_DATA' # Test unknown indicator error = DataRequirements.check_indicator_requirements('unknown', 50, {}) assert error.severity == ErrorSeverity.ERROR assert error.code == 'UNKNOWN_INDICATOR' def test_chart_error_handler(self, sample_data, insufficient_data): """Test ChartErrorHandler functionality""" handler = ChartErrorHandler() # Test with sufficient data is_valid = handler.validate_data_sufficiency(sample_data) assert is_valid == True assert len(handler.errors) == 0 # Test with insufficient data and indicators indicators = [{'type': 'sma', 'parameters': {'period': 30}}] is_valid = handler.validate_data_sufficiency(insufficient_data, indicators=indicators) assert is_valid == False assert len(handler.errors) > 0 or len(handler.warnings) > 0 # Test error summary summary = handler.get_error_summary() assert 'has_errors' in summary assert 'can_proceed' in summary def test_convenience_functions(self, sample_data, insufficient_data): """Test convenience functions for error handling""" # Test check_data_sufficiency is_sufficient, summary = check_data_sufficiency(sample_data) assert is_sufficient == True assert summary['can_proceed'] == True # Test with insufficient data indicators = [{'type': 'sma', 'parameters': {'period': 100}}] is_sufficient, summary = check_data_sufficiency(insufficient_data, indicators) assert is_sufficient == False # Test get_error_message error_msg = get_error_message(insufficient_data, indicators) assert isinstance(error_msg, str) assert len(error_msg) > 0 class TestBaseLayerSystem: """Test suite for base layer components""" @pytest.fixture def sample_df(self): """Sample DataFrame for testing""" base_time = datetime.now(timezone.utc) - timedelta(hours=24) data = [] for i in range(100): data.append({ 'timestamp': base_time + timedelta(minutes=i), 'open': 50000 + i * 10, 'high': 50100 + i * 10, 'low': 49900 + i * 10, 'close': 50050 + i * 10, 'volume': 1000 + i * 5 }) return pd.DataFrame(data) @pytest.fixture def invalid_df(self): """Invalid DataFrame for testing error handling""" return pd.DataFrame([ {'timestamp': datetime.now(), 'open': -100, 'high': 50, 'low': 60, 'close': 40, 'volume': -50}, {'timestamp': datetime.now(), 'open': None, 'high': None, 'low': None, 'close': None, 'volume': None} ]) def test_layer_config(self): """Test LayerConfig creation""" config = LayerConfig(name="test", enabled=True, color="#FF0000") assert config.name == "test" assert config.enabled == True assert config.color == "#FF0000" assert config.style == {} assert config.subplot_row is None def test_base_layer(self): """Test BaseLayer functionality""" config = LayerConfig(name="test_layer") layer = BaseLayer(config) assert layer.config.name == "test_layer" assert hasattr(layer, 'error_handler') assert hasattr(layer, 'logger') def test_candlestick_layer_validation(self, sample_df, invalid_df): """Test CandlestickLayer data validation""" layer = CandlestickLayer() # Test valid data is_valid = layer.validate_data(sample_df) assert is_valid == True # Test invalid data is_valid = layer.validate_data(invalid_df) assert is_valid == False assert len(layer.error_handler.errors) > 0 def test_candlestick_layer_render(self, sample_df): """Test CandlestickLayer rendering""" layer = CandlestickLayer() fig = go.Figure() result_fig = layer.render(fig, sample_df) assert result_fig is not None assert len(result_fig.data) >= 1 # Should have candlestick trace def test_volume_layer_validation(self, sample_df, invalid_df): """Test VolumeLayer data validation""" layer = VolumeLayer() # Test valid data is_valid = layer.validate_data(sample_df) assert is_valid == True # Test invalid data (some volume issues) is_valid = layer.validate_data(invalid_df) # Volume layer should handle invalid data gracefully assert len(layer.error_handler.warnings) >= 0 # May have warnings def test_volume_layer_render(self, sample_df): """Test VolumeLayer rendering""" layer = VolumeLayer() fig = go.Figure() result_fig = layer.render(fig, sample_df) assert result_fig is not None def test_layer_manager(self, sample_df): """Test LayerManager functionality""" manager = LayerManager() # Add layers candlestick_layer = CandlestickLayer() volume_layer = VolumeLayer() manager.add_layer(candlestick_layer) manager.add_layer(volume_layer) assert len(manager.layers) == 2 # Test enabled layers enabled = manager.get_enabled_layers() assert len(enabled) == 2 # Test overlay vs subplot layers overlays = manager.get_overlay_layers() subplots = manager.get_subplot_layers() assert len(overlays) == 1 # Candlestick is overlay assert len(subplots) >= 1 # Volume is subplot # Test layout calculation layout_config = manager.calculate_subplot_layout() assert 'rows' in layout_config assert 'cols' in layout_config assert layout_config['rows'] >= 2 # Main chart + volume subplot # Test rendering all layers fig = manager.render_all_layers(sample_df) assert fig is not None assert len(fig.data) >= 2 # Candlestick + volume class TestIndicatorLayers: """Test suite for indicator layer components""" @pytest.fixture def sample_df(self): """Sample DataFrame with trend for indicator testing""" base_time = datetime.now(timezone.utc) - timedelta(hours=24) data = [] for i in range(100): # Create trending data for better indicator calculation trend = i * 0.1 base_price = 50000 + trend data.append({ 'timestamp': base_time + timedelta(minutes=i), 'open': base_price + (i % 3) * 10, 'high': base_price + 50 + (i % 3) * 10, 'low': base_price - 50 + (i % 3) * 10, 'close': base_price + (i % 2) * 10, 'volume': 1000 + i * 5 }) return pd.DataFrame(data) @pytest.fixture def insufficient_df(self): """Insufficient data for indicator testing""" base_time = datetime.now(timezone.utc) data = [] for i in range(10): # Only 10 data points data.append({ 'timestamp': base_time + timedelta(minutes=i), 'open': 50000, 'high': 50100, 'low': 49900, 'close': 50050, 'volume': 1000 }) return pd.DataFrame(data) def test_indicator_layer_config(self): """Test IndicatorLayerConfig creation""" config = IndicatorLayerConfig( name="test_indicator", indicator_type="sma", parameters={'period': 20} ) assert config.name == "test_indicator" assert config.indicator_type == "sma" assert config.parameters == {'period': 20} assert config.line_width == 2 assert config.opacity == 1.0 def test_sma_layer(self, sample_df, insufficient_df): """Test SMALayer functionality""" config = IndicatorLayerConfig( name="SMA(20)", indicator_type='sma', parameters={'period': 20} ) layer = SMALayer(config) # Test with sufficient data is_valid = layer.validate_indicator_data(sample_df, required_columns=['close', 'timestamp']) assert is_valid == True # Test calculation sma_data = layer._calculate_sma(sample_df, 20) assert sma_data is not None assert 'sma' in sma_data.columns assert len(sma_data) > 0 # Test with insufficient data is_valid = layer.validate_indicator_data(insufficient_df, required_columns=['close', 'timestamp']) # Should have warnings but may still be valid for short periods assert len(layer.error_handler.warnings) >= 0 def test_ema_layer(self, sample_df): """Test EMALayer functionality""" config = IndicatorLayerConfig( name="EMA(12)", indicator_type='ema', parameters={'period': 12} ) layer = EMALayer(config) # Test validation is_valid = layer.validate_indicator_data(sample_df, required_columns=['close', 'timestamp']) assert is_valid == True # Test calculation ema_data = layer._calculate_ema(sample_df, 12) assert ema_data is not None assert 'ema' in ema_data.columns assert len(ema_data) > 0 def test_bollinger_bands_layer(self, sample_df): """Test BollingerBandsLayer functionality""" config = IndicatorLayerConfig( name="BB(20,2)", indicator_type='bollinger_bands', parameters={'period': 20, 'std_dev': 2} ) layer = BollingerBandsLayer(config) # Test validation is_valid = layer.validate_indicator_data(sample_df, required_columns=['close', 'timestamp']) assert is_valid == True # Test calculation bb_data = layer._calculate_bollinger_bands(sample_df, 20, 2) assert bb_data is not None assert 'upper_band' in bb_data.columns assert 'middle_band' in bb_data.columns assert 'lower_band' in bb_data.columns assert len(bb_data) > 0 def test_safe_calculate_indicator(self, sample_df, insufficient_df): """Test safe indicator calculation with error handling""" config = IndicatorLayerConfig( name="SMA(20)", indicator_type='sma', parameters={'period': 20} ) layer = SMALayer(config) # Test successful calculation result = layer.safe_calculate_indicator( sample_df, layer._calculate_sma, period=20 ) assert result is not None # Test with insufficient data - should attempt recovery result = layer.safe_calculate_indicator( insufficient_df, layer._calculate_sma, period=50 # Too large for data ) # Should either return adjusted result or None assert result is None or len(result) > 0 class TestSubplotLayers: """Test suite for subplot layer components""" @pytest.fixture def sample_df(self): """Sample DataFrame for RSI/MACD testing""" base_time = datetime.now(timezone.utc) - timedelta(hours=24) data = [] # Create more realistic price data for RSI/MACD prices = [50000] for i in range(100): # Random walk with trend change = (i % 7 - 3) * 50 # Some volatility new_price = prices[-1] + change prices.append(new_price) data.append({ 'timestamp': base_time + timedelta(minutes=i), 'open': prices[i], 'high': prices[i] + abs(change) + 20, 'low': prices[i] - abs(change) - 20, 'close': prices[i+1], 'volume': 1000 + i * 5 }) return pd.DataFrame(data) def test_subplot_layer_config(self): """Test SubplotLayerConfig creation""" config = SubplotLayerConfig( name="RSI(14)", indicator_type="rsi", parameters={'period': 14}, subplot_height_ratio=0.25, y_axis_range=(0, 100), reference_lines=[30, 70] ) assert config.name == "RSI(14)" assert config.indicator_type == "rsi" assert config.subplot_height_ratio == 0.25 assert config.y_axis_range == (0, 100) assert config.reference_lines == [30, 70] def test_rsi_layer(self, sample_df): """Test RSILayer functionality""" layer = RSILayer(period=14) # Test validation is_valid = layer.validate_indicator_data(sample_df, required_columns=['close', 'timestamp']) assert is_valid == True # Test RSI calculation rsi_data = layer._calculate_rsi(sample_df, 14) assert rsi_data is not None assert 'rsi' in rsi_data.columns assert len(rsi_data) > 0 # Validate RSI values are in correct range assert (rsi_data['rsi'] >= 0).all() assert (rsi_data['rsi'] <= 100).all() # Test subplot properties assert layer.has_fixed_range() == True assert layer.get_y_axis_range() == (0, 100) assert 30 in layer.get_reference_lines() assert 70 in layer.get_reference_lines() def test_macd_layer(self, sample_df): """Test MACDLayer functionality""" layer = MACDLayer(fast_period=12, slow_period=26, signal_period=9) # Test validation is_valid = layer.validate_indicator_data(sample_df, required_columns=['close', 'timestamp']) assert is_valid == True # Test MACD calculation macd_data = layer._calculate_macd(sample_df, 12, 26, 9) assert macd_data is not None assert 'macd' in macd_data.columns assert 'signal' in macd_data.columns assert 'histogram' in macd_data.columns assert len(macd_data) > 0 # Test subplot properties assert layer.should_show_zero_line() == True assert layer.get_subplot_height_ratio() == 0.3 def test_rsi_calculation_edge_cases(self, sample_df): """Test RSI calculation with edge cases""" layer = RSILayer(period=14) # Test with very short period short_data = sample_df.head(20) rsi_data = layer._calculate_rsi(short_data, 5) # Short period assert rsi_data is not None assert len(rsi_data) > 0 # Test with period too large for data try: layer._calculate_rsi(sample_df.head(10), 20) # Period larger than data assert False, "Should have raised an error" except Exception: pass # Expected to fail def test_macd_calculation_edge_cases(self, sample_df): """Test MACD calculation with edge cases""" layer = MACDLayer(fast_period=12, slow_period=26, signal_period=9) # Test with invalid periods (fast >= slow) try: layer._calculate_macd(sample_df, 26, 12, 9) # fast >= slow assert False, "Should have raised an error" except Exception: pass # Expected to fail class TestLayerIntegration: """Test suite for layer integration and complex scenarios""" @pytest.fixture def sample_df(self): """Sample DataFrame for integration testing""" base_time = datetime.now(timezone.utc) - timedelta(hours=24) data = [] for i in range(150): # Enough data for all indicators trend = i * 0.1 base_price = 50000 + trend volatility = (i % 10) * 20 data.append({ 'timestamp': base_time + timedelta(minutes=i), 'open': base_price + volatility, 'high': base_price + volatility + 50, 'low': base_price + volatility - 50, 'close': base_price + volatility + (i % 3 - 1) * 10, 'volume': 1000 + i * 5 }) return pd.DataFrame(data) def test_full_chart_creation(self, sample_df): """Test creating a full chart with multiple layers""" manager = LayerManager() # Add base layers manager.add_layer(CandlestickLayer()) manager.add_layer(VolumeLayer()) # Add indicator layers manager.add_layer(SMALayer(IndicatorLayerConfig( name="SMA(20)", indicator_type='sma', parameters={'period': 20} ))) manager.add_layer(EMALayer(IndicatorLayerConfig( name="EMA(12)", indicator_type='ema', parameters={'period': 12} ))) # Add subplot layers manager.add_layer(RSILayer(period=14)) manager.add_layer(MACDLayer(fast_period=12, slow_period=26, signal_period=9)) # Calculate layout layout_config = manager.calculate_subplot_layout() assert layout_config['rows'] >= 4 # Main + volume + RSI + MACD # Render all layers fig = manager.render_all_layers(sample_df) assert fig is not None assert len(fig.data) >= 6 # Candlestick + volume + SMA + EMA + RSI + MACD components def test_error_recovery_integration(self): """Test error recovery with insufficient data""" manager = LayerManager() # Create insufficient data base_time = datetime.now(timezone.utc) insufficient_data = pd.DataFrame([ { 'timestamp': base_time + timedelta(minutes=i), 'open': 50000, 'high': 50100, 'low': 49900, 'close': 50050, 'volume': 1000 } for i in range(15) # Only 15 data points ]) # Add layers that require more data manager.add_layer(CandlestickLayer()) manager.add_layer(SMALayer(IndicatorLayerConfig( name="SMA(50)", # Requires too much data indicator_type='sma', parameters={'period': 50} ))) # Should still create a chart (graceful degradation) fig = manager.render_all_layers(insufficient_data) assert fig is not None # Should have at least candlestick layer assert len(fig.data) >= 1 def test_mixed_valid_invalid_data(self): """Test handling mixed valid and invalid data""" # Create data with some invalid entries base_time = datetime.now(timezone.utc) mixed_data = [] for i in range(50): if i % 10 == 0: # Every 10th entry is invalid data_point = { 'timestamp': base_time + timedelta(minutes=i), 'open': -100, # Invalid negative price 'high': None, # Missing data 'low': None, 'close': None, 'volume': -50 # Invalid negative volume } else: data_point = { 'timestamp': base_time + timedelta(minutes=i), 'open': 50000 + i * 10, 'high': 50100 + i * 10, 'low': 49900 + i * 10, 'close': 50050 + i * 10, 'volume': 1000 + i * 5 } mixed_data.append(data_point) df = pd.DataFrame(mixed_data) # Test candlestick layer with mixed data candlestick_layer = CandlestickLayer() is_valid = candlestick_layer.validate_data(df) # Should handle mixed data gracefully if not is_valid: # Should have warnings but possibly still proceed assert len(candlestick_layer.error_handler.warnings) > 0 def test_layer_manager_dynamic_layout(self): """Test LayerManager dynamic layout calculation""" manager = LayerManager() # Test with no subplots manager.add_layer(CandlestickLayer()) layout = manager.calculate_subplot_layout() assert layout['rows'] == 1 # Add one subplot manager.add_layer(VolumeLayer()) layout = manager.calculate_subplot_layout() assert layout['rows'] == 2 # Add more subplots manager.add_layer(RSILayer(period=14)) manager.add_layer(MACDLayer(fast_period=12, slow_period=26, signal_period=9)) layout = manager.calculate_subplot_layout() assert layout['rows'] == 4 # Main + volume + RSI + MACD assert layout['cols'] == 1 assert len(layout['subplot_titles']) == 4 assert len(layout['row_heights']) == 4 # Test row height calculation total_height = sum(layout['row_heights']) assert abs(total_height - 1.0) < 0.01 # Should sum to approximately 1.0 if __name__ == "__main__": pytest.main([__file__, "-v"])