2025-06-12 13:27:30 +08:00

713 lines
28 KiB
Python

"""
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)")
]