425 lines
15 KiB
Python
425 lines
15 KiB
Python
"""
|
|
Subplot Chart Layers
|
|
|
|
This module contains subplot layer implementations for indicators that render
|
|
in separate subplots below the main price chart, such as RSI, MACD, and other
|
|
oscillators and momentum indicators.
|
|
"""
|
|
|
|
import plotly.graph_objects as go
|
|
import pandas as pd
|
|
from decimal import Decimal
|
|
from typing import Dict, Any, Optional, List, Union, Tuple
|
|
from dataclasses import dataclass
|
|
|
|
from .base import BaseChartLayer, LayerConfig
|
|
from .indicators import BaseIndicatorLayer, IndicatorLayerConfig
|
|
from data.common.indicators import TechnicalIndicators, IndicatorResult
|
|
from data.common.data_types import OHLCVCandle
|
|
from components.charts.utils import get_indicator_colors
|
|
from utils.logger import get_logger
|
|
from ..error_handling import (
|
|
ChartErrorHandler, ChartError, ErrorSeverity, DataRequirements,
|
|
InsufficientDataError, DataValidationError, IndicatorCalculationError,
|
|
ErrorRecoveryStrategies, create_error_annotation, get_error_message
|
|
)
|
|
|
|
# Initialize logger
|
|
logger = get_logger()
|
|
|
|
|
|
@dataclass
|
|
class SubplotLayerConfig(IndicatorLayerConfig):
|
|
"""Extended configuration for subplot indicator layers"""
|
|
subplot_height_ratio: float = 0.25 # Height ratio for subplot (0.25 = 25% of total height)
|
|
y_axis_range: Optional[Tuple[float, float]] = None # Fixed y-axis range (min, max)
|
|
show_zero_line: bool = False # Show horizontal line at y=0
|
|
reference_lines: List[float] = None # Additional horizontal reference lines
|
|
|
|
def __post_init__(self):
|
|
super().__post_init__()
|
|
if self.reference_lines is None:
|
|
self.reference_lines = []
|
|
|
|
|
|
class BaseSubplotLayer(BaseIndicatorLayer):
|
|
"""
|
|
Base class for all subplot indicator layers.
|
|
|
|
Provides common functionality for indicators that render in separate subplots
|
|
with their own y-axis scaling and reference lines.
|
|
"""
|
|
|
|
def __init__(self, config: SubplotLayerConfig):
|
|
"""
|
|
Initialize base subplot layer.
|
|
|
|
Args:
|
|
config: Subplot layer configuration
|
|
"""
|
|
super().__init__(config)
|
|
self.subplot_config = config
|
|
|
|
def get_subplot_height_ratio(self) -> float:
|
|
"""Get the height ratio for this subplot."""
|
|
return self.subplot_config.subplot_height_ratio
|
|
|
|
def has_fixed_range(self) -> bool:
|
|
"""Check if this subplot has a fixed y-axis range."""
|
|
return self.subplot_config.y_axis_range is not None
|
|
|
|
def get_y_axis_range(self) -> Optional[Tuple[float, float]]:
|
|
"""Get the fixed y-axis range if defined."""
|
|
return self.subplot_config.y_axis_range
|
|
|
|
def should_show_zero_line(self) -> bool:
|
|
"""Check if zero line should be shown."""
|
|
return self.subplot_config.show_zero_line
|
|
|
|
def get_reference_lines(self) -> List[float]:
|
|
"""Get additional reference lines to draw."""
|
|
return self.subplot_config.reference_lines
|
|
|
|
def add_reference_lines(self, fig: go.Figure, row: int, col: int = 1) -> None:
|
|
"""
|
|
Add reference lines to the subplot.
|
|
|
|
Args:
|
|
fig: Target figure
|
|
row: Subplot row
|
|
col: Subplot column
|
|
"""
|
|
try:
|
|
# Add zero line if enabled
|
|
if self.should_show_zero_line():
|
|
fig.add_hline(
|
|
y=0,
|
|
line=dict(color='gray', width=1, dash='dash'),
|
|
row=row,
|
|
col=col
|
|
)
|
|
|
|
# Add additional reference lines
|
|
for ref_value in self.get_reference_lines():
|
|
fig.add_hline(
|
|
y=ref_value,
|
|
line=dict(color='lightgray', width=1, dash='dot'),
|
|
row=row,
|
|
col=col
|
|
)
|
|
|
|
except Exception as e:
|
|
self.logger.warning(f"Subplot layers: Could not add reference lines: {e}")
|
|
|
|
|
|
class RSILayer(BaseSubplotLayer):
|
|
"""
|
|
Relative Strength Index (RSI) subplot layer.
|
|
|
|
Renders RSI oscillator in a separate subplot with standard overbought (70)
|
|
and oversold (30) reference lines.
|
|
"""
|
|
|
|
def __init__(self, period: int = 14, color: str = None, name: str = None):
|
|
"""
|
|
Initialize RSI layer.
|
|
|
|
Args:
|
|
period: RSI period (default: 14)
|
|
color: Line color (optional, uses default)
|
|
name: Layer name (optional, auto-generated)
|
|
"""
|
|
# Use default color if not specified
|
|
if color is None:
|
|
colors = get_indicator_colors()
|
|
color = colors.get('rsi', '#20c997')
|
|
|
|
# Generate name if not specified
|
|
if name is None:
|
|
name = f"RSI({period})"
|
|
|
|
# Find next available subplot row (will be managed by LayerManager)
|
|
subplot_row = 2 # Default to row 2 (first subplot after main chart)
|
|
|
|
config = SubplotLayerConfig(
|
|
name=name,
|
|
indicator_type="rsi",
|
|
color=color,
|
|
parameters={'period': period},
|
|
subplot_row=subplot_row,
|
|
subplot_height_ratio=0.25,
|
|
y_axis_range=(0, 100), # RSI ranges from 0 to 100
|
|
reference_lines=[30, 70], # Oversold and overbought levels
|
|
style={
|
|
'line_color': color,
|
|
'line_width': 2,
|
|
'opacity': 1.0
|
|
}
|
|
)
|
|
|
|
super().__init__(config)
|
|
self.period = period
|
|
|
|
def _calculate_rsi(self, data: pd.DataFrame, period: int) -> pd.DataFrame:
|
|
"""Calculate RSI with validation and error handling"""
|
|
try:
|
|
result_df = data.copy()
|
|
|
|
# Calculate price changes
|
|
result_df['price_change'] = result_df['close'].diff()
|
|
|
|
# Separate gains and losses
|
|
result_df['gain'] = result_df['price_change'].clip(lower=0)
|
|
result_df['loss'] = -result_df['price_change'].clip(upper=0)
|
|
|
|
# Calculate average gains and losses using Wilder's smoothing
|
|
result_df['avg_gain'] = result_df['gain'].ewm(alpha=1/period, adjust=False).mean()
|
|
result_df['avg_loss'] = result_df['loss'].ewm(alpha=1/period, adjust=False).mean()
|
|
|
|
# Calculate RS and RSI
|
|
result_df['rs'] = result_df['avg_gain'] / result_df['avg_loss']
|
|
result_df['rsi'] = 100 - (100 / (1 + result_df['rs']))
|
|
|
|
# Remove rows where RSI cannot be calculated
|
|
result_df = result_df.iloc[period:].copy()
|
|
|
|
# Remove NaN values and invalid RSI values
|
|
result_df = result_df.dropna(subset=['rsi'])
|
|
result_df = result_df[
|
|
(result_df['rsi'] >= 0) &
|
|
(result_df['rsi'] <= 100) &
|
|
pd.notna(result_df['rsi'])
|
|
]
|
|
|
|
if result_df.empty:
|
|
raise Exception(f'RSI calculation produced no values (period={period}, data_length={len(data)})')
|
|
|
|
return result_df[['timestamp', 'rsi']]
|
|
|
|
except Exception as e:
|
|
raise Exception(f'RSI calculation failed: {str(e)}')
|
|
|
|
def render(self, fig: go.Figure, data: pd.DataFrame, **kwargs) -> go.Figure:
|
|
"""Render RSI layer for compatibility with base interface"""
|
|
try:
|
|
# Calculate RSI
|
|
rsi_data = self._calculate_rsi(data, self.period)
|
|
if rsi_data.empty:
|
|
return fig
|
|
|
|
# Create RSI trace
|
|
rsi_trace = go.Scatter(
|
|
x=rsi_data['timestamp'],
|
|
y=rsi_data['rsi'],
|
|
mode='lines',
|
|
name=self.config.name,
|
|
line=dict(
|
|
color=self.config.color,
|
|
width=2
|
|
),
|
|
showlegend=True
|
|
)
|
|
|
|
# Add trace
|
|
row = kwargs.get('row', self.config.subplot_row or 2)
|
|
col = kwargs.get('col', 1)
|
|
|
|
if hasattr(fig, 'add_trace'):
|
|
fig.add_trace(rsi_trace, row=row, col=col)
|
|
else:
|
|
fig.add_trace(rsi_trace)
|
|
|
|
# Add reference lines
|
|
self.add_reference_lines(fig, row, col)
|
|
|
|
return fig
|
|
except Exception as e:
|
|
self.logger.error(f"Subplot layers: Error rendering RSI layer: {e}")
|
|
return fig
|
|
|
|
|
|
class MACDLayer(BaseSubplotLayer):
|
|
"""MACD (Moving Average Convergence Divergence) subplot layer with enhanced error handling"""
|
|
|
|
def __init__(self, fast_period: int = 12, slow_period: int = 26, signal_period: int = 9,
|
|
color: str = None, name: str = None):
|
|
"""Initialize MACD layer with custom parameters"""
|
|
# Use default color if not specified
|
|
if color is None:
|
|
colors = get_indicator_colors()
|
|
color = colors.get('macd', '#fd7e14')
|
|
|
|
# Generate name if not specified
|
|
if name is None:
|
|
name = f"MACD({fast_period},{slow_period},{signal_period})"
|
|
|
|
config = SubplotLayerConfig(
|
|
name=name,
|
|
indicator_type="macd",
|
|
color=color,
|
|
parameters={
|
|
'fast_period': fast_period,
|
|
'slow_period': slow_period,
|
|
'signal_period': signal_period
|
|
},
|
|
subplot_row=3, # Will be managed by LayerManager
|
|
subplot_height_ratio=0.3,
|
|
show_zero_line=True,
|
|
style={
|
|
'line_color': color,
|
|
'line_width': 2,
|
|
'opacity': 1.0
|
|
}
|
|
)
|
|
|
|
super().__init__(config)
|
|
self.fast_period = fast_period
|
|
self.slow_period = slow_period
|
|
self.signal_period = signal_period
|
|
|
|
def _calculate_macd(self, data: pd.DataFrame, fast_period: int,
|
|
slow_period: int, signal_period: int) -> pd.DataFrame:
|
|
"""Calculate MACD with validation and error handling"""
|
|
try:
|
|
result_df = data.copy()
|
|
|
|
# Validate periods
|
|
if fast_period >= slow_period:
|
|
raise Exception(f'Fast period ({fast_period}) must be less than slow period ({slow_period})')
|
|
|
|
# Calculate EMAs
|
|
result_df['ema_fast'] = result_df['close'].ewm(span=fast_period, adjust=False).mean()
|
|
result_df['ema_slow'] = result_df['close'].ewm(span=slow_period, adjust=False).mean()
|
|
|
|
# Calculate MACD line
|
|
result_df['macd'] = result_df['ema_fast'] - result_df['ema_slow']
|
|
|
|
# Calculate signal line
|
|
result_df['signal'] = result_df['macd'].ewm(span=signal_period, adjust=False).mean()
|
|
|
|
# Calculate histogram
|
|
result_df['histogram'] = result_df['macd'] - result_df['signal']
|
|
|
|
# Remove rows where MACD cannot be calculated reliably
|
|
warmup_period = slow_period + signal_period
|
|
result_df = result_df.iloc[warmup_period:].copy()
|
|
|
|
# Remove NaN values
|
|
result_df = result_df.dropna(subset=['macd', 'signal', 'histogram'])
|
|
|
|
if result_df.empty:
|
|
raise Exception(f'MACD calculation produced no values (fast={fast_period}, slow={slow_period}, signal={signal_period})')
|
|
|
|
return result_df[['timestamp', 'macd', 'signal', 'histogram']]
|
|
|
|
except Exception as e:
|
|
raise Exception(f'MACD calculation failed: {str(e)}')
|
|
|
|
def render(self, fig: go.Figure, data: pd.DataFrame, **kwargs) -> go.Figure:
|
|
"""Render MACD layer for compatibility with base interface"""
|
|
try:
|
|
# Calculate MACD
|
|
macd_data = self._calculate_macd(data, self.fast_period, self.slow_period, self.signal_period)
|
|
if macd_data.empty:
|
|
return fig
|
|
|
|
row = kwargs.get('row', self.config.subplot_row or 3)
|
|
col = kwargs.get('col', 1)
|
|
|
|
# Create MACD line trace
|
|
macd_trace = go.Scatter(
|
|
x=macd_data['timestamp'],
|
|
y=macd_data['macd'],
|
|
mode='lines',
|
|
name=f'{self.config.name} Line',
|
|
line=dict(color=self.config.color, width=2),
|
|
showlegend=True
|
|
)
|
|
|
|
# Create signal line trace
|
|
signal_trace = go.Scatter(
|
|
x=macd_data['timestamp'],
|
|
y=macd_data['signal'],
|
|
mode='lines',
|
|
name=f'{self.config.name} Signal',
|
|
line=dict(color='#FF9800', width=2),
|
|
showlegend=True
|
|
)
|
|
|
|
# Create histogram
|
|
histogram_colors = ['green' if h >= 0 else 'red' for h in macd_data['histogram']]
|
|
histogram_trace = go.Bar(
|
|
x=macd_data['timestamp'],
|
|
y=macd_data['histogram'],
|
|
name=f'{self.config.name} Histogram',
|
|
marker_color=histogram_colors,
|
|
opacity=0.6,
|
|
showlegend=True
|
|
)
|
|
|
|
# Add traces
|
|
if hasattr(fig, 'add_trace'):
|
|
fig.add_trace(macd_trace, row=row, col=col)
|
|
fig.add_trace(signal_trace, row=row, col=col)
|
|
fig.add_trace(histogram_trace, row=row, col=col)
|
|
else:
|
|
fig.add_trace(macd_trace)
|
|
fig.add_trace(signal_trace)
|
|
fig.add_trace(histogram_trace)
|
|
|
|
# Add zero line
|
|
self.add_reference_lines(fig, row, col)
|
|
|
|
return fig
|
|
except Exception as e:
|
|
self.logger.error(f"Subplot layers: Error rendering MACD layer: {e}")
|
|
return fig
|
|
|
|
|
|
def create_rsi_layer(period: int = 14, **kwargs) -> 'RSILayer':
|
|
"""
|
|
Convenience function to create an RSI layer.
|
|
|
|
Args:
|
|
period: RSI period (default: 14)
|
|
**kwargs: Additional configuration options
|
|
|
|
Returns:
|
|
Configured RSI layer
|
|
"""
|
|
return RSILayer(period=period, **kwargs)
|
|
|
|
|
|
def create_macd_layer(fast_period: int = 12, slow_period: int = 26,
|
|
signal_period: int = 9, **kwargs) -> 'MACDLayer':
|
|
"""
|
|
Convenience function to create a MACD layer.
|
|
|
|
Args:
|
|
fast_period: Fast EMA period (default: 12)
|
|
slow_period: Slow EMA period (default: 26)
|
|
signal_period: Signal line period (default: 9)
|
|
**kwargs: Additional configuration options
|
|
|
|
Returns:
|
|
Configured MACD layer
|
|
"""
|
|
return MACDLayer(
|
|
fast_period=fast_period,
|
|
slow_period=slow_period,
|
|
signal_period=signal_period,
|
|
**kwargs
|
|
)
|
|
|
|
|
|
def create_common_subplot_indicators() -> List[BaseSubplotLayer]:
|
|
"""
|
|
Create commonly used subplot indicators.
|
|
|
|
Returns:
|
|
List of configured subplot indicator layers (RSI, MACD)
|
|
"""
|
|
return [
|
|
RSILayer(period=14),
|
|
MACDLayer(fast_period=12, slow_period=26, signal_period=9)
|
|
] |