301 lines
14 KiB
Python
301 lines
14 KiB
Python
"""
|
|
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") |