""" ChartBuilder - Main orchestrator for chart creation This module contains the ChartBuilder class which serves as the main entry point for creating charts with various configurations, indicators, and layers. """ import plotly.graph_objects as go from plotly.subplots import make_subplots import pandas as pd from datetime import datetime, timedelta, timezone from typing import List, Dict, Any, Optional, Union from decimal import Decimal from database.operations import get_database_operations, DatabaseOperationError from utils.logger import get_logger from .utils import validate_market_data, prepare_chart_data, get_indicator_colors from .indicator_manager import get_indicator_manager from .layers import ( LayerManager, CandlestickLayer, VolumeLayer, SMALayer, EMALayer, BollingerBandsLayer, RSILayer, MACDLayer, IndicatorLayerConfig ) # Initialize logger logger = get_logger() class ChartBuilder: """ Main chart builder class for creating modular, configurable charts. This class orchestrates the creation of charts by coordinating between data fetching, layer rendering, and configuration management. """ def __init__(self, logger_instance: Optional = None): """ Initialize the ChartBuilder. Args: logger_instance: Optional logger instance """ self.logger = logger_instance or logger self.db_ops = get_database_operations(self.logger) # Initialize market data integrator from .data_integration import get_market_data_integrator self.data_integrator = get_market_data_integrator() # Chart styling defaults self.default_colors = get_indicator_colors() self.default_height = 600 self.default_template = "plotly_white" def fetch_market_data(self, symbol: str, timeframe: str, days_back: int = 7, exchange: str = "okx") -> List[Dict[str, Any]]: """ Fetch market data from the database. Args: symbol: Trading pair (e.g., 'BTC-USDT') timeframe: Timeframe (e.g., '1h', '1d') days_back: Number of days to look back exchange: Exchange name Returns: List of candle data dictionaries """ try: # Calculate time range end_time = datetime.now(timezone.utc) start_time = end_time - timedelta(days=days_back) # Fetch candles using the database operations API candles = self.db_ops.market_data.get_candles( symbol=symbol, timeframe=timeframe, start_time=start_time, end_time=end_time, exchange=exchange ) self.logger.debug(f"Chart builder: Fetched {len(candles)} candles for {symbol} {timeframe}") return candles except DatabaseOperationError as e: self.logger.error(f"Chart builder: Database error fetching market data: {e}") return [] except Exception as e: self.logger.error(f"Chart builder: Unexpected error fetching market data: {e}") return [] def fetch_market_data_enhanced(self, symbol: str, timeframe: str, days_back: int = 7, exchange: str = "okx") -> List[Dict[str, Any]]: """ Enhanced market data fetching with validation and caching. Args: symbol: Trading pair (e.g., 'BTC-USDT') timeframe: Timeframe (e.g., '1h', '1d') days_back: Number of days to look back exchange: Exchange name Returns: List of validated candle data dictionaries """ try: # Use the data integrator for enhanced data handling raw_candles, ohlcv_candles = self.data_integrator.get_market_data_for_indicators( symbol, timeframe, days_back, exchange ) if not raw_candles: self.logger.warning(f"Chart builder: No market data available for {symbol} {timeframe}") return [] self.logger.debug(f"Chart builder: Enhanced fetch: {len(raw_candles)} candles for {symbol} {timeframe}") return raw_candles except Exception as e: self.logger.error(f"Chart builder: Error in enhanced market data fetch: {e}") # Fallback to original method return self.fetch_market_data(symbol, timeframe, days_back, exchange) def create_candlestick_chart(self, symbol: str, timeframe: str, days_back: int = 7, **kwargs) -> go.Figure: """ Create a basic candlestick chart. Args: symbol: Trading pair timeframe: Timeframe days_back: Number of days to look back **kwargs: Additional chart parameters Returns: Plotly Figure object with candlestick chart """ try: # Fetch market data candles = self.fetch_market_data(symbol, timeframe, days_back) # Handle empty data if not candles: self.logger.warning(f"Chart builder: No data available for {symbol} {timeframe}") return self._create_empty_chart(f"No data available for {symbol} {timeframe}") # Validate and prepare data if not validate_market_data(candles): self.logger.error(f"Chart builder: Invalid market data for {symbol} {timeframe}") return self._create_error_chart("Invalid market data format") # Prepare chart data df = prepare_chart_data(candles) # Determine if we need volume subplot has_volume = 'volume' in df.columns and df['volume'].sum() > 0 include_volume = kwargs.get('include_volume', has_volume) if include_volume and has_volume: fig, df_chart = self._create_candlestick_with_volume(df, symbol, timeframe, **kwargs) return fig, df_chart else: fig, df_chart = self._create_basic_candlestick(df, symbol, timeframe, **kwargs) return fig, df_chart except Exception as e: self.logger.error(f"Chart builder: Error creating candlestick chart for {symbol} {timeframe}: {e}") error_fig = self._create_error_chart(f"Error loading chart: {str(e)}") return error_fig, pd.DataFrame() def _create_basic_candlestick(self, df: pd.DataFrame, symbol: str, timeframe: str, **kwargs) -> go.Figure: """Create a basic candlestick chart without volume.""" # Get custom parameters height = kwargs.get('height', self.default_height) template = kwargs.get('template', self.default_template) # Create candlestick chart fig = go.Figure(data=go.Candlestick( x=df['timestamp'], open=df['open'], high=df['high'], low=df['low'], close=df['close'], name=symbol, increasing_line_color=self.default_colors['bullish'], decreasing_line_color=self.default_colors['bearish'] )) # Update layout fig.update_layout( title=f"{symbol} - {timeframe} Chart", xaxis_title="Time", yaxis_title="Price (USDT)", template=template, showlegend=False, height=height, xaxis_rangeslider_visible=False, hovermode='x unified' ) self.logger.debug(f"Chart builder: Created basic candlestick chart for {symbol} {timeframe} with {len(df)} candles") return fig, df def _create_candlestick_with_volume(self, df: pd.DataFrame, symbol: str, timeframe: str, **kwargs) -> go.Figure: """Create a candlestick chart with volume subplot.""" # Get custom parameters height = kwargs.get('height', 700) # Taller for volume subplot template = kwargs.get('template', self.default_template) # Create subplots fig = make_subplots( rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.03, subplot_titles=(f'{symbol} Price', 'Volume'), row_heights=[0.7, 0.3] # 70% for price, 30% for volume ) # Add candlestick chart fig.add_trace( go.Candlestick( x=df['timestamp'], open=df['open'], high=df['high'], low=df['low'], close=df['close'], name=symbol, increasing_line_color=self.default_colors['bullish'], decreasing_line_color=self.default_colors['bearish'] ), row=1, col=1 ) # Add volume bars with color coding colors = [self.default_colors['bullish'] if close >= open else self.default_colors['bearish'] for close, open in zip(df['close'], df['open'])] fig.add_trace( go.Bar( x=df['timestamp'], y=df['volume'], name='Volume', marker_color=colors, opacity=0.7 ), row=2, col=1 ) # Update layout fig.update_layout( title=f"{symbol} - {timeframe} Chart with Volume", template=template, showlegend=False, height=height, xaxis_rangeslider_visible=False, hovermode='x unified', dragmode='pan' ) # Update axes fig.update_yaxes(title_text="Price (USDT)", row=1, col=1) fig.update_yaxes(title_text="Volume", row=2, col=1) fig.update_xaxes(title_text="Time", row=2, col=1) self.logger.debug(f"Chart builder: Created candlestick chart with volume for {symbol} {timeframe} with {len(df)} candles") return fig, df def _create_empty_chart(self, message: str = "No data available") -> go.Figure: """Create an empty chart with a message.""" fig = go.Figure() fig.add_annotation( text=message, xref="paper", yref="paper", x=0.5, y=0.5, xanchor='center', yanchor='middle', showarrow=False, font=dict(size=16, color="#7f8c8d") ) fig.update_layout( template=self.default_template, height=self.default_height, showlegend=False, xaxis=dict(visible=False), yaxis=dict(visible=False) ) return fig def _create_error_chart(self, error_message: str) -> go.Figure: """Create an error chart with error message.""" fig = go.Figure() fig.add_annotation( text=f"⚠️ {error_message}", xref="paper", yref="paper", x=0.5, y=0.5, xanchor='center', yanchor='middle', showarrow=False, font=dict(size=16, color="#e74c3c") ) fig.update_layout( template=self.default_template, height=self.default_height, showlegend=False, xaxis=dict(visible=False), yaxis=dict(visible=False) ) return fig def create_strategy_chart(self, symbol: str, timeframe: str, strategy_name: str, **kwargs) -> go.Figure: """ Create a strategy-specific chart (placeholder for future implementation). Args: symbol: Trading pair timeframe: Timeframe strategy_name: Name of the strategy configuration **kwargs: Additional parameters Returns: Plotly Figure object """ # For now, return a basic candlestick chart # This will be enhanced in later tasks with strategy configurations self.logger.info(f"Chart builder: Creating strategy chart for {strategy_name} (basic implementation)") return self.create_candlestick_chart(symbol, timeframe, **kwargs) def check_data_quality(self, symbol: str, timeframe: str, exchange: str = "okx") -> Dict[str, Any]: """ Check data quality and availability for chart creation. Args: symbol: Trading pair timeframe: Timeframe exchange: Exchange name Returns: Dictionary with data quality information """ try: return self.data_integrator.check_data_availability(symbol, timeframe, exchange) except Exception as e: self.logger.error(f"Chart builder: Error checking data quality: {e}") return { 'available': False, 'latest_timestamp': None, 'data_age_minutes': None, 'sufficient_for_indicators': False, 'message': f"Error checking data: {str(e)}" } def create_chart_with_indicators(self, symbol: str, timeframe: str, overlay_indicators: List[str] = None, subplot_indicators: List[str] = None, days_back: int = 7, **kwargs) -> go.Figure: """ Create a candlestick chart with specified technical indicators. Args: symbol: Trading pair timeframe: Timeframe overlay_indicators: List of overlay indicator names subplot_indicators: List of subplot indicator names days_back: Number of days to look back **kwargs: Additional chart parameters Returns: Plotly Figure object and a pandas DataFrame with all chart data. """ overlay_indicators = overlay_indicators or [] subplot_indicators = subplot_indicators or [] try: # 1. Fetch and Prepare Base Data candles = self.fetch_market_data_enhanced(symbol, timeframe, days_back) if not candles: self.logger.warning(f"No data for {symbol} {timeframe}, creating empty chart.") return self._create_empty_chart(f"No data for {symbol} {timeframe}"), pd.DataFrame() df = prepare_chart_data(candles) if df.empty: self.logger.warning(f"DataFrame empty for {symbol} {timeframe}, creating empty chart.") return self._create_empty_chart(f"No data for {symbol} {timeframe}"), pd.DataFrame() # Initialize final DataFrame for export final_df = df.copy() # 2. Setup Subplots # Count subplot indicators to configure rows subplot_count = 0 volume_enabled = 'volume' in df.columns and df['volume'].sum() > 0 if volume_enabled: subplot_count += 1 if subplot_indicators: subplot_count += len(subplot_indicators) # Create subplot structure if needed if subplot_count > 0: # Calculate height ratios main_height = 0.7 # Main chart gets 70% subplot_height = 0.3 / subplot_count if subplot_count > 0 else 0 # Create subplot specifications subplot_specs = [[{"secondary_y": False}]] # Main chart row_heights = [main_height] if volume_enabled: subplot_specs.append([{"secondary_y": False}]) row_heights.append(subplot_height) if subplot_indicators: for _ in subplot_indicators: subplot_specs.append([{"secondary_y": False}]) row_heights.append(subplot_height) # Create subplots figure from plotly.subplots import make_subplots fig = make_subplots( rows=len(subplot_specs), cols=1, shared_xaxes=True, vertical_spacing=0.02, row_heights=row_heights, specs=subplot_specs, subplot_titles=[f"{symbol} - {timeframe}"] + [""] * (len(subplot_specs) - 1) ) else: # Create simple figure for main chart only fig = go.Figure() current_row = 1 # 4. Add Candlestick Trace fig.add_trace(go.Candlestick( x=df['timestamp'], open=df['open'], high=df['high'], low=df['low'], close=df['close'], name=symbol, increasing_line_color=self.default_colors['bullish'], decreasing_line_color=self.default_colors['bearish'] ), row=current_row, col=1) # 5. Add Volume Trace (if applicable) if volume_enabled: current_row += 1 volume_colors = [self.default_colors['bullish'] if close >= open else self.default_colors['bearish'] for close, open in zip(df['close'], df['open'])] volume_trace = go.Bar( x=df['timestamp'], y=df['volume'], name='Volume', marker_color=volume_colors, opacity=0.7 ) fig.add_trace(volume_trace, row=current_row, col=1) fig.update_yaxes(title_text="Volume", row=current_row, col=1) # 6. Add Indicator Traces indicator_manager = get_indicator_manager() all_indicator_configs = [] # Create IndicatorLayerConfig objects from indicator IDs indicator_ids = (overlay_indicators or []) + (subplot_indicators or []) for ind_id in indicator_ids: indicator = indicator_manager.load_indicator(ind_id) if indicator: config = IndicatorLayerConfig( id=indicator.id, name=indicator.name, indicator_type=indicator.type, parameters=indicator.parameters ) all_indicator_configs.append(config) if all_indicator_configs: indicator_data_map = self.data_integrator.get_indicator_data( main_df=df, main_timeframe=timeframe, indicator_configs=all_indicator_configs, indicator_manager=indicator_manager, symbol=symbol, exchange="okx" ) for indicator_id, indicator_df in indicator_data_map.items(): indicator = indicator_manager.load_indicator(indicator_id) if not indicator: self.logger.warning(f"Could not load indicator '{indicator_id}' for plotting.") continue if indicator_df is not None and not indicator_df.empty: # Add a suffix to the indicator's columns before joining to prevent overlap # when multiple indicators of the same type are added. final_df = final_df.join(indicator_df, how='left', rsuffix=f'_{indicator.id}') # Determine target row for plotting target_row = 1 # Default to overlay on the main chart if indicator.id in subplot_indicators: current_row += 1 target_row = current_row fig.update_yaxes(title_text=indicator.name, row=target_row, col=1) if indicator.type == 'bollinger_bands': if all(c in indicator_df.columns for c in ['upper_band', 'lower_band', 'middle_band']): # Prepare data for the filled area x_vals = indicator_df.index y_upper = indicator_df['upper_band'] y_lower = indicator_df['lower_band'] # Convert hex color to rgba for the fill hex_color = indicator.styling.color.lstrip('#') rgb = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) fill_color = f'rgba({rgb[0]}, {rgb[1]}, {rgb[2]}, 0.1)' # Add the transparent fill trace fig.add_trace(go.Scatter( x=pd.concat([x_vals.to_series(), x_vals.to_series()[::-1]]), y=pd.concat([y_upper, y_lower[::-1]]), fill='toself', fillcolor=fill_color, line={'color': 'rgba(255,255,255,0)'}, hoverinfo='none', showlegend=False ), row=target_row, col=1) # Add the visible line traces for the bands fig.add_trace(go.Scatter(x=x_vals, y=y_upper, name=f'{indicator.name} Upper', mode='lines', line=dict(color=indicator.styling.color, width=1.5)), row=target_row, col=1) fig.add_trace(go.Scatter(x=x_vals, y=y_lower, name=f'{indicator.name} Lower', mode='lines', line=dict(color=indicator.styling.color, width=1.5)), row=target_row, col=1) fig.add_trace(go.Scatter(x=x_vals, y=indicator_df['middle_band'], name=f'{indicator.name} Middle', mode='lines', line=dict(color=indicator.styling.color, width=1.5, dash='dash')), row=target_row, col=1) else: # Generic plotting for other indicators for col in indicator_df.columns: if col != 'timestamp': fig.add_trace(go.Scatter( x=indicator_df.index, y=indicator_df[col], mode='lines', name=f"{indicator.name} ({col})", line=dict(color=indicator.styling.color) ), row=target_row, col=1) # 7. Final Layout Updates height = kwargs.get('height', self.default_height) template = kwargs.get('template', self.default_template) fig.update_layout( title=f"{symbol} - {timeframe} Chart", template=template, height=height, showlegend=True, legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01), xaxis_rangeslider_visible=False, hovermode='x unified' ) # Update x-axis for all subplots fig.update_xaxes(title_text="Time", row=current_row, col=1) fig.update_yaxes(title_text="Price (USDT)", row=1, col=1) indicator_count = len(overlay_indicators or []) + len(subplot_indicators or []) self.logger.debug(f"Created chart for {symbol} {timeframe} with {indicator_count} indicators") self.logger.info(f"Successfully created chart for {symbol} with {len(overlay_indicators + subplot_indicators)} indicators.") return fig, final_df except Exception as e: self.logger.error(f"Error in create_chart_with_indicators for {symbol}: {e}", exc_info=True) return self._create_error_chart(f"Error generating indicator chart: {e}"), pd.DataFrame()