""" Chart-related callbacks for the dashboard. """ from dash import Output, Input, State, Patch, ctx, html, no_update, dcc import dash_bootstrap_components as dbc 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, ) 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 from utils.time_range_utils import load_time_range_options logger = get_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: # Load time range options dynamically time_range_options = load_time_range_options() predefined_ranges = [option['value'] for option in time_range_options if option['value'] not in ['custom', 'realtime']] time_map = {} # Dynamically create time_map from loaded options for option in time_range_options: value = option['value'] label = option['label'] if value.endswith('h'): days_back_fractional = int(value[:-1]) / 24 elif value.endswith('d'): days_back_fractional = int(value[:-1]) else: continue # Skip custom and realtime, and any other unexpected values time_map[value] = (days_back_fractional, label) if time_range_quick in predefined_ranges: # Ensure the selected time_range_quick exists in our dynamically created time_map if time_range_quick not in time_map: logger.warning(f"Selected time range quick option '{time_range_quick}' not found in time_map. Defaulting to 7 days.") return 7, f"⚠️ Invalid time range selected. Defaulting to 7 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) return days_back, status 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)" return max(1, days_diff), status 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)" return 7, status 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" status = f"{default_label} | {mode_text}" return 7, status except Exception as e: logger.warning(f"Error calculating time range: {e}. Defaulting to 7 days.") return 7, f"⚠️ Error in time range. Defaulting to 7 days." def register_chart_callbacks(app): """Register chart-related callbacks.""" @app.callback( [Output('price-chart', 'figure'), Output('time-range-status', 'children'), Output('chart-data-store', 'data')], [Input('symbol-dropdown', 'value'), Input('timeframe-dropdown', 'value'), Input('overlay-indicators-checklist', 'value'), Input('subplot-indicators-checklist', 'value'), Input('strategy-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')], [State('price-chart', 'relayoutData'), State('price-chart', 'figure')] ) 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): """Updates the main price chart based on user selections and time range. Args: symbol (str): The selected trading symbol. timeframe (str): The selected chart timeframe (e.g., '1h', '1d'). overlay_indicators (list): List of selected overlay indicators. subplot_indicators (list): List of selected subplot indicators. selected_strategy (str): The selected trading strategy. time_range_quick (str): Quick time range selection (e.g., '7d', 'custom'). custom_start_date (str): Custom start date for the chart. custom_end_date (str): Custom end date for the chart. analysis_mode (str): The current analysis mode ('locked' or 'live'). n_intervals (int): Interval component trigger. relayout_data (dict): Data from chart relayout events (e.g., zoom/pan). current_figure (dict): The current chart figure data. Returns: tuple: A tuple containing: - dash.Dash.figure: The updated price chart figure. - str: The status message for the time range. - str: JSON string of the chart data. """ try: triggered_id = ctx.triggered_id if triggered_id == 'interval-component' and analysis_mode == 'locked': 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 ) chart_df = pd.DataFrame() if selected_strategy and selected_strategy != 'basic': fig, chart_df = create_strategy_chart(symbol, timeframe, selected_strategy, days_back=days_back) else: 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 ) 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'))) return fig, status_message, stored_data except Exception as e: logger.error(f"Error updating price chart: {e}", exc_info=True) error_fig = create_error_chart(f"Error loading chart: {str(e)}") return error_fig, f"❌ Error: {str(e)}", None @app.callback( Output('analysis-mode-toggle', 'value'), Input('price-chart', 'relayoutData'), State('analysis-mode-toggle', 'value'), prevent_initial_call=True ) def auto_lock_chart_on_interaction(relayout_data, current_mode): """Automatically locks the chart to 'analysis' mode upon user interaction (zoom/pan). Args: relayout_data (dict): Data from chart relayout events (e.g., zoom/pan). current_mode (str): The current analysis mode ('locked' or 'live'). Returns: str: The new analysis mode ('locked') if interaction occurred and not already locked, else no_update. """ 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): """Updates the market statistics display based on the stored chart data. Args: stored_data (str): JSON string of the chart data. symbol (str): The selected trading symbol. timeframe (str): The selected chart timeframe. Returns: dbc.Alert or html.Div: An alert message or the market statistics component. """ if not stored_data: return dbc.Alert("Statistics will be available once chart data is loaded.", color="info") try: df = pd.read_json(io.StringIO(stored_data), orient='split') if df.empty: return dbc.Alert("Not enough data to calculate statistics.", color="warning") 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 dbc.Alert(f"Error loading statistics: {e}", color="danger") @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): """Exports chart data to CSV or JSON based on button clicks. Args: csv_clicks (int): Number of clicks on the export CSV button. json_clicks (int): Number of clicks on the export JSON button. stored_data (str): JSON string of the chart data. symbol (str): The selected trading symbol. timeframe (str): The selected chart timeframe. Returns: dict: Data for download (filename and content) or no_update. """ 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 @app.callback( [Output('overlay-indicators-checklist', 'value'), Output('subplot-indicators-checklist', 'value')], [Input('strategy-dropdown', 'value')] ) def update_indicators_from_strategy(selected_strategy): """Updates the overlay and subplot indicators based on the selected strategy. Args: selected_strategy (str): The currently selected trading strategy. Returns: tuple: A tuple containing lists of overlay indicators and subplot indicators. """ if not selected_strategy or selected_strategy == 'basic': return [], [] try: all_strategies = get_all_example_strategies() if selected_strategy in all_strategies: strategy_example = all_strategies[selected_strategy] config = strategy_example.config overlay_indicators = config.overlay_indicators or [] subplot_indicators = [] for subplot_config in config.subplot_configs or []: subplot_indicators.extend(subplot_config.indicators or []) return overlay_indicators, subplot_indicators else: return [], [] except Exception as e: logger.error(f"Error loading strategy indicators: {e}", exc_info=True) return [], [] @app.callback( [Output('custom-date-range', 'start_date'), Output('custom-date-range', 'end_date'), Output('time-range-quick-select', 'value')], [Input('clear-date-range-btn', 'n_clicks')], prevent_initial_call=True ) def clear_custom_date_range(n_clicks): """Clears the custom date range and resets the quick time range selection. Args: n_clicks (int): Number of clicks on the clear date range button. Returns: tuple: A tuple containing None for start and end dates, and '7d' for quick select, or no_update. """ if n_clicks and n_clicks > 0: return None, None, '7d' return no_update, no_update, no_update logger.info("Chart callback: Chart callbacks registered successfully")