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

952 lines
37 KiB
Python

"""
Base Chart Layer Components
This module contains the foundational layer classes that serve as building blocks
for all chart components including candlestick charts, indicators, and signals.
"""
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import pandas as pd
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional, List, Union
from dataclasses import dataclass
from utils.logger import get_logger
from ..error_handling import (
ChartErrorHandler, ChartError, ErrorSeverity,
InsufficientDataError, DataValidationError, IndicatorCalculationError,
create_error_annotation, get_error_message
)
# Initialize logger
logger = get_logger()
@dataclass
class LayerConfig:
"""Configuration for chart layers"""
name: str
enabled: bool = True
color: Optional[str] = None
style: Dict[str, Any] = None
subplot_row: Optional[int] = None # None = main chart, 1+ = subplot row
def __post_init__(self):
if self.style is None:
self.style = {}
class BaseLayer:
"""
Base class for all chart layers providing common functionality
for data validation, error handling, and trace management.
"""
def __init__(self, config: LayerConfig):
self.config = config
self.logger = get_logger()
self.error_handler = ChartErrorHandler()
self.traces = []
self._is_valid = False
self._error_message = None
def validate_data(self, data: Union[pd.DataFrame, List[Dict[str, Any]]]) -> bool:
"""
Validate input data for layer requirements.
Args:
data: Input data to validate
Returns:
True if data is valid, False otherwise
"""
try:
self.error_handler.clear_errors()
# Check data type
if not isinstance(data, (pd.DataFrame, list)):
error = ChartError(
code='INVALID_DATA_TYPE',
message=f'Invalid data type for {self.__class__.__name__}: {type(data)}',
severity=ErrorSeverity.ERROR,
context={'layer': self.__class__.__name__, 'data_type': str(type(data))},
recovery_suggestion='Provide data as pandas DataFrame or list of dictionaries'
)
self.error_handler.errors.append(error)
return False
# Check data sufficiency
is_sufficient = self.error_handler.validate_data_sufficiency(
data,
chart_type='candlestick', # Default chart type since LayerConfig doesn't have layer_type
indicators=[{'type': 'candlestick', 'parameters': {}}] # Default indicator type
)
self._is_valid = is_sufficient
if not is_sufficient:
self._error_message = self.error_handler.get_user_friendly_message()
return is_sufficient
except Exception as e:
self.logger.error(f"Base layer: Data validation error in {self.__class__.__name__}: {e}")
error = ChartError(
code='VALIDATION_EXCEPTION',
message=f'Validation error: {str(e)}',
severity=ErrorSeverity.ERROR,
context={'layer': self.__class__.__name__, 'exception': str(e)}
)
self.error_handler.errors.append(error)
self._is_valid = False
self._error_message = str(e)
return False
def get_error_info(self) -> Dict[str, Any]:
"""Get error information for this layer"""
return {
'is_valid': self._is_valid,
'error_message': self._error_message,
'error_summary': self.error_handler.get_error_summary(),
'can_proceed': len(self.error_handler.errors) == 0
}
def create_error_trace(self, error_message: str) -> go.Scatter:
"""Create an error display trace"""
return go.Scatter(
x=[],
y=[],
mode='text',
text=[error_message],
textposition='middle center',
textfont={'size': 14, 'color': '#e74c3c'},
showlegend=False,
name=f"{self.__class__.__name__} Error"
)
class BaseChartLayer(ABC):
"""
Abstract base class for all chart layers.
This defines the interface that all chart layers must implement,
whether they are candlestick charts, indicators, or signal overlays.
"""
def __init__(self, config: LayerConfig):
"""
Initialize the base layer.
Args:
config: Layer configuration
"""
self.config = config
self.logger = logger
@abstractmethod
def render(self, fig: go.Figure, data: pd.DataFrame, **kwargs) -> go.Figure:
"""
Render the layer onto the provided figure.
Args:
fig: Plotly figure to render onto
data: Chart data (OHLCV format)
**kwargs: Additional rendering parameters
Returns:
Updated figure with layer rendered
"""
pass
@abstractmethod
def validate_data(self, data: pd.DataFrame) -> bool:
"""
Validate that the data is suitable for this layer.
Args:
data: Chart data to validate
Returns:
True if data is valid, False otherwise
"""
pass
def is_enabled(self) -> bool:
"""Check if the layer is enabled."""
return self.config.enabled
def get_subplot_row(self) -> Optional[int]:
"""Get the subplot row for this layer."""
return self.config.subplot_row
def is_overlay(self) -> bool:
"""Check if this layer is an overlay (main chart) or subplot."""
return self.config.subplot_row is None
class CandlestickLayer(BaseLayer):
"""
Candlestick chart layer implementation with enhanced error handling.
This layer renders OHLC data as candlesticks on the main chart.
"""
def __init__(self, config: LayerConfig = None):
"""
Initialize candlestick layer.
Args:
config: Layer configuration (optional, uses defaults)
"""
if config is None:
config = LayerConfig(
name="candlestick",
enabled=True,
style={
'increasing_color': '#00C851', # Green for bullish
'decreasing_color': '#FF4444', # Red for bearish
'line_width': 1
}
)
super().__init__(config)
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
def validate_data(self, data: Union[pd.DataFrame, List[Dict[str, Any]]]) -> bool:
"""Enhanced validation with comprehensive error handling"""
try:
# Use parent class error handling for comprehensive validation
parent_valid = super().validate_data(data)
# Convert to DataFrame if needed for local validation
if isinstance(data, list):
df = pd.DataFrame(data)
else:
df = data.copy()
# Additional candlestick-specific validation
required_columns = ['timestamp', 'open', 'high', 'low', 'close']
if not all(col in df.columns for col in required_columns):
missing = [col for col in required_columns if col not in df.columns]
error = ChartError(
code='MISSING_OHLC_COLUMNS',
message=f'Missing required OHLC columns: {missing}',
severity=ErrorSeverity.ERROR,
context={'missing_columns': missing, 'available_columns': list(df.columns)},
recovery_suggestion='Ensure data contains timestamp, open, high, low, close columns'
)
self.error_handler.errors.append(error)
return False
if len(df) == 0:
error = ChartError(
code='EMPTY_CANDLESTICK_DATA',
message='No candlestick data available',
severity=ErrorSeverity.ERROR,
context={'data_count': 0},
recovery_suggestion='Check data source or time range'
)
self.error_handler.errors.append(error)
return False
# Check for price data validity
invalid_prices = df[
(df['high'] < df['low']) |
(df['open'] < 0) | (df['close'] < 0) |
(df['high'] < 0) | (df['low'] < 0) |
pd.isna(df[['open', 'high', 'low', 'close']]).any(axis=1)
]
if len(invalid_prices) > len(df) * 0.5: # More than 50% invalid
error = ChartError(
code='EXCESSIVE_INVALID_PRICES',
message=f'Too many invalid price records: {len(invalid_prices)}/{len(df)}',
severity=ErrorSeverity.ERROR,
context={'invalid_count': len(invalid_prices), 'total_count': len(df)},
recovery_suggestion='Check data quality and price data sources'
)
self.error_handler.errors.append(error)
return False
elif len(invalid_prices) > 0:
# Warning for some invalid data
error = ChartError(
code='SOME_INVALID_PRICES',
message=f'Found {len(invalid_prices)} invalid price records (will be filtered)',
severity=ErrorSeverity.WARNING,
context={'invalid_count': len(invalid_prices), 'total_count': len(df)},
recovery_suggestion='Invalid records will be automatically removed'
)
self.error_handler.warnings.append(error)
return parent_valid and len(self.error_handler.errors) == 0
except Exception as e:
self.logger.error(f"Candlestick layer: Error validating candlestick data: {e}")
error = ChartError(
code='CANDLESTICK_VALIDATION_ERROR',
message=f'Candlestick validation failed: {str(e)}',
severity=ErrorSeverity.ERROR,
context={'exception': str(e)}
)
self.error_handler.errors.append(error)
return False
def render(self, fig: go.Figure, data: pd.DataFrame, **kwargs) -> go.Figure:
"""
Render candlestick chart with error handling and recovery.
Args:
fig: Target figure
data: OHLCV data
**kwargs: Additional parameters (row, col for subplots)
Returns:
Figure with candlestick trace added or error display
"""
try:
# Validate data
if not self.validate_data(data):
self.logger.error("Candlestick layer: Invalid data for candlestick layer")
# Add error annotation to figure
if self.error_handler.errors:
error_msg = self.error_handler.errors[0].message
fig.add_annotation(create_error_annotation(
f"Candlestick Error: {error_msg}",
position='center'
))
return fig
# Clean and prepare data
clean_data = self._clean_candlestick_data(data)
if clean_data.empty:
fig.add_annotation(create_error_annotation(
"No valid candlestick data after cleaning",
position='center'
))
return fig
# Extract styling
style = self.config.style
increasing_color = style.get('increasing_color', '#00C851')
decreasing_color = style.get('decreasing_color', '#FF4444')
# Create candlestick trace
candlestick = go.Candlestick(
x=clean_data['timestamp'],
open=clean_data['open'],
high=clean_data['high'],
low=clean_data['low'],
close=clean_data['close'],
name=self.config.name,
increasing_line_color=increasing_color,
decreasing_line_color=decreasing_color,
showlegend=False
)
# Add to figure
row = kwargs.get('row', 1)
col = kwargs.get('col', 1)
try:
if hasattr(fig, 'add_trace') and row == 1 and col == 1:
# Simple figure without subplots
fig.add_trace(candlestick)
elif hasattr(fig, 'add_trace'):
# Subplot figure
fig.add_trace(candlestick, row=row, col=col)
else:
# Fallback
fig.add_trace(candlestick)
except Exception as trace_error:
# If subplot call fails, try simple add_trace
try:
fig.add_trace(candlestick)
except Exception as fallback_error:
self.logger.error(f"Candlestick layer: Failed to add candlestick trace: {fallback_error}")
fig.add_annotation(create_error_annotation(
f"Failed to add candlestick trace: {str(fallback_error)}",
position='center'
))
return fig
# Add warning annotations if needed
if self.error_handler.warnings:
warning_msg = f"⚠️ {self.error_handler.warnings[0].message}"
fig.add_annotation({
'text': warning_msg,
'xref': 'paper', 'yref': 'paper',
'x': 0.02, 'y': 0.98,
'xanchor': 'left', 'yanchor': 'top',
'showarrow': False,
'font': {'size': 10, 'color': '#f39c12'},
'bgcolor': 'rgba(255,255,255,0.8)'
})
self.logger.debug(f"Rendered candlestick layer with {len(clean_data)} candles")
return fig
except Exception as e:
self.logger.error(f"Candlestick layer: Error rendering candlestick layer: {e}")
fig.add_annotation(create_error_annotation(
f"Candlestick render error: {str(e)}",
position='center'
))
return fig
def _clean_candlestick_data(self, data: pd.DataFrame) -> pd.DataFrame:
"""Clean and validate candlestick data"""
try:
clean_data = data.copy()
# Remove rows with invalid prices
invalid_mask = (
(clean_data['high'] < clean_data['low']) |
(clean_data['open'] < 0) | (clean_data['close'] < 0) |
(clean_data['high'] < 0) | (clean_data['low'] < 0) |
pd.isna(clean_data[['open', 'high', 'low', 'close']]).any(axis=1)
)
initial_count = len(clean_data)
clean_data = clean_data[~invalid_mask]
if len(clean_data) < initial_count:
removed_count = initial_count - len(clean_data)
self.logger.info(f"Removed {removed_count} invalid candlestick records")
# Ensure timestamp is properly formatted
if not pd.api.types.is_datetime64_any_dtype(clean_data['timestamp']):
clean_data['timestamp'] = pd.to_datetime(clean_data['timestamp'])
# Sort by timestamp
clean_data = clean_data.sort_values('timestamp')
return clean_data
except Exception as e:
self.logger.error(f"Candlestick layer: Error cleaning candlestick data: {e}")
return pd.DataFrame()
class VolumeLayer(BaseLayer):
"""
Volume subplot layer implementation with enhanced error handling.
This layer renders volume data as a bar chart in a separate subplot,
with bars colored based on price movement.
"""
def __init__(self, config: LayerConfig = None):
"""
Initialize volume layer.
Args:
config: Layer configuration (optional, uses defaults)
"""
if config is None:
config = LayerConfig(
name="volume",
enabled=True,
subplot_row=2, # Volume goes in second row by default
style={
'bullish_color': '#00C851',
'bearish_color': '#FF4444',
'opacity': 0.7
}
)
super().__init__(config)
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
def validate_data(self, data: Union[pd.DataFrame, List[Dict[str, Any]]]) -> bool:
"""Enhanced validation with comprehensive error handling"""
try:
# Use parent class error handling
parent_valid = super().validate_data(data)
# Convert to DataFrame if needed
if isinstance(data, list):
df = pd.DataFrame(data)
else:
df = data.copy()
# Volume-specific validation
required_columns = ['timestamp', 'open', 'close', 'volume']
if not all(col in df.columns for col in required_columns):
missing = [col for col in required_columns if col not in df.columns]
error = ChartError(
code='MISSING_VOLUME_COLUMNS',
message=f'Missing required volume columns: {missing}',
severity=ErrorSeverity.ERROR,
context={'missing_columns': missing, 'available_columns': list(df.columns)},
recovery_suggestion='Ensure data contains timestamp, open, close, volume columns'
)
self.error_handler.errors.append(error)
return False
if len(df) == 0:
error = ChartError(
code='EMPTY_VOLUME_DATA',
message='No volume data available',
severity=ErrorSeverity.ERROR,
context={'data_count': 0},
recovery_suggestion='Check data source or time range'
)
self.error_handler.errors.append(error)
return False
# Check if volume data exists and is valid
valid_volume_mask = (df['volume'] >= 0) & pd.notna(df['volume'])
valid_volume_count = valid_volume_mask.sum()
if valid_volume_count == 0:
error = ChartError(
code='NO_VALID_VOLUME',
message='No valid volume data found',
severity=ErrorSeverity.WARNING,
context={'total_records': len(df), 'valid_volume': 0},
recovery_suggestion='Volume chart will be skipped'
)
self.error_handler.warnings.append(error)
elif valid_volume_count < len(df) * 0.5: # Less than 50% valid
error = ChartError(
code='MOSTLY_INVALID_VOLUME',
message=f'Most volume data is invalid: {valid_volume_count}/{len(df)} valid',
severity=ErrorSeverity.WARNING,
context={'total_records': len(df), 'valid_volume': valid_volume_count},
recovery_suggestion='Invalid volume records will be filtered out'
)
self.error_handler.warnings.append(error)
elif df['volume'].sum() <= 0:
error = ChartError(
code='ZERO_VOLUME_TOTAL',
message='Total volume is zero or negative',
severity=ErrorSeverity.WARNING,
context={'volume_sum': float(df['volume'].sum())},
recovery_suggestion='Volume chart may not be meaningful'
)
self.error_handler.warnings.append(error)
return parent_valid and valid_volume_count > 0
except Exception as e:
self.logger.error(f"Volume layer: Error validating volume data: {e}")
error = ChartError(
code='VOLUME_VALIDATION_ERROR',
message=f'Volume validation failed: {str(e)}',
severity=ErrorSeverity.ERROR,
context={'exception': str(e)}
)
self.error_handler.errors.append(error)
return False
def render(self, fig: go.Figure, data: pd.DataFrame, **kwargs) -> go.Figure:
"""
Render volume bars with error handling and recovery.
Args:
fig: Target figure (must be subplot figure)
data: OHLCV data
**kwargs: Additional parameters (row, col for subplots)
Returns:
Figure with volume trace added or error handling
"""
try:
# Validate data
if not self.validate_data(data):
# Check if we can skip gracefully (warnings only)
if not self.error_handler.errors and self.error_handler.warnings:
self.logger.debug("Skipping volume layer due to warnings")
return fig
else:
self.logger.error("Volume layer: Invalid data for volume layer")
return fig
# Clean and prepare data
clean_data = self._clean_volume_data(data)
if clean_data.empty:
self.logger.debug("No valid volume data after cleaning")
return fig
# Calculate bar colors based on price movement
style = self.config.style
bullish_color = style.get('bullish_color', '#00C851')
bearish_color = style.get('bearish_color', '#FF4444')
opacity = style.get('opacity', 0.7)
colors = [
bullish_color if close >= open_price else bearish_color
for close, open_price in zip(clean_data['close'], clean_data['open'])
]
# Create volume bar trace
volume_bars = go.Bar(
x=clean_data['timestamp'],
y=clean_data['volume'],
name='Volume',
marker_color=colors,
opacity=opacity,
showlegend=False
)
# Add to figure
row = kwargs.get('row', 2) # Default to row 2 for volume
col = kwargs.get('col', 1)
fig.add_trace(volume_bars, row=row, col=col)
self.logger.debug(f"Volume layer: Rendered volume layer with {len(clean_data)} bars")
return fig
except Exception as e:
self.logger.error(f"Volume layer: Error rendering volume layer: {e}")
return fig
def _clean_volume_data(self, data: pd.DataFrame) -> pd.DataFrame:
"""Clean and validate volume data"""
try:
clean_data = data.copy()
# Remove rows with invalid volume
valid_mask = (clean_data['volume'] >= 0) & pd.notna(clean_data['volume'])
initial_count = len(clean_data)
clean_data = clean_data[valid_mask]
if len(clean_data) < initial_count:
removed_count = initial_count - len(clean_data)
self.logger.info(f"Removed {removed_count} invalid volume records")
# Ensure timestamp is properly formatted
if not pd.api.types.is_datetime64_any_dtype(clean_data['timestamp']):
clean_data['timestamp'] = pd.to_datetime(clean_data['timestamp'])
# Sort by timestamp
clean_data = clean_data.sort_values('timestamp')
return clean_data
except Exception as e:
self.logger.error(f"Volume layer: Error cleaning volume data: {e}")
return pd.DataFrame()
class LayerManager:
"""
Manager class for coordinating multiple chart layers.
This class handles the orchestration of multiple layers, including
setting up subplots and rendering layers in the correct order.
"""
def __init__(self):
"""Initialize the layer manager."""
self.layers: List[BaseLayer] = []
self.logger = logger
def add_layer(self, layer: BaseLayer) -> None:
"""
Add a layer to the manager.
Args:
layer: Chart layer to add
"""
self.layers.append(layer)
self.logger.debug(f"Added layer: {layer.config.name}")
def remove_layer(self, layer_name: str) -> bool:
"""
Remove a layer by name.
Args:
layer_name: Name of layer to remove
Returns:
True if layer was removed, False if not found
"""
for i, layer in enumerate(self.layers):
if layer.config.name == layer_name:
self.layers.pop(i)
self.logger.debug(f"Removed layer: {layer_name}")
return True
self.logger.warning(f"Layer not found for removal: {layer_name}")
return False
def get_enabled_layers(self) -> List[BaseLayer]:
"""Get list of enabled layers."""
return [layer for layer in self.layers if layer.is_enabled()]
def get_overlay_layers(self) -> List[BaseLayer]:
"""Get layers that render on the main chart."""
return [layer for layer in self.get_enabled_layers() if layer.is_overlay()]
def get_subplot_layers(self) -> Dict[int, List[BaseLayer]]:
"""Get layers grouped by subplot row."""
subplot_layers = {}
for layer in self.get_enabled_layers():
if not layer.is_overlay():
row = layer.get_subplot_row()
if row not in subplot_layers:
subplot_layers[row] = []
subplot_layers[row].append(layer)
return subplot_layers
def calculate_subplot_layout(self) -> Dict[str, Any]:
"""
Calculate subplot configuration based on layers.
Returns:
Dict with subplot configuration parameters
"""
subplot_layers = self.get_subplot_layers()
if not subplot_layers:
# No subplots needed
return {
'rows': 1,
'cols': 1,
'subplot_titles': None,
'row_heights': None
}
# Reassign subplot rows dynamically to ensure proper ordering
self._reassign_subplot_rows()
# Recalculate after reassignment
subplot_layers = self.get_subplot_layers()
# Calculate number of rows (main chart + subplots)
max_subplot_row = max(subplot_layers.keys()) if subplot_layers else 0
total_rows = max(1, max_subplot_row) # Row numbers are 1-indexed, so max_subplot_row is the total rows needed
# Create subplot titles
subplot_titles = ['Price'] # Main chart
for row in range(2, total_rows + 1):
if row in subplot_layers:
# Use the first layer's name as the subtitle
layer_names = [layer.config.name for layer in subplot_layers[row]]
subplot_titles.append(' / '.join(layer_names).title())
else:
subplot_titles.append(f'Subplot {row}')
# Calculate row heights based on subplot height ratios
row_heights = self._calculate_dynamic_row_heights(subplot_layers, total_rows)
return {
'rows': total_rows,
'cols': 1,
'subplot_titles': subplot_titles,
'row_heights': row_heights,
'shared_xaxes': True,
'vertical_spacing': 0.03
}
def _reassign_subplot_rows(self) -> None:
"""
Reassign subplot rows to ensure proper sequential ordering.
This method dynamically assigns subplot rows starting from row 2,
ensuring no gaps in the subplot layout.
"""
subplot_layers = []
# Collect all subplot layers
for layer in self.get_enabled_layers():
if not layer.is_overlay():
subplot_layers.append(layer)
# Sort by priority: volume first, then by current subplot row
def layer_priority(layer):
# Volume gets highest priority (0), then by current row
if hasattr(layer, 'config') and layer.config.name == 'volume':
return (0, layer.get_subplot_row() or 999)
else:
return (1, layer.get_subplot_row() or 999)
subplot_layers.sort(key=layer_priority)
# Reassign rows starting from 2
for i, layer in enumerate(subplot_layers):
new_row = i + 2 # Start from row 2 (row 1 is main chart)
layer.config.subplot_row = new_row
self.logger.debug(f"Assigned {layer.config.name} to subplot row {new_row}")
def _calculate_dynamic_row_heights(self, subplot_layers: Dict[int, List], total_rows: int) -> List[float]:
"""
Calculate row heights based on subplot height ratios.
Args:
subplot_layers: Dictionary of subplot layers by row
total_rows: Total number of rows
Returns:
List of height ratios for each row
"""
if total_rows == 1:
return [1.0] # Single row gets full height
# Calculate total requested subplot height
total_subplot_ratio = 0.0
subplot_ratios = {}
for row in range(2, total_rows + 1):
if row in subplot_layers:
# Get height ratio from first layer in the row
layer = subplot_layers[row][0]
if hasattr(layer, 'get_subplot_height_ratio'):
ratio = layer.get_subplot_height_ratio()
else:
ratio = 0.25 # Default ratio
subplot_ratios[row] = ratio
total_subplot_ratio += ratio
else:
subplot_ratios[row] = 0.25 # Default for empty rows
total_subplot_ratio += 0.25
# Ensure total doesn't exceed reasonable limits
max_subplot_ratio = 0.6 # Maximum 60% for all subplots
if total_subplot_ratio > max_subplot_ratio:
# Scale down proportionally
scale_factor = max_subplot_ratio / total_subplot_ratio
for row in subplot_ratios:
subplot_ratios[row] *= scale_factor
total_subplot_ratio = max_subplot_ratio
# Main chart gets remaining space
main_chart_ratio = 1.0 - total_subplot_ratio
# Build final height list
row_heights = [main_chart_ratio] # Main chart
for row in range(2, total_rows + 1):
row_heights.append(subplot_ratios.get(row, 0.25))
return row_heights
def render_all_layers(self, data: pd.DataFrame, **kwargs) -> go.Figure:
"""
Render all enabled layers onto a new figure.
Args:
data: Chart data (OHLCV format)
**kwargs: Additional rendering parameters
Returns:
Complete figure with all layers rendered
"""
try:
# Calculate subplot layout
layout_config = self.calculate_subplot_layout()
# Create figure with subplots if needed
if layout_config['rows'] > 1:
fig = make_subplots(**layout_config)
else:
fig = go.Figure()
# Render overlay layers (main chart)
overlay_layers = self.get_overlay_layers()
for layer in overlay_layers:
fig = layer.render(fig, data, row=1, col=1, **kwargs)
# Render subplot layers
subplot_layers = self.get_subplot_layers()
for row, layers in subplot_layers.items():
for layer in layers:
fig = layer.render(fig, data, row=row, col=1, **kwargs)
# Update layout styling
self._apply_layout_styling(fig, layout_config)
self.logger.debug(f"Rendered {len(self.get_enabled_layers())} layers")
return fig
except Exception as e:
self.logger.error(f"Layer manager: Error rendering layers: {e}")
# Return empty figure on error
return go.Figure()
def _apply_layout_styling(self, fig: go.Figure, layout_config: Dict[str, Any]) -> None:
"""Apply consistent styling to the figure layout."""
try:
# Basic layout settings
fig.update_layout(
template="plotly_white",
showlegend=False,
hovermode='x unified',
xaxis_rangeslider_visible=False
)
# Update axes for subplots
if layout_config['rows'] > 1:
# Update main chart axes
fig.update_yaxes(title_text="Price (USDT)", row=1, col=1)
fig.update_xaxes(showticklabels=False, row=1, col=1)
# Update subplot axes
subplot_layers = self.get_subplot_layers()
for row in range(2, layout_config['rows'] + 1):
if row in subplot_layers:
# Set y-axis title and range based on layer type
layers_in_row = subplot_layers[row]
layer = layers_in_row[0] # Use first layer for configuration
# Set y-axis title
if hasattr(layer, 'config') and hasattr(layer.config, 'indicator_type'):
indicator_type = layer.config.indicator_type
if indicator_type == 'rsi':
fig.update_yaxes(title_text="RSI", row=row, col=1)
elif indicator_type == 'macd':
fig.update_yaxes(title_text="MACD", row=row, col=1)
else:
layer_names = [l.config.name for l in layers_in_row]
fig.update_yaxes(title_text=' / '.join(layer_names), row=row, col=1)
# Set fixed y-axis range if specified
if hasattr(layer, 'has_fixed_range') and layer.has_fixed_range():
y_range = layer.get_y_axis_range()
if y_range:
fig.update_yaxes(range=list(y_range), row=row, col=1)
# Only show x-axis labels on the bottom subplot
if row == layout_config['rows']:
fig.update_xaxes(title_text="Time", row=row, col=1)
else:
fig.update_xaxes(showticklabels=False, row=row, col=1)
else:
# Single chart
fig.update_layout(
xaxis_title="Time",
yaxis_title="Price (USDT)"
)
except Exception as e:
self.logger.error(f"Layer manager: Error applying layout styling: {e}")