diff --git a/components/charts/builder.py b/components/charts/builder.py index e48e53f..a2eca6c 100644 --- a/components/charts/builder.py +++ b/components/charts/builder.py @@ -15,6 +15,12 @@ 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("default_logger") @@ -153,13 +159,16 @@ class ChartBuilder: include_volume = kwargs.get('include_volume', has_volume) if include_volume and has_volume: - return self._create_candlestick_with_volume(df, symbol, timeframe, **kwargs) + fig, df_chart = self._create_candlestick_with_volume(df, symbol, timeframe, **kwargs) + return fig, df_chart else: - return self._create_basic_candlestick(df, symbol, timeframe, **kwargs) + 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}") - return self._create_error_chart(f"Error loading chart: {str(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: @@ -194,7 +203,7 @@ class ChartBuilder: ) self.logger.debug(f"Chart builder: Created basic candlestick chart for {symbol} {timeframe} with {len(df)} candles") - return fig + return fig, df def _create_candlestick_with_volume(self, df: pd.DataFrame, symbol: str, timeframe: str, **kwargs) -> go.Figure: @@ -250,7 +259,8 @@ class ChartBuilder: showlegend=False, height=height, xaxis_rangeslider_visible=False, - hovermode='x unified' + hovermode='x unified', + dragmode='pan' ) # Update axes @@ -258,8 +268,8 @@ class ChartBuilder: 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}") - return fig + 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.""" @@ -356,7 +366,7 @@ class ChartBuilder: subplot_indicators: List[str] = None, days_back: int = 7, **kwargs) -> go.Figure: """ - Create a chart with dynamically selected indicators. + Create a candlestick chart with specified technical indicators. Args: symbol: Trading pair @@ -367,35 +377,27 @@ class ChartBuilder: **kwargs: Additional chart parameters Returns: - Plotly Figure object with selected indicators + Plotly Figure object and a pandas DataFrame with all chart data. """ + overlay_indicators = overlay_indicators or [] + subplot_indicators = subplot_indicators or [] try: - # Fetch market data + # 1. Fetch and Prepare Base Data candles = self.fetch_market_data_enhanced(symbol, timeframe, days_back) - 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") - + 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) - - # Import layer classes - from .layers import ( - LayerManager, CandlestickLayer, VolumeLayer, - SMALayer, EMALayer, BollingerBandsLayer, - RSILayer, MACDLayer, IndicatorLayerConfig - ) - from .indicator_manager import get_indicator_manager - - # Get user indicators instead of default configurations - indicator_manager = get_indicator_manager() - - # Calculate subplot requirements + 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: @@ -440,8 +442,8 @@ class ChartBuilder: current_row = 1 - # Add candlestick layer (always included) - candlestick_trace = go.Candlestick( + # 4. Add Candlestick Trace + fig.add_trace(go.Candlestick( x=df['timestamp'], open=df['open'], high=df['high'], @@ -449,72 +451,10 @@ class ChartBuilder: close=df['close'], name=symbol, increasing_line_color=self.default_colors['bullish'], - decreasing_line_color=self.default_colors['bearish'], - showlegend=False - ) - fig.add_trace(candlestick_trace, row=current_row, col=1) + decreasing_line_color=self.default_colors['bearish'] + ), row=current_row, col=1) - # Add overlay indicators - if overlay_indicators: - for indicator_id in overlay_indicators: - try: - # Load user indicator - user_indicator = indicator_manager.load_indicator(indicator_id) - - if user_indicator is None: - self.logger.warning(f"Overlay indicator {indicator_id} not found") - continue - - # Create appropriate indicator layer using user configuration - if user_indicator.type == 'sma': - period = user_indicator.parameters.get('period', 20) - layer_config = IndicatorLayerConfig( - name=user_indicator.name, - indicator_type='sma', - color=user_indicator.styling.color, - parameters={'period': period}, - line_width=user_indicator.styling.line_width - ) - sma_layer = SMALayer(layer_config) - traces = sma_layer.create_traces(df.to_dict('records')) - for trace in traces: - fig.add_trace(trace, row=current_row, col=1) - - elif user_indicator.type == 'ema': - period = user_indicator.parameters.get('period', 12) - layer_config = IndicatorLayerConfig( - name=user_indicator.name, - indicator_type='ema', - color=user_indicator.styling.color, - parameters={'period': period}, - line_width=user_indicator.styling.line_width - ) - ema_layer = EMALayer(layer_config) - traces = ema_layer.create_traces(df.to_dict('records')) - for trace in traces: - fig.add_trace(trace, row=current_row, col=1) - - elif user_indicator.type == 'bollinger_bands': - period = user_indicator.parameters.get('period', 20) - std_dev = user_indicator.parameters.get('std_dev', 2.0) - layer_config = IndicatorLayerConfig( - name=user_indicator.name, - indicator_type='bollinger_bands', - color=user_indicator.styling.color, - parameters={'period': period, 'std_dev': std_dev}, - line_width=user_indicator.styling.line_width, - show_middle_line=True - ) - bb_layer = BollingerBandsLayer(layer_config) - traces = bb_layer.create_traces(df.to_dict('records')) - for trace in traces: - fig.add_trace(trace, row=current_row, col=1) - - self.logger.debug(f"Added overlay indicator: {user_indicator.name}") - except Exception as e: - self.logger.error(f"Chart builder: Error adding overlay indicator {indicator_id}: {e}") - - # Move to next row for volume if enabled + # 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'] @@ -525,56 +465,89 @@ class ChartBuilder: y=df['volume'], name='Volume', marker_color=volume_colors, - opacity=0.7, - showlegend=False + opacity=0.7 ) fig.add_trace(volume_trace, row=current_row, col=1) fig.update_yaxes(title_text="Volume", row=current_row, col=1) - # Add subplot indicators - if subplot_indicators: - for indicator_id in subplot_indicators: - current_row += 1 - try: - # Load user indicator - user_indicator = indicator_manager.load_indicator(indicator_id) - - if user_indicator is None: - self.logger.warning(f"Subplot indicator {indicator_id} not found") - continue - - # Create appropriate subplot indicator layer - if user_indicator.type == 'rsi': - period = user_indicator.parameters.get('period', 14) - rsi_layer = RSILayer(period=period, color=user_indicator.styling.color, name=user_indicator.name) - - # Use the render method - fig = rsi_layer.render(fig, df, row=current_row, col=1) - - # Add RSI reference lines - fig.add_hline(y=70, line_dash="dash", line_color="red", opacity=0.5, row=current_row, col=1) - fig.add_hline(y=30, line_dash="dash", line_color="green", opacity=0.5, row=current_row, col=1) - fig.update_yaxes(title_text="RSI", range=[0, 100], row=current_row, col=1) - - elif user_indicator.type == 'macd': - fast_period = user_indicator.parameters.get('fast_period', 12) - slow_period = user_indicator.parameters.get('slow_period', 26) - signal_period = user_indicator.parameters.get('signal_period', 9) - macd_layer = MACDLayer(fast_period=fast_period, slow_period=slow_period, - signal_period=signal_period, color=user_indicator.styling.color, name=user_indicator.name) - - # Use the render method - fig = macd_layer.render(fig, df, row=current_row, col=1) - - # Add zero line for MACD - fig.add_hline(y=0, line_dash="dash", line_color="gray", opacity=0.5, row=current_row, col=1) - fig.update_yaxes(title_text="MACD", row=current_row, col=1) - - self.logger.debug(f"Added subplot indicator: {user_indicator.name}") - except Exception as e: - self.logger.error(f"Chart builder: Error adding subplot indicator {indicator_id}: {e}") + # 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) - # Update layout + if all_indicator_configs: + indicator_data_map = self.data_integrator.get_indicator_data( + df, all_indicator_configs, indicator_manager + ) + + 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: + final_df = pd.merge(final_df, indicator_df, on='timestamp', how='left') + + # 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['timestamp'] + 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, x_vals[::-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['timestamp'], + 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) @@ -594,8 +567,9 @@ class ChartBuilder: indicator_count = len(overlay_indicators or []) + len(subplot_indicators or []) self.logger.debug(f"Created chart for {symbol} {timeframe} with {indicator_count} indicators") - return fig - + 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"Chart builder: Error creating chart with indicators: {e}") - return self._create_error_chart(f"Chart creation failed: {str(e)}") \ No newline at end of file + 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() \ No newline at end of file diff --git a/components/charts/data_integration.py b/components/charts/data_integration.py index 4cf81b6..8dd0686 100644 --- a/components/charts/data_integration.py +++ b/components/charts/data_integration.py @@ -457,6 +457,49 @@ class MarketDataIntegrator: self._cache.clear() self.logger.debug("Data Integration: Data cache cleared") + def get_indicator_data( + self, + main_df: pd.DataFrame, + indicator_configs: List['IndicatorLayerConfig'], + indicator_manager: 'IndicatorManager' + ) -> Dict[str, pd.DataFrame]: + + indicator_data_map = {} + if main_df.empty: + return indicator_data_map + + for config in indicator_configs: + indicator_id = config.id + indicator = indicator_manager.load_indicator(indicator_id) + + if not indicator: + logger.warning(f"Data Integrator: Could not load indicator with ID: {indicator_id}") + continue + + try: + # The new `calculate` method in TechnicalIndicators handles DataFrame input + indicator_result_pkg = self.indicators.calculate( + indicator.type, + main_df, + **indicator.parameters + ) + + if indicator_result_pkg and 'data' in indicator_result_pkg and indicator_result_pkg['data']: + # The result is a list of IndicatorResult objects. Convert to DataFrame. + indicator_results = indicator_result_pkg['data'] + result_df = pd.DataFrame([ + {'timestamp': r.timestamp, **r.values} + for r in indicator_results + ]) + indicator_data_map[indicator.id] = result_df + else: + self.logger.warning(f"No data returned for indicator '{indicator.name}'") + + except Exception as e: + self.logger.error(f"Error calculating indicator '{indicator.name}': {e}", exc_info=True) + + return indicator_data_map + # Convenience functions for common operations def get_market_data_integrator(config: DataIntegrationConfig = None) -> MarketDataIntegrator: diff --git a/components/charts/layers/indicators.py b/components/charts/layers/indicators.py index d7789f3..f3fd70d 100644 --- a/components/charts/layers/indicators.py +++ b/components/charts/layers/indicators.py @@ -28,6 +28,7 @@ logger = get_logger("default_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 diff --git a/config/indicators/user_indicators/ema_ca5fd53d.json b/config/indicators/user_indicators/ema_ca5fd53d.json index e5b5981..73b5501 100644 --- a/config/indicators/user_indicators/ema_ca5fd53d.json +++ b/config/indicators/user_indicators/ema_ca5fd53d.json @@ -1,19 +1,19 @@ { "id": "ema_ca5fd53d", - "name": "EMA 10", + "name": "EMA 12", "description": "12-period Exponential Moving Average for fast signals", "type": "ema", "display_type": "overlay", "parameters": { - "period": 10 + "period": 12 }, "styling": { - "color": "#ff6b35", + "color": "#8880ff", "line_width": 2, "opacity": 1.0, "line_style": "solid" }, "visible": true, "created_date": "2025-06-04T04:16:35.455729+00:00", - "modified_date": "2025-06-04T04:54:49.608549+00:00" + "modified_date": "2025-06-06T04:14:33.123102+00:00" } \ No newline at end of file diff --git a/dashboard/callbacks/charts.py b/dashboard/callbacks/charts.py index 14a5889..8b374a3 100644 --- a/dashboard/callbacks/charts.py +++ b/dashboard/callbacks/charts.py @@ -2,19 +2,21 @@ Chart-related callbacks for the dashboard. """ -from dash import Output, Input, State, Patch, ctx, html, no_update +from dash import Output, Input, State, Patch, ctx, html, no_update, dcc from datetime import datetime, timedelta from utils.logger import get_logger from components.charts import ( create_strategy_chart, create_chart_with_indicators, create_error_chart, - get_market_statistics ) +from dashboard.components.data_analysis import get_market_statistics from components.charts.config import get_all_example_strategies from database.connection import DatabaseManager from components.charts.builder import ChartBuilder from components.charts.utils import prepare_chart_data +import pandas as pd +import io logger = get_logger("default_logger") @@ -22,53 +24,37 @@ logger = get_logger("default_logger") def calculate_time_range(time_range_quick, custom_start_date, custom_end_date, analysis_mode, n_intervals): """Calculate days_back and status message based on time range controls.""" try: - # Define predefined quick select options (excluding 'custom' and 'realtime') predefined_ranges = ['1h', '4h', '6h', '12h', '1d', '3d', '7d', '30d'] - # PRIORITY 1: Explicit Predefined Dropdown Selection if time_range_quick in predefined_ranges: time_map = { - '1h': (1/24, '🕐 Last 1 Hour'), - '4h': (4/24, '🕐 Last 4 Hours'), - '6h': (6/24, '🕐 Last 6 Hours'), - '12h': (12/24, '🕐 Last 12 Hours'), - '1d': (1, '📅 Last 1 Day'), - '3d': (3, '📅 Last 3 Days'), - '7d': (7, '📅 Last 7 Days'), - '30d': (30, '📅 Last 30 Days') + '1h': (1/24, '🕐 Last 1 Hour'), '4h': (4/24, '🕐 Last 4 Hours'), '6h': (6/24, '🕐 Last 6 Hours'), + '12h': (12/24, '🕐 Last 12 Hours'), '1d': (1, '📅 Last 1 Day'), '3d': (3, '📅 Last 3 Days'), + '7d': (7, '📅 Last 7 Days'), '30d': (30, '📅 Last 30 Days') } days_back_fractional, label = time_map[time_range_quick] mode_text = "🔒 Locked" if analysis_mode == 'locked' else "🔴 Live" status = f"{label} | {mode_text}" days_back = days_back_fractional if days_back_fractional < 1 else int(days_back_fractional) - logger.debug(f"Using predefined dropdown selection: {time_range_quick} -> {days_back} days. Custom dates ignored.") return days_back, status - # PRIORITY 2: Custom Date Range (if dropdown is 'custom' and dates are set) if time_range_quick == 'custom' and custom_start_date and custom_end_date: start_date = datetime.fromisoformat(custom_start_date.split('T')[0]) end_date = datetime.fromisoformat(custom_end_date.split('T')[0]) days_diff = (end_date - start_date).days status = f"📅 Custom Range: {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')} ({days_diff} days)" - logger.debug(f"Using custom date range: {days_diff} days as dropdown is 'custom'.") return max(1, days_diff), status - # PRIORITY 3: Real-time (uses default lookback, typically 7 days for context) if time_range_quick == 'realtime': mode_text = "🔒 Analysis Mode" if analysis_mode == 'locked' else "🔴 Real-time Updates" status = f"📈 Real-time Mode | {mode_text} (Default: Last 7 Days)" - logger.debug("Using real-time mode with default 7 days lookback.") return 7, status - # Fallback / Default (e.g., if time_range_quick is None or an unexpected value, or 'custom' without dates) - # This also covers the case where 'custom' is selected but dates are not yet picked. mode_text = "🔒 Analysis Mode" if analysis_mode == 'locked' else "🔴 Live" default_label = "📅 Default (Last 7 Days)" if time_range_quick == 'custom' and not (custom_start_date and custom_end_date): - default_label = "⏳ Select Custom Dates" # Prompt user if 'custom' is chosen but dates aren't set - + default_label = "⏳ Select Custom Dates" status = f"{default_label} | {mode_text}" - logger.debug(f"Fallback to default time range (7 days). time_range_quick: {time_range_quick}") return 7, status except Exception as e: @@ -81,7 +67,8 @@ def register_chart_callbacks(app): @app.callback( [Output('price-chart', 'figure'), - Output('time-range-status', 'children')], + Output('time-range-status', 'children'), + Output('chart-data-store', 'data')], [Input('symbol-dropdown', 'value'), Input('timeframe-dropdown', 'value'), Input('overlay-indicators-checklist', 'value'), @@ -98,120 +85,38 @@ def register_chart_callbacks(app): def update_price_chart(symbol, timeframe, overlay_indicators, subplot_indicators, selected_strategy, time_range_quick, custom_start_date, custom_end_date, analysis_mode, n_intervals, relayout_data, current_figure): - """Update the price chart with latest market data and selected indicators.""" try: triggered_id = ctx.triggered_id - logger.debug(f"Update_price_chart triggered by: {triggered_id}") - - # If the update is from the interval and the chart is locked, do nothing. if triggered_id == 'interval-component' and analysis_mode == 'locked': - logger.debug("Analysis mode is 'locked'. Skipping interval-based chart update.") - return no_update, no_update + return no_update, no_update, no_update days_back, status_message = calculate_time_range( time_range_quick, custom_start_date, custom_end_date, analysis_mode, n_intervals ) - - # Condition for attempting to use Patch() - can_patch = ( - triggered_id == 'interval-component' and - analysis_mode == 'realtime' and - (not selected_strategy or selected_strategy == 'basic') and - not (overlay_indicators or []) and # Ensure lists are treated as empty if None - not (subplot_indicators or []) - ) - - if can_patch: - logger.info(f"Attempting to PATCH chart for {symbol} {timeframe}") - - try: - # Find trace indices from current_figure - candlestick_trace_idx = -1 - volume_trace_idx = -1 - if current_figure and 'data' in current_figure: - for i, trace in enumerate(current_figure['data']): - if trace.get('type') == 'candlestick': - candlestick_trace_idx = i - elif trace.get('type') == 'bar' and trace.get('name', '').lower() == 'volume': # Basic volume trace often named 'Volume' - volume_trace_idx = i - logger.debug(f"Found candlestick trace at index {candlestick_trace_idx}, volume trace at index {volume_trace_idx}") - - if candlestick_trace_idx == -1: - logger.warning(f"Could not find candlestick trace in current figure for patch. Falling back to full draw.") - # Fall through to full draw by re-setting can_patch or just letting logic proceed - else: - chart_builder = ChartBuilder(logger_instance=logger) - candles = chart_builder.fetch_market_data_enhanced(symbol, timeframe, days_back) - - if not candles: - logger.warning(f"Patch update: No candles fetched for {symbol} {timeframe}. No update.") - return ctx.no_update, status_message - - df = prepare_chart_data(candles) - if df.empty: - logger.warning(f"Patch update: DataFrame empty after preparing chart data for {symbol} {timeframe}. No update.") - return ctx.no_update, status_message - - patched_figure = Patch() - - # Patch Candlestick Data using found index - patched_figure['data'][candlestick_trace_idx]['x'] = df['timestamp'] - patched_figure['data'][candlestick_trace_idx]['open'] = df['open'] - patched_figure['data'][candlestick_trace_idx]['high'] = df['high'] - patched_figure['data'][candlestick_trace_idx]['low'] = df['low'] - patched_figure['data'][candlestick_trace_idx]['close'] = df['close'] - logger.debug(f"Patched candlestick data (trace {candlestick_trace_idx}) for {symbol} {timeframe} with {len(df)} points.") - - # Patch Volume Data using found index (if volume trace exists) - if volume_trace_idx != -1: - if 'volume' in df.columns and df['volume'].sum() > 0: - patched_figure['data'][volume_trace_idx]['x'] = df['timestamp'] - patched_figure['data'][volume_trace_idx]['y'] = df['volume'] - logger.debug(f"Patched volume data (trace {volume_trace_idx}) for {symbol} {timeframe}.") - else: - logger.debug(f"No significant volume data in new fetch for {symbol} {timeframe}. Clearing data for volume trace {volume_trace_idx}.") - patched_figure['data'][volume_trace_idx]['x'] = [] - patched_figure['data'][volume_trace_idx]['y'] = [] - elif 'volume' in df.columns and df['volume'].sum() > 0: - logger.warning(f"New volume data present, but no existing volume trace found to patch in current figure.") - - logger.info(f"Successfully prepared patch for {symbol} {timeframe}.") - return patched_figure, status_message - - except Exception as patch_exception: - logger.error(f"Error during chart PATCH attempt for {symbol} {timeframe}: {patch_exception}. Falling back to full draw.") - # Fall through to full chart creation if patching fails - - # Full figure creation (default or if not patching or if patch failed) - logger.debug(f"Performing full chart draw for {symbol} {timeframe}. Can_patch: {can_patch}") + + chart_df = pd.DataFrame() if selected_strategy and selected_strategy != 'basic': - fig = create_strategy_chart(symbol, timeframe, selected_strategy, days_back=days_back) - logger.debug(f"Chart callback: Created strategy chart for {symbol} ({timeframe}) with strategy: {selected_strategy}, days_back: {days_back}") + fig, chart_df = create_strategy_chart(symbol, timeframe, selected_strategy, days_back=days_back) else: - fig = create_chart_with_indicators( - symbol=symbol, - timeframe=timeframe, - overlay_indicators=overlay_indicators or [], - subplot_indicators=subplot_indicators or [], + fig, chart_df = create_chart_with_indicators( + symbol=symbol, timeframe=timeframe, + overlay_indicators=overlay_indicators or [], subplot_indicators=subplot_indicators or [], days_back=days_back ) - indicator_count = len(overlay_indicators or []) + len(subplot_indicators or []) - logger.debug(f"Chart callback: Created dynamic chart for {symbol} ({timeframe}) with {indicator_count} indicators, days_back: {days_back}") + stored_data = None + if chart_df is not None and not chart_df.empty: + stored_data = chart_df.to_json(orient='split', date_format='iso') + if relayout_data and 'xaxis.range' in relayout_data: - fig.update_layout( - xaxis=dict(range=relayout_data['xaxis.range']), - yaxis=dict(range=relayout_data.get('yaxis.range')) - ) - logger.debug("Chart callback: Preserved chart zoom/pan state") + fig.update_layout(xaxis=dict(range=relayout_data['xaxis.range']), yaxis=dict(range=relayout_data.get('yaxis.range'))) - return fig, status_message + return fig, status_message, stored_data except Exception as e: - logger.error(f"Error updating price chart: {e}") + logger.error(f"Error updating price chart: {e}", exc_info=True) error_fig = create_error_chart(f"Error loading chart: {str(e)}") - error_status = f"❌ Error: {str(e)}" - return error_fig, error_status + return error_fig, f"❌ Error: {str(e)}", None @app.callback( Output('analysis-mode-toggle', 'value'), @@ -220,212 +125,79 @@ def register_chart_callbacks(app): prevent_initial_call=True ) def auto_lock_chart_on_interaction(relayout_data, current_mode): - """Automatically switch to 'locked' mode when the user zooms or pans.""" - # relayout_data is triggered by zoom/pan actions. - if relayout_data and 'xaxis.range' in relayout_data: - if current_mode != 'locked': - logger.debug("User chart interaction detected (zoom/pan). Switching to 'locked' analysis mode.") - return 'locked' + if relayout_data and 'xaxis.range' in relayout_data and current_mode != 'locked': + return 'locked' + return no_update + + @app.callback( + Output('market-stats', 'children'), + [Input('chart-data-store', 'data')], + [State('symbol-dropdown', 'value'), + State('timeframe-dropdown', 'value')] + ) + def update_market_stats(stored_data, symbol, timeframe): + if not stored_data: + return html.Div("Statistics will be available once chart data is loaded.") + try: + df = pd.read_json(io.StringIO(stored_data), orient='split') + if df.empty: + return html.Div("Not enough data to calculate statistics.") + return get_market_statistics(df, symbol, timeframe) + except Exception as e: + logger.error(f"Error updating market stats from stored data: {e}", exc_info=True) + return html.Div(f"Error loading statistics: {e}", style={'color': 'red'}) + + @app.callback( + Output("download-chart-data", "data"), + [Input("export-csv-btn", "n_clicks"), + Input("export-json-btn", "n_clicks")], + [State("chart-data-store", "data"), + State("symbol-dropdown", "value"), + State("timeframe-dropdown", "value")], + prevent_initial_call=True, + ) + def export_chart_data(csv_clicks, json_clicks, stored_data, symbol, timeframe): + triggered_id = ctx.triggered_id + if not triggered_id or not stored_data: + return no_update + try: + df = pd.read_json(io.StringIO(stored_data), orient='split') + if df.empty: + return no_update + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename_base = f"chart_data_{symbol}_{timeframe}_{timestamp}" + if triggered_id == "export-csv-btn": + return dcc.send_data_frame(df.to_csv, f"{filename_base}.csv", index=False) + elif triggered_id == "export-json-btn": + return dict(content=df.to_json(orient='records', date_format='iso'), filename=f"{filename_base}.json") + except Exception as e: + logger.error(f"Error exporting chart data from store: {e}", exc_info=True) return no_update - # Strategy selection callback - automatically load strategy indicators @app.callback( [Output('overlay-indicators-checklist', 'value'), Output('subplot-indicators-checklist', 'value')], [Input('strategy-dropdown', 'value')] ) def update_indicators_from_strategy(selected_strategy): - """Update indicator selections when a strategy is chosen.""" if not selected_strategy or selected_strategy == 'basic': return [], [] - try: - # Get strategy configuration all_strategies = get_all_example_strategies() if selected_strategy in all_strategies: strategy_example = all_strategies[selected_strategy] config = strategy_example.config - - # Extract overlay and subplot indicators from strategy overlay_indicators = config.overlay_indicators or [] - - # Extract subplot indicators from subplot configs subplot_indicators = [] for subplot_config in config.subplot_configs or []: subplot_indicators.extend(subplot_config.indicators or []) - - logger.debug(f"Chart callback: Loaded strategy {selected_strategy}: {len(overlay_indicators)} overlays, {len(subplot_indicators)} subplots") return overlay_indicators, subplot_indicators else: - logger.warning(f"Chart callback: Strategy {selected_strategy} not found") return [], [] - except Exception as e: - logger.error(f"Chart callback: Error loading strategy indicators: {e}") + logger.error(f"Error loading strategy indicators: {e}", exc_info=True) return [], [] - # Enhanced market statistics callback with comprehensive analysis - @app.callback( - Output('market-stats', 'children'), - [Input('symbol-dropdown', 'value'), - Input('timeframe-dropdown', 'value'), - Input('time-range-quick-select', 'value'), - Input('custom-date-range', 'start_date'), - Input('custom-date-range', 'end_date'), - Input('analysis-mode-toggle', 'value'), - Input('interval-component', 'n_intervals')] - ) - def update_market_stats(symbol, timeframe, time_range_quick, custom_start_date, custom_end_date, analysis_mode, n_intervals): - """Update comprehensive market statistics with analysis.""" - try: - triggered_id = ctx.triggered_id - logger.debug(f"update_market_stats triggered by: {triggered_id}, analysis_mode: {analysis_mode}") - - if analysis_mode == 'locked' and triggered_id == 'interval-component': - logger.info("Stats: Analysis mode is locked and triggered by interval; skipping stats update.") - return no_update - - # Calculate time range for analysis - days_back, time_status = calculate_time_range( - time_range_quick, custom_start_date, custom_end_date, analysis_mode, n_intervals - ) - - # Import analysis classes - from dashboard.components.data_analysis import VolumeAnalyzer, PriceMovementAnalyzer - - # Get basic market statistics for the selected time range - basic_stats = get_market_statistics(symbol, timeframe, days_back=days_back) - - # Create analyzers for comprehensive analysis - volume_analyzer = VolumeAnalyzer() - price_analyzer = PriceMovementAnalyzer() - - # Get analysis for the selected time range - volume_analysis = volume_analyzer.get_volume_statistics(symbol, timeframe, days_back) - price_analysis = price_analyzer.get_price_movement_statistics(symbol, timeframe, days_back) - - # Create enhanced statistics layout - return html.Div([ - html.H3("📊 Enhanced Market Statistics"), - html.P( - f"{time_status}", - style={'font-weight': 'bold', 'margin-bottom': '15px', 'color': '#4A4A4A', 'text-align': 'center', 'font-size': '1.1em'} - ), - - # Basic Market Data - html.Div([ - html.H4("💹 Current Market Data", style={'color': '#2c3e50', 'margin-bottom': '10px'}), - html.Div([ - html.Div([ - html.Strong(f"{key}: "), - html.Span(value, style={ - 'color': '#27ae60' if '+' in str(value) else '#e74c3c' if '-' in str(value) else '#2c3e50', - 'font-weight': 'bold' - }) - ], style={'margin': '5px 0'}) for key, value in basic_stats.items() - ]) - ], style={'border': '1px solid #bdc3c7', 'padding': '15px', 'margin': '10px 0', 'border-radius': '5px', 'background-color': '#f8f9fa'}), - - # Volume Analysis Section - create_volume_analysis_section(volume_analysis, days_back), - - # Price Movement Analysis Section - create_price_movement_section(price_analysis, days_back), - - # Additional Market Insights - html.Div([ - html.H4("🔍 Market Insights", style={'color': '#2c3e50', 'margin-bottom': '10px'}), - html.Div([ - html.P(f"📈 Analysis Period: {days_back} days | Timeframe: {timeframe}", style={'margin': '5px 0'}), - html.P(f"🎯 Symbol: {symbol}", style={'margin': '5px 0'}), - html.P("💡 Statistics are calculated for the selected time range.", style={'margin': '5px 0', 'font-style': 'italic', 'font-size': '14px'}) - ]) - ], style={'border': '1px solid #3498db', 'padding': '15px', 'margin': '10px 0', 'border-radius': '5px', 'background-color': '#ebf3fd'}) - ]) - - except Exception as e: - logger.error(f"Chart callback: Error updating enhanced market stats: {e}") - return html.Div([ - html.H3("Market Statistics"), - html.P(f"Error loading statistics: {str(e)}", style={'color': '#e74c3c'}) - ]) - - -def create_volume_analysis_section(volume_stats, days_back=7): - """Create volume analysis section for market statistics.""" - if not volume_stats or volume_stats.get('total_volume', 0) == 0: - return html.Div([ - html.H4(f"📊 Volume Analysis ({days_back} days)", style={'color': '#2c3e50', 'margin-bottom': '10px'}), - html.P("No volume data available for analysis", style={'color': '#e74c3c'}) - ], style={'border': '1px solid #e74c3c', 'padding': '15px', 'margin': '10px 0', 'border-radius': '5px', 'background-color': '#fdeded'}) - - return html.Div([ - html.H4(f"📊 Volume Analysis ({days_back} days)", style={'color': '#2c3e50', 'margin-bottom': '10px'}), - html.Div([ - html.Div([ - html.Strong("Total Volume: "), - html.Span(f"{volume_stats.get('total_volume', 0):,.2f}", style={'color': '#27ae60'}) - ], style={'margin': '5px 0'}), - html.Div([ - html.Strong("Average Volume: "), - html.Span(f"{volume_stats.get('average_volume', 0):,.2f}", style={'color': '#2c3e50'}) - ], style={'margin': '5px 0'}), - html.Div([ - html.Strong("Volume Trend: "), - html.Span( - volume_stats.get('volume_trend', 'Neutral'), - style={'color': '#27ae60' if volume_stats.get('volume_trend') == 'Increasing' else '#e74c3c' if volume_stats.get('volume_trend') == 'Decreasing' else '#f39c12'} - ) - ], style={'margin': '5px 0'}), - html.Div([ - html.Strong("High Volume Periods: "), - html.Span(f"{volume_stats.get('high_volume_periods', 0)}", style={'color': '#2c3e50'}) - ], style={'margin': '5px 0'}) - ]) - ], style={'border': '1px solid #27ae60', 'padding': '15px', 'margin': '10px 0', 'border-radius': '5px', 'background-color': '#eafaf1'}) - - -def create_price_movement_section(price_stats, days_back=7): - """Create price movement analysis section for market statistics.""" - if not price_stats or price_stats.get('total_returns') is None: - return html.Div([ - html.H4(f"📈 Price Movement Analysis ({days_back} days)", style={'color': '#2c3e50', 'margin-bottom': '10px'}), - html.P("No price movement data available for analysis", style={'color': '#e74c3c'}) - ], style={'border': '1px solid #e74c3c', 'padding': '15px', 'margin': '10px 0', 'border-radius': '5px', 'background-color': '#fdeded'}) - - return html.Div([ - html.H4(f"📈 Price Movement Analysis ({days_back} days)", style={'color': '#2c3e50', 'margin-bottom': '10px'}), - html.Div([ - html.Div([ - html.Strong("Total Return: "), - html.Span( - f"{price_stats.get('total_returns', 0):+.2f}%", - style={'color': '#27ae60' if price_stats.get('total_returns', 0) >= 0 else '#e74c3c'} - ) - ], style={'margin': '5px 0'}), - html.Div([ - html.Strong("Volatility: "), - html.Span(f"{price_stats.get('volatility', 0):.2f}%", style={'color': '#2c3e50'}) - ], style={'margin': '5px 0'}), - html.Div([ - html.Strong("Bullish Periods: "), - html.Span(f"{price_stats.get('bullish_periods', 0)}", style={'color': '#27ae60'}) - ], style={'margin': '5px 0'}), - html.Div([ - html.Strong("Bearish Periods: "), - html.Span(f"{price_stats.get('bearish_periods', 0)}", style={'color': '#e74c3c'}) - ], style={'margin': '5px 0'}), - html.Div([ - html.Strong("Trend Strength: "), - html.Span( - price_stats.get('trend_strength', 'Neutral'), - style={'color': '#27ae60' if 'Strong' in str(price_stats.get('trend_strength', '')) else '#f39c12'} - ) - ], style={'margin': '5px 0'}) - ]) - ], style={'border': '1px solid #3498db', 'padding': '15px', 'margin': '10px 0', 'border-radius': '5px', 'background-color': '#ebf3fd'}) - - # Clear date range button callback @app.callback( [Output('custom-date-range', 'start_date'), Output('custom-date-range', 'end_date'), @@ -434,13 +206,8 @@ def create_price_movement_section(price_stats, days_back=7): prevent_initial_call=True ) def clear_custom_date_range(n_clicks): - """Clear the custom date range and reset dropdown to force update.""" if n_clicks and n_clicks > 0: - logger.debug("Clear button clicked: Clearing custom dates and setting dropdown to 7d.") - return None, None, '7d' # Clear dates AND set dropdown to default '7d' - # Should not happen with prevent_initial_call=True and n_clicks > 0 check, but as a fallback: - return ctx.no_update, ctx.no_update, ctx.no_update - - + return None, None, '7d' + return no_update, no_update, no_update logger.info("Chart callback: Chart callbacks registered successfully") \ No newline at end of file diff --git a/dashboard/components/chart_controls.py b/dashboard/components/chart_controls.py index f13951a..c43b797 100644 --- a/dashboard/components/chart_controls.py +++ b/dashboard/components/chart_controls.py @@ -181,4 +181,45 @@ def create_time_range_controls(): 'padding': '15px', 'background-color': '#f0f8ff', 'margin-bottom': '20px' + }) + + +def create_export_controls(): + """Create the data export control panel.""" + return html.Div([ + html.H5("💾 Data Export", style={'color': '#2c3e50', 'margin-bottom': '15px'}), + html.Button( + "Export to CSV", + id="export-csv-btn", + className="btn btn-primary", + style={ + 'background-color': '#28a745', + 'color': 'white', + 'border': 'none', + 'padding': '8px 16px', + 'border-radius': '4px', + 'cursor': 'pointer', + 'margin-right': '10px' + } + ), + html.Button( + "Export to JSON", + id="export-json-btn", + className="btn btn-primary", + style={ + 'background-color': '#17a2b8', + 'color': 'white', + 'border': 'none', + 'padding': '8px 16px', + 'border-radius': '4px', + 'cursor': 'pointer' + } + ), + dcc.Download(id="download-chart-data") + ], style={ + 'border': '1px solid #bdc3c7', + 'border-radius': '8px', + 'padding': '15px', + 'background-color': '#f8f9fa', + 'margin-bottom': '20px' }) \ No newline at end of file diff --git a/dashboard/components/data_analysis.py b/dashboard/components/data_analysis.py index a820ee6..35de89a 100644 --- a/dashboard/components/data_analysis.py +++ b/dashboard/components/data_analysis.py @@ -26,89 +26,54 @@ class VolumeAnalyzer: self.db_manager = DatabaseManager() self.db_manager.initialize() - def get_volume_statistics(self, symbol: str, timeframe: str = "1h", days_back: int = 7) -> Dict[str, Any]: - """Calculate comprehensive volume statistics.""" + def get_volume_statistics(self, df: pd.DataFrame) -> Dict[str, Any]: + """Calculate comprehensive volume statistics from a DataFrame.""" try: - # Fetch recent market data - end_time = datetime.now(timezone.utc) - start_time = end_time - timedelta(days=days_back) + if df.empty or 'volume' not in df.columns: + return {'error': 'DataFrame is empty or missing volume column'} + + # Calculate volume statistics + total_volume = df['volume'].sum() + avg_volume = df['volume'].mean() + volume_std = df['volume'].std() - with self.db_manager.get_session() as session: - from sqlalchemy import text - - query = text(""" - SELECT timestamp, open, high, low, close, volume, trades_count - FROM market_data - WHERE symbol = :symbol - AND timeframe = :timeframe - AND timestamp >= :start_time - AND timestamp <= :end_time - ORDER BY timestamp ASC - """) - - result = session.execute(query, { - 'symbol': symbol, - 'timeframe': timeframe, - 'start_time': start_time, - 'end_time': end_time - }) - - candles = [] - for row in result: - candles.append({ - 'timestamp': row.timestamp, - 'open': float(row.open), - 'high': float(row.high), - 'low': float(row.low), - 'close': float(row.close), - 'volume': float(row.volume), - 'trades_count': int(row.trades_count) if row.trades_count else 0 - }) - - if not candles: - return {'error': 'No data available'} - - df = pd.DataFrame(candles) - - # Calculate volume statistics - total_volume = df['volume'].sum() - avg_volume = df['volume'].mean() - volume_std = df['volume'].std() - - # Volume trend analysis - recent_volume = df['volume'].tail(10).mean() # Last 10 periods - older_volume = df['volume'].head(10).mean() # First 10 periods - volume_trend = "Increasing" if recent_volume > older_volume else "Decreasing" - - # High volume periods (above 2 standard deviations) - high_volume_threshold = avg_volume + (2 * volume_std) - high_volume_periods = len(df[df['volume'] > high_volume_threshold]) - - # Volume-Price correlation - price_change = df['close'] - df['open'] - volume_price_corr = df['volume'].corr(price_change.abs()) - - # Average trade size (volume per trade) + # Volume trend analysis + recent_volume = df['volume'].tail(10).mean() # Last 10 periods + older_volume = df['volume'].head(10).mean() # First 10 periods + volume_trend = "Increasing" if recent_volume > older_volume else "Decreasing" + + # High volume periods (above 2 standard deviations) + high_volume_threshold = avg_volume + (2 * volume_std) + high_volume_periods = len(df[df['volume'] > high_volume_threshold]) + + # Volume-Price correlation + price_change = df['close'] - df['open'] + volume_price_corr = df['volume'].corr(price_change.abs()) + + # Average trade size (volume per trade) + if 'trades_count' in df.columns: df['avg_trade_size'] = df['volume'] / df['trades_count'].replace(0, 1) avg_trade_size = df['avg_trade_size'].mean() - - return { - 'total_volume': total_volume, - 'avg_volume': avg_volume, - 'volume_std': volume_std, - 'volume_trend': volume_trend, - 'high_volume_periods': high_volume_periods, - 'volume_price_correlation': volume_price_corr, - 'avg_trade_size': avg_trade_size, - 'max_volume': df['volume'].max(), - 'min_volume': df['volume'].min(), - 'volume_percentiles': { - '25th': df['volume'].quantile(0.25), - '50th': df['volume'].quantile(0.50), - '75th': df['volume'].quantile(0.75), - '95th': df['volume'].quantile(0.95) - } + else: + avg_trade_size = None # Not available + + return { + 'total_volume': total_volume, + 'avg_volume': avg_volume, + 'volume_std': volume_std, + 'volume_trend': volume_trend, + 'high_volume_periods': high_volume_periods, + 'volume_price_correlation': volume_price_corr, + 'avg_trade_size': avg_trade_size, + 'max_volume': df['volume'].max(), + 'min_volume': df['volume'].min(), + 'volume_percentiles': { + '25th': df['volume'].quantile(0.25), + '50th': df['volume'].quantile(0.50), + '75th': df['volume'].quantile(0.75), + '95th': df['volume'].quantile(0.95) } + } except Exception as e: logger.error(f"Volume analysis error: {e}") @@ -122,120 +87,83 @@ class PriceMovementAnalyzer: self.db_manager = DatabaseManager() self.db_manager.initialize() - def get_price_movement_statistics(self, symbol: str, timeframe: str = "1h", days_back: int = 7) -> Dict[str, Any]: - """Calculate comprehensive price movement statistics.""" + def get_price_movement_statistics(self, df: pd.DataFrame) -> Dict[str, Any]: + """Calculate comprehensive price movement statistics from a DataFrame.""" try: - # Fetch recent market data - end_time = datetime.now(timezone.utc) - start_time = end_time - timedelta(days=days_back) + if df.empty or not all(col in df.columns for col in ['open', 'high', 'low', 'close']): + return {'error': 'DataFrame is empty or missing required price columns'} + + # Basic price statistics + current_price = df['close'].iloc[-1] + period_start_price = df['open'].iloc[0] + period_return = ((current_price - period_start_price) / period_start_price) * 100 - with self.db_manager.get_session() as session: - from sqlalchemy import text - - query = text(""" - SELECT timestamp, open, high, low, close, volume - FROM market_data - WHERE symbol = :symbol - AND timeframe = :timeframe - AND timestamp >= :start_time - AND timestamp <= :end_time - ORDER BY timestamp ASC - """) - - result = session.execute(query, { - 'symbol': symbol, - 'timeframe': timeframe, - 'start_time': start_time, - 'end_time': end_time - }) - - candles = [] - for row in result: - candles.append({ - 'timestamp': row.timestamp, - 'open': float(row.open), - 'high': float(row.high), - 'low': float(row.low), - 'close': float(row.close), - 'volume': float(row.volume) - }) - - if not candles: - return {'error': 'No data available'} - - df = pd.DataFrame(candles) - - # Basic price statistics - current_price = df['close'].iloc[-1] - period_start_price = df['open'].iloc[0] - period_return = ((current_price - period_start_price) / period_start_price) * 100 - - # Daily returns (percentage changes) - df['returns'] = df['close'].pct_change() * 100 - df['returns'] = df['returns'].fillna(0) - - # Volatility metrics - volatility = df['returns'].std() - avg_return = df['returns'].mean() - - # Price range analysis - df['range'] = df['high'] - df['low'] - df['range_pct'] = (df['range'] / df['open']) * 100 - avg_range_pct = df['range_pct'].mean() - - # Directional analysis - bullish_periods = len(df[df['close'] > df['open']]) - bearish_periods = len(df[df['close'] < df['open']]) - neutral_periods = len(df[df['close'] == df['open']]) - - total_periods = len(df) - bullish_ratio = (bullish_periods / total_periods) * 100 if total_periods > 0 else 0 - - # Price extremes - period_high = df['high'].max() - period_low = df['low'].min() - - # Momentum indicators - # Simple momentum (current vs N periods ago) - momentum_periods = min(10, len(df) - 1) - if momentum_periods > 0: - momentum = ((current_price - df['close'].iloc[-momentum_periods-1]) / df['close'].iloc[-momentum_periods-1]) * 100 - else: - momentum = 0 - - # Trend strength (linear regression slope) - if len(df) > 2: - x = np.arange(len(df)) - slope, _ = np.polyfit(x, df['close'], 1) - trend_strength = slope / df['close'].mean() * 100 # Normalize by average price - else: - trend_strength = 0 - - return { - 'current_price': current_price, - 'period_return': period_return, - 'volatility': volatility, - 'avg_return': avg_return, - 'avg_range_pct': avg_range_pct, - 'bullish_periods': bullish_periods, - 'bearish_periods': bearish_periods, - 'neutral_periods': neutral_periods, - 'bullish_ratio': bullish_ratio, - 'period_high': period_high, - 'period_low': period_low, - 'momentum': momentum, - 'trend_strength': trend_strength, - 'return_percentiles': { - '5th': df['returns'].quantile(0.05), - '25th': df['returns'].quantile(0.25), - '75th': df['returns'].quantile(0.75), - '95th': df['returns'].quantile(0.95) - }, - 'max_gain': df['returns'].max(), - 'max_loss': df['returns'].min(), - 'positive_returns': len(df[df['returns'] > 0]), - 'negative_returns': len(df[df['returns'] < 0]) - } + # Daily returns (percentage changes) + df['returns'] = df['close'].pct_change() * 100 + df['returns'] = df['returns'].fillna(0) + + # Volatility metrics + volatility = df['returns'].std() + avg_return = df['returns'].mean() + + # Price range analysis + df['range'] = df['high'] - df['low'] + df['range_pct'] = (df['range'] / df['open']) * 100 + avg_range_pct = df['range_pct'].mean() + + # Directional analysis + bullish_periods = len(df[df['close'] > df['open']]) + bearish_periods = len(df[df['close'] < df['open']]) + neutral_periods = len(df[df['close'] == df['open']]) + + total_periods = len(df) + bullish_ratio = (bullish_periods / total_periods) * 100 if total_periods > 0 else 0 + + # Price extremes + period_high = df['high'].max() + period_low = df['low'].min() + + # Momentum indicators + # Simple momentum (current vs N periods ago) + momentum_periods = min(10, len(df) - 1) + if momentum_periods > 0: + momentum = ((current_price - df['close'].iloc[-momentum_periods-1]) / df['close'].iloc[-momentum_periods-1]) * 100 + else: + momentum = 0 + + # Trend strength (linear regression slope) + if len(df) > 2: + x = np.arange(len(df)) + slope, _ = np.polyfit(x, df['close'], 1) + trend_strength = slope / df['close'].mean() * 100 # Normalize by average price + else: + trend_strength = 0 + + return { + 'current_price': current_price, + 'period_return': period_return, + 'volatility': volatility, + 'avg_return': avg_return, + 'avg_range_pct': avg_range_pct, + 'bullish_periods': bullish_periods, + 'bearish_periods': bearish_periods, + 'neutral_periods': neutral_periods, + 'bullish_ratio': bullish_ratio, + 'period_high': period_high, + 'period_low': period_low, + 'momentum': momentum, + 'trend_strength': trend_strength, + 'return_percentiles': { + '5th': df['returns'].quantile(0.05), + '25th': df['returns'].quantile(0.25), + '75th': df['returns'].quantile(0.75), + '95th': df['returns'].quantile(0.95) + }, + 'max_gain': df['returns'].max(), + 'max_loss': df['returns'].min(), + 'positive_returns': len(df[df['returns'] > 0]), + 'negative_returns': len(df[df['returns'] < 0]) + } except Exception as e: logger.error(f"Price movement analysis error: {e}") @@ -718,4 +646,39 @@ def create_price_stats_display(stats: Dict[str, Any]) -> html.Div: ]) ], p="md", shadow="sm") - ], cols=3, spacing="md", style={'margin-top': '20px'}) \ No newline at end of file + ], cols=3, spacing="md", style={'margin-top': '20px'}) + + +def get_market_statistics(df: pd.DataFrame, symbol: str, timeframe: str) -> html.Div: + """ + Generate a comprehensive market statistics component from a DataFrame. + """ + try: + volume_analyzer = VolumeAnalyzer() + price_analyzer = PriceMovementAnalyzer() + + volume_stats = volume_analyzer.get_volume_statistics(df) + price_stats = price_analyzer.get_price_movement_statistics(df) + + if 'error' in volume_stats or 'error' in price_stats: + error_msg = volume_stats.get('error') or price_stats.get('error') + return html.Div(f"Error generating statistics: {error_msg}", style={'color': 'red'}) + + # Time range for display + start_date = df['timestamp'].min().strftime('%Y-%m-%d %H:%M') + end_date = df['timestamp'].max().strftime('%Y-%m-%d %H:%M') + days_back = (df['timestamp'].max() - df['timestamp'].min()).days + time_status = f"📅 Analysis Range: {start_date} to {end_date} (~{days_back} days)" + + return html.Div([ + html.H3("📊 Enhanced Market Statistics"), + html.P( + time_status, + style={'font-weight': 'bold', 'margin-bottom': '15px', 'color': '#4A4A4A', 'text-align': 'center', 'font-size': '1.1em'} + ), + create_price_stats_display(price_stats), + create_volume_stats_display(volume_stats) + ]) + except Exception as e: + logger.error(f"Error in get_market_statistics: {e}", exc_info=True) + return html.Div(f"Error generating statistics display: {e}", style={'color': 'red'}) \ No newline at end of file diff --git a/dashboard/layouts/market_data.py b/dashboard/layouts/market_data.py index 3108ed0..501e9ae 100644 --- a/dashboard/layouts/market_data.py +++ b/dashboard/layouts/market_data.py @@ -10,7 +10,8 @@ from components.charts.indicator_manager import get_indicator_manager from components.charts.indicator_defaults import ensure_default_indicators from dashboard.components.chart_controls import ( create_chart_config_panel, - create_time_range_controls + create_time_range_controls, + create_export_controls ) logger = get_logger("default_logger") @@ -80,6 +81,7 @@ def get_market_data_layout(): # Create components using the new modular functions chart_config_panel = create_chart_config_panel(strategy_options, overlay_options, subplot_options) time_range_controls = create_time_range_controls() + export_controls = create_export_controls() return html.Div([ # Title and basic controls @@ -115,9 +117,15 @@ def get_market_data_layout(): # Time Range Controls (positioned under indicators, next to chart) time_range_controls, + # Export Controls + export_controls, + # Chart dcc.Graph(id='price-chart'), + # Hidden store for chart data + dcc.Store(id='chart-data-store'), + # Enhanced Market statistics with integrated data analysis html.Div(id='market-stats', style={'margin-top': '20px'}) ]) \ No newline at end of file diff --git a/data/common/indicators.py b/data/common/indicators.py index 8cc9bfe..a09aa57 100644 --- a/data/common/indicators.py +++ b/data/common/indicators.py @@ -415,6 +415,67 @@ class TechnicalIndicators: return results + def calculate(self, indicator_type: str, candles: Union[pd.DataFrame, List[OHLCVCandle]], **kwargs) -> Optional[Dict[str, Any]]: + """ + Generic method to calculate any supported indicator by type. + + Args: + indicator_type: The type of indicator to calculate (e.g., 'sma', 'ema'). + candles: The input data, either a DataFrame or a list of OHLCVCandle objects. + **kwargs: Keyword arguments for the specific indicator function. + + Returns: + A dictionary containing the indicator results, or None if the type is unknown. + """ + # If input is a DataFrame, convert it to list of OHLCVCandle objects. + # This is a temporary adaptation to the existing methods. + # Future optimization should standardize on DataFrames. + if isinstance(candles, pd.DataFrame): + from .data_types import OHLCVCandle + + # Ensure required columns are present + required_cols = {'open', 'high', 'low', 'close', 'volume'} + if not required_cols.issubset(candles.columns): + if self.logger: + self.logger.error("Indicators: DataFrame missing required columns for OHLCVCandle conversion.") + return None + + symbol = kwargs.get('symbol', 'UNKNOWN') + timeframe = kwargs.get('timeframe', 'UNKNOWN') + + candles_list = [ + OHLCVCandle( + symbol=symbol, + timeframe=timeframe, + start_time=row['timestamp'], + end_time=row['timestamp'], + open=Decimal(str(row['open'])), + high=Decimal(str(row['high'])), + low=Decimal(str(row['low'])), + close=Decimal(str(row['close'])), + volume=Decimal(str(row['volume'])), + trade_count=int(row.get('trade_count', 0)) + ) for _, row in candles.iterrows() + ] + candles = candles_list + + indicator_method = getattr(self, indicator_type, None) + if indicator_method and callable(indicator_method): + # We need to construct a proper IndicatorResult object here + # For now, let's adapt to what the methods return + raw_result = indicator_method(candles, **kwargs) + + # The methods return List[IndicatorResult], let's package that + if raw_result: + return { + "data": raw_result + } + return None + + if self.logger: + self.logger.warning(f"TechnicalIndicators: Unknown indicator type '{indicator_type}'") + return None + def create_default_indicators_config() -> Dict[str, Dict[str, Any]]: """ diff --git a/tasks/tasks-crypto-bot-prd.md b/tasks/tasks-crypto-bot-prd.md index 9a905f9..222beaa 100644 --- a/tasks/tasks-crypto-bot-prd.md +++ b/tasks/tasks-crypto-bot-prd.md @@ -86,8 +86,8 @@ - [x] 3.5 Create market data monitoring dashboard (real-time data feed status) - [x] 3.6 Build simple data analysis tools (volume analysis, price movement statistics) - [x] 3.7 Add the chart time range selector and trigger for realtime data or historical data (when i analyze specified time range i do not want it to reset with realtime data triggers and callbacks) - - [ ] 3.8 Setup real-time dashboard updates using Redis callbacks - - [ ] 3.9 Add data export functionality for analysis (CSV/JSON export) + - [-] 3.8 Setup real-time dashboard updates using Redis callbacks (DEFERRED: Redis is not used for real-time dashboard updates now) + - [x] 3.9 Add data export functionality for analysis (CSV/JSON export) - [ ] 3.10 Unit test basic dashboard components and data visualization - [ ] 4.0 Strategy Engine and Bot Management Framework