""" Technical Indicator Chart Layers This module implements overlay indicator layers for technical analysis visualization including SMA, EMA, and Bollinger Bands with comprehensive error handling. """ import pandas as pd import plotly.graph_objects as go from typing import Dict, Any, Optional, List, Union, Callable from dataclasses import dataclass from ..error_handling import ( ChartErrorHandler, ChartError, ErrorSeverity, DataRequirements, InsufficientDataError, DataValidationError, IndicatorCalculationError, ErrorRecoveryStrategies, create_error_annotation, get_error_message ) from .base import BaseLayer, LayerConfig from data.common.indicators import TechnicalIndicators from data.common.data_types import OHLCVCandle from components.charts.utils import get_indicator_colors from utils.logger import get_logger # Initialize logger logger = get_logger() @dataclass class IndicatorLayerConfig(LayerConfig): """Extended configuration for indicator layers""" id: str = "" indicator_type: str = "" # e.g., 'sma', 'ema', 'rsi' parameters: Dict[str, Any] = None # Indicator-specific parameters line_width: int = 2 opacity: float = 1.0 show_middle_line: bool = True # For indicators like Bollinger Bands def __post_init__(self): super().__post_init__() if self.parameters is None: self.parameters = {} class BaseIndicatorLayer(BaseLayer): """ Enhanced base class for all indicator layers with comprehensive error handling. """ def __init__(self, config: IndicatorLayerConfig): """ Initialize base indicator layer. Args: config: Indicator layer configuration """ super().__init__(config) self.indicators = TechnicalIndicators() self.colors = get_indicator_colors() self.calculated_data = None self.calculation_errors = [] def prepare_indicator_data(self, data: pd.DataFrame) -> List[OHLCVCandle]: """ Convert DataFrame to OHLCVCandle format for indicator calculations. Args: data: Chart data (OHLCV format) Returns: List of OHLCVCandle objects """ try: candles = [] for _, row in data.iterrows(): # Calculate start_time (assuming 1-minute candles for now) start_time = row['timestamp'] end_time = row['timestamp'] candle = OHLCVCandle( symbol="BTCUSDT", # Default symbol for testing timeframe="1m", # Default timeframe start_time=start_time, end_time=end_time, open=Decimal(str(row['open'])), high=Decimal(str(row['high'])), low=Decimal(str(row['low'])), close=Decimal(str(row['close'])), volume=Decimal(str(row.get('volume', 0))), trade_count=1, # Default trade count exchange="test", # Test exchange is_complete=True # Mark as complete for testing ) candles.append(candle) return candles except Exception as e: self.logger.error(f"Indicators: Error preparing indicator data: {e}") return [] def validate_indicator_data(self, data: Union[pd.DataFrame, List[Dict[str, Any]]], required_columns: List[str] = None) -> bool: """ Validate data specifically for indicator calculations. Args: data: Input data required_columns: Required columns for this indicator Returns: True if data is valid for indicator calculation """ try: # Use parent validation first if not super().validate_data(data): return False # Convert to DataFrame if needed if isinstance(data, list): df = pd.DataFrame(data) else: df = data.copy() # Check required columns for indicator if required_columns: missing_columns = [col for col in required_columns if col not in df.columns] if missing_columns: error = ChartError( code='MISSING_INDICATOR_COLUMNS', message=f'Missing columns for {self.config.indicator_type}: {missing_columns}', severity=ErrorSeverity.ERROR, context={ 'indicator_type': self.config.indicator_type, 'missing_columns': missing_columns, 'available_columns': list(df.columns) }, recovery_suggestion=f'Ensure data contains required columns: {required_columns}' ) self.error_handler.errors.append(error) return False # Check data sufficiency for indicator indicator_config = { 'type': self.config.indicator_type, 'parameters': self.config.parameters or {} } indicator_error = DataRequirements.check_indicator_requirements( self.config.indicator_type, len(df), self.config.parameters or {} ) if indicator_error.severity == ErrorSeverity.WARNING: self.error_handler.warnings.append(indicator_error) elif indicator_error.severity in [ErrorSeverity.ERROR, ErrorSeverity.CRITICAL]: self.error_handler.errors.append(indicator_error) return False return True except Exception as e: self.logger.error(f"Indicators: Error validating indicator data: {e}") error = ChartError( code='INDICATOR_VALIDATION_ERROR', message=f'Indicator validation failed: {str(e)}', severity=ErrorSeverity.ERROR, context={'exception': str(e), 'indicator_type': self.config.indicator_type} ) self.error_handler.errors.append(error) return False def safe_calculate_indicator(self, data: pd.DataFrame, calculation_func: Callable, **kwargs) -> Optional[pd.DataFrame]: """ Safely calculate indicator with error handling. Args: data: Input data calculation_func: Function to calculate indicator **kwargs: Additional arguments for calculation Returns: Calculated indicator data or None if failed """ try: # Validate data first if not self.validate_indicator_data(data): return None # Try calculation with recovery strategies result = calculation_func(data, **kwargs) # Validate result if result is None or (isinstance(result, pd.DataFrame) and result.empty): error = ChartError( code='EMPTY_INDICATOR_RESULT', message=f'Indicator calculation returned no data: {self.config.indicator_type}', severity=ErrorSeverity.WARNING, context={'indicator_type': self.config.indicator_type, 'input_length': len(data)}, recovery_suggestion='Check calculation parameters or input data range' ) self.error_handler.warnings.append(error) return None # Check for sufficient calculated data if isinstance(result, pd.DataFrame) and len(result) < len(data) * 0.1: error = ChartError( code='INSUFFICIENT_INDICATOR_OUTPUT', message=f'Very few indicator values calculated: {len(result)}/{len(data)}', severity=ErrorSeverity.WARNING, context={ 'indicator_type': self.config.indicator_type, 'output_length': len(result), 'input_length': len(data) }, recovery_suggestion='Consider adjusting indicator parameters' ) self.error_handler.warnings.append(error) self.calculated_data = result return result except Exception as e: self.logger.error(f"Indicators: Error calculating {self.config.indicator_type}: {e}") # Try to apply error recovery recovery_strategy = ErrorRecoveryStrategies.handle_insufficient_data( ChartError( code='INDICATOR_CALCULATION_ERROR', message=f'Calculation failed for {self.config.indicator_type}: {str(e)}', severity=ErrorSeverity.ERROR, context={'exception': str(e), 'indicator_type': self.config.indicator_type} ), fallback_options={'data_length': len(data)} ) if recovery_strategy['can_proceed'] and recovery_strategy['fallback_action'] == 'adjust_parameters': # Try with adjusted parameters try: modified_config = recovery_strategy.get('modified_config', {}) self.logger.info(f"Indicators: Retrying indicator calculation with adjusted parameters: {modified_config}") # Update parameters temporarily original_params = self.config.parameters.copy() if self.config.parameters else {} self.config.parameters.update(modified_config) # Retry calculation result = calculation_func(data, **kwargs) # Restore original parameters self.config.parameters = original_params if result is not None and not (isinstance(result, pd.DataFrame) and result.empty): # Add warning about parameter adjustment warning = ChartError( code='INDICATOR_PARAMETERS_ADJUSTED', message=recovery_strategy['user_message'], severity=ErrorSeverity.WARNING, context={'original_params': original_params, 'adjusted_params': modified_config} ) self.error_handler.warnings.append(warning) self.calculated_data = result return result except Exception as retry_error: self.logger.error(f"Indicators: Retry with adjusted parameters also failed: {retry_error}") # Final error if all recovery attempts fail error = ChartError( code='INDICATOR_CALCULATION_FAILED', message=f'Failed to calculate {self.config.indicator_type}: {str(e)}', severity=ErrorSeverity.ERROR, context={'exception': str(e), 'indicator_type': self.config.indicator_type} ) self.error_handler.errors.append(error) return None def create_indicator_traces(self, data: pd.DataFrame, subplot_row: int = 1) -> List[go.Scatter]: """ Create indicator traces with error handling. Must be implemented by subclasses. """ raise NotImplementedError("Subclasses must implement create_indicator_traces") def is_enabled(self) -> bool: """Check if the layer is enabled.""" return self.config.enabled def is_overlay(self) -> bool: """Check if this layer is an overlay (main chart) or subplot.""" return self.config.subplot_row is None def get_subplot_row(self) -> Optional[int]: """Get the subplot row for this layer.""" return self.config.subplot_row class SMALayer(BaseIndicatorLayer): """Simple Moving Average layer with enhanced error handling""" def __init__(self, config: IndicatorLayerConfig = None): """Initialize SMA layer""" if config is None: config = IndicatorLayerConfig( indicator_type='sma', parameters={'period': 20} ) super().__init__(config) def create_traces(self, data: List[Dict[str, Any]], subplot_row: int = 1) -> List[go.Scatter]: """Create SMA traces with comprehensive error handling""" try: # Convert to DataFrame df = pd.DataFrame(data) if isinstance(data, list) else data.copy() # Validate data if not self.validate_indicator_data(df, required_columns=['close', 'timestamp']): if self.error_handler.errors: return [self.create_error_trace(f"SMA Error: {self._error_message}")] # Calculate SMA with error handling period = self.config.parameters.get('period', 20) sma_data = self.safe_calculate_indicator( df, self._calculate_sma, period=period ) if sma_data is None: if self.error_handler.errors: return [self.create_error_trace(f"SMA calculation failed")] else: return [] # Skip layer gracefully # Create trace sma_trace = go.Scatter( x=sma_data['timestamp'], y=sma_data['sma'], mode='lines', name=f'SMA({period})', line=dict( color=self.config.color or '#2196F3', width=self.config.line_width ) ) self.traces = [sma_trace] return self.traces except Exception as e: error_msg = f"Indicators: Error creating SMA traces: {str(e)}" self.logger.error(error_msg) return [self.create_error_trace(error_msg)] def _calculate_sma(self, data: pd.DataFrame, period: int) -> pd.DataFrame: """Calculate SMA with validation""" try: result_df = data.copy() result_df['sma'] = result_df['close'].rolling(window=period, min_periods=period).mean() # Remove NaN values result_df = result_df.dropna(subset=['sma']) if result_df.empty: raise IndicatorCalculationError(ChartError( code='SMA_NO_VALUES', message=f'SMA calculation produced no values (period={period}, data_length={len(data)})', severity=ErrorSeverity.ERROR, context={'period': period, 'data_length': len(data)} )) return result_df[['timestamp', 'sma']] except Exception as e: raise IndicatorCalculationError(ChartError( code='SMA_CALCULATION_ERROR', message=f'SMA calculation failed: {str(e)}', severity=ErrorSeverity.ERROR, context={'period': period, 'data_length': len(data), 'exception': str(e)} )) def render(self, fig: go.Figure, data: pd.DataFrame, **kwargs) -> go.Figure: """Render SMA layer for compatibility with base interface""" try: traces = self.create_traces(data.to_dict('records'), **kwargs) for trace in traces: if hasattr(fig, 'add_trace'): fig.add_trace(trace, **kwargs) else: fig.add_trace(trace) return fig except Exception as e: self.logger.error(f"Indicators: Error rendering SMA layer: {e}") return fig class EMALayer(BaseIndicatorLayer): """Exponential Moving Average layer with enhanced error handling""" def __init__(self, config: IndicatorLayerConfig = None): """Initialize EMA layer""" if config is None: config = IndicatorLayerConfig( indicator_type='ema', parameters={'period': 20} ) super().__init__(config) def create_traces(self, data: List[Dict[str, Any]], subplot_row: int = 1) -> List[go.Scatter]: """Create EMA traces with comprehensive error handling""" try: # Convert to DataFrame df = pd.DataFrame(data) if isinstance(data, list) else data.copy() # Validate data if not self.validate_indicator_data(df, required_columns=['close', 'timestamp']): if self.error_handler.errors: return [self.create_error_trace(f"EMA Error: {self._error_message}")] # Calculate EMA with error handling period = self.config.parameters.get('period', 20) ema_data = self.safe_calculate_indicator( df, self._calculate_ema, period=period ) if ema_data is None: if self.error_handler.errors: return [self.create_error_trace(f"EMA calculation failed")] else: return [] # Skip layer gracefully # Create trace ema_trace = go.Scatter( x=ema_data['timestamp'], y=ema_data['ema'], mode='lines', name=f'EMA({period})', line=dict( color=self.config.color or '#FF9800', width=self.config.line_width ) ) self.traces = [ema_trace] return self.traces except Exception as e: error_msg = f"Indicators: Error creating EMA traces: {str(e)}" self.logger.error(error_msg) return [self.create_error_trace(error_msg)] def _calculate_ema(self, data: pd.DataFrame, period: int) -> pd.DataFrame: """Calculate EMA with validation""" try: result_df = data.copy() result_df['ema'] = result_df['close'].ewm(span=period, adjust=False).mean() # For EMA, we can start from the first value, but remove obvious outliers # Skip first few values for stability warmup_period = max(1, period // 4) result_df = result_df.iloc[warmup_period:] if result_df.empty: raise IndicatorCalculationError(ChartError( code='EMA_NO_VALUES', message=f'EMA calculation produced no values (period={period}, data_length={len(data)})', severity=ErrorSeverity.ERROR, context={'period': period, 'data_length': len(data)} )) return result_df[['timestamp', 'ema']] except Exception as e: raise IndicatorCalculationError(ChartError( code='EMA_CALCULATION_ERROR', message=f'EMA calculation failed: {str(e)}', severity=ErrorSeverity.ERROR, context={'period': period, 'data_length': len(data), 'exception': str(e)} )) def render(self, fig: go.Figure, data: pd.DataFrame, **kwargs) -> go.Figure: """Render EMA layer for compatibility with base interface""" try: traces = self.create_traces(data.to_dict('records'), **kwargs) for trace in traces: if hasattr(fig, 'add_trace'): fig.add_trace(trace, **kwargs) else: fig.add_trace(trace) return fig except Exception as e: self.logger.error(f"Indicators: Error rendering EMA layer: {e}") return fig class BollingerBandsLayer(BaseIndicatorLayer): """Bollinger Bands layer with enhanced error handling""" def __init__(self, config: IndicatorLayerConfig = None): """Initialize Bollinger Bands layer""" if config is None: config = IndicatorLayerConfig( indicator_type='bollinger_bands', parameters={'period': 20, 'std_dev': 2}, show_middle_line=True ) super().__init__(config) def create_traces(self, data: List[Dict[str, Any]], subplot_row: int = 1) -> List[go.Scatter]: """Create Bollinger Bands traces with comprehensive error handling""" try: # Convert to DataFrame df = pd.DataFrame(data) if isinstance(data, list) else data.copy() # Validate data if not self.validate_indicator_data(df, required_columns=['close', 'timestamp']): if self.error_handler.errors: return [self.create_error_trace(f"Bollinger Bands Error: {self._error_message}")] # Calculate Bollinger Bands with error handling period = self.config.parameters.get('period', 20) std_dev = self.config.parameters.get('std_dev', 2) bb_data = self.safe_calculate_indicator( df, self._calculate_bollinger_bands, period=period, std_dev=std_dev ) if bb_data is None: if self.error_handler.errors: return [self.create_error_trace(f"Bollinger Bands calculation failed")] else: return [] # Skip layer gracefully # Create traces traces = [] # Upper band upper_trace = go.Scatter( x=bb_data['timestamp'], y=bb_data['upper_band'], mode='lines', name=f'BB Upper({period})', line=dict(color=self.config.color or '#9C27B0', width=1), showlegend=True ) traces.append(upper_trace) # Lower band with fill lower_trace = go.Scatter( x=bb_data['timestamp'], y=bb_data['lower_band'], mode='lines', name=f'BB Lower({period})', line=dict(color=self.config.color or '#9C27B0', width=1), fill='tonexty', fillcolor='rgba(156, 39, 176, 0.1)', showlegend=True ) traces.append(lower_trace) # Middle line (SMA) if self.config.show_middle_line: middle_trace = go.Scatter( x=bb_data['timestamp'], y=bb_data['middle_band'], mode='lines', name=f'BB Middle({period})', line=dict(color=self.config.color or '#9C27B0', width=1, dash='dash'), showlegend=True ) traces.append(middle_trace) self.traces = traces return self.traces except Exception as e: error_msg = f"Indicators: Error creating Bollinger Bands traces: {str(e)}" self.logger.error(error_msg) return [self.create_error_trace(error_msg)] def _calculate_bollinger_bands(self, data: pd.DataFrame, period: int, std_dev: float) -> pd.DataFrame: """Calculate Bollinger Bands with validation""" try: result_df = data.copy() # Calculate middle band (SMA) result_df['middle_band'] = result_df['close'].rolling(window=period, min_periods=period).mean() # Calculate standard deviation result_df['std'] = result_df['close'].rolling(window=period, min_periods=period).std() # Calculate upper and lower bands result_df['upper_band'] = result_df['middle_band'] + (result_df['std'] * std_dev) result_df['lower_band'] = result_df['middle_band'] - (result_df['std'] * std_dev) # Remove NaN values result_df = result_df.dropna(subset=['middle_band', 'upper_band', 'lower_band']) if result_df.empty: raise IndicatorCalculationError(ChartError( code='BB_NO_VALUES', message=f'Bollinger Bands calculation produced no values (period={period}, data_length={len(data)})', severity=ErrorSeverity.ERROR, context={'period': period, 'std_dev': std_dev, 'data_length': len(data)} )) return result_df[['timestamp', 'upper_band', 'middle_band', 'lower_band']] except Exception as e: raise IndicatorCalculationError(ChartError( code='BB_CALCULATION_ERROR', message=f'Bollinger Bands calculation failed: {str(e)}', severity=ErrorSeverity.ERROR, context={'period': period, 'std_dev': std_dev, 'data_length': len(data), 'exception': str(e)} )) def render(self, fig: go.Figure, data: pd.DataFrame, **kwargs) -> go.Figure: """Render Bollinger Bands layer for compatibility with base interface""" try: traces = self.create_traces(data.to_dict('records'), **kwargs) for trace in traces: if hasattr(fig, 'add_trace'): fig.add_trace(trace, **kwargs) else: fig.add_trace(trace) return fig except Exception as e: self.logger.error(f"Indicators: Error rendering Bollinger Bands layer: {e}") return fig def create_sma_layer(period: int = 20, **kwargs) -> SMALayer: """ Convenience function to create an SMA layer. Args: period: SMA period **kwargs: Additional configuration options Returns: Configured SMA layer """ return SMALayer(period=period, **kwargs) def create_ema_layer(period: int = 12, **kwargs) -> EMALayer: """ Convenience function to create an EMA layer. Args: period: EMA period **kwargs: Additional configuration options Returns: Configured EMA layer """ return EMALayer(period=period, **kwargs) def create_bollinger_bands_layer(period: int = 20, std_dev: float = 2.0, **kwargs) -> BollingerBandsLayer: """ Convenience function to create a Bollinger Bands layer. Args: period: BB period (default: 20) std_dev: Standard deviation multiplier (default: 2.0) **kwargs: Additional configuration options Returns: Configured Bollinger Bands layer """ return BollingerBandsLayer(period=period, std_dev=std_dev, **kwargs) def create_common_ma_layers() -> List[BaseIndicatorLayer]: """ Create commonly used moving average layers. Returns: List of configured MA layers (SMA 20, SMA 50, EMA 12, EMA 26) """ colors = get_indicator_colors() return [ SMALayer(20, color=colors.get('sma', '#007bff'), name="SMA(20)"), SMALayer(50, color='#6c757d', name="SMA(50)"), # Gray for longer SMA EMALayer(12, color=colors.get('ema', '#ff6b35'), name="EMA(12)"), EMALayer(26, color='#28a745', name="EMA(26)") # Green for longer EMA ] def create_common_overlay_indicators() -> List[BaseIndicatorLayer]: """ Create commonly used overlay indicators including moving averages and Bollinger Bands. Returns: List of configured overlay indicator layers """ colors = get_indicator_colors() return [ SMALayer(20, color=colors.get('sma', '#007bff'), name="SMA(20)"), EMALayer(12, color=colors.get('ema', '#ff6b35'), name="EMA(12)"), BollingerBandsLayer(20, 2.0, color=colors.get('bb_upper', '#6f42c1'), name="BB(20,2)") ]