- Enhanced the `UserIndicator` class to include an optional `timeframe` attribute for custom indicator timeframes. - Updated the `get_indicator_data` method in `MarketDataIntegrator` to fetch and calculate indicators based on the specified timeframe, ensuring proper data alignment and handling. - Modified the `ChartBuilder` to pass the correct DataFrame for plotting indicators with different timeframes. - Added UI elements in the indicator modal for selecting timeframes, improving user experience. - Updated relevant JSON templates to include the new `timeframe` field for all indicators. - Refactored the `prepare_chart_data` function to ensure it returns a DataFrame with a `DatetimeIndex` for consistent calculations. This commit enhances the flexibility and usability of the indicator system, allowing users to analyze data across various timeframes.
506 lines
22 KiB
Python
506 lines
22 KiB
Python
"""
|
|
Indicator-related callbacks for the dashboard.
|
|
"""
|
|
|
|
import dash
|
|
from dash import Output, Input, State, html, dcc, callback_context, no_update
|
|
import dash_bootstrap_components as dbc
|
|
import json
|
|
from utils.logger import get_logger
|
|
|
|
logger = get_logger("default_logger")
|
|
|
|
|
|
def register_indicator_callbacks(app):
|
|
"""Register indicator-related callbacks."""
|
|
|
|
# Modal control callbacks
|
|
@app.callback(
|
|
Output('indicator-modal', 'is_open'),
|
|
[Input('add-indicator-btn-visible', 'n_clicks'),
|
|
Input('cancel-indicator-btn', 'n_clicks'),
|
|
Input('save-indicator-btn', 'n_clicks'),
|
|
Input({'type': 'edit-indicator-btn', 'index': dash.ALL}, 'n_clicks')],
|
|
[State('indicator-modal', 'is_open')],
|
|
prevent_initial_call=True
|
|
)
|
|
def toggle_indicator_modal(add_clicks, cancel_clicks, save_clicks, edit_clicks, is_open):
|
|
"""Toggle the visibility of the add indicator modal."""
|
|
ctx = callback_context
|
|
if not ctx.triggered:
|
|
return is_open
|
|
|
|
triggered_id = ctx.triggered[0]['prop_id'].split('.')[0]
|
|
|
|
# Check for add button click
|
|
if triggered_id == 'add-indicator-btn-visible' and add_clicks:
|
|
return True
|
|
|
|
# Check for edit button clicks, ensuring a click actually happened
|
|
if 'edit-indicator-btn' in triggered_id and any(c for c in edit_clicks if c is not None):
|
|
return True
|
|
|
|
# Check for cancel or save clicks to close the modal
|
|
if triggered_id in ['cancel-indicator-btn', 'save-indicator-btn']:
|
|
return False
|
|
|
|
return is_open
|
|
|
|
# Update parameter fields based on indicator type
|
|
@app.callback(
|
|
[Output('indicator-parameters-message', 'style'),
|
|
Output('sma-parameters', 'style'),
|
|
Output('ema-parameters', 'style'),
|
|
Output('rsi-parameters', 'style'),
|
|
Output('macd-parameters', 'style'),
|
|
Output('bb-parameters', 'style')],
|
|
Input('indicator-type-dropdown', 'value'),
|
|
prevent_initial_call=True
|
|
)
|
|
def update_parameter_fields(indicator_type):
|
|
"""Show/hide parameter input fields based on selected indicator type."""
|
|
# Default styles
|
|
hidden_style = {'display': 'none', 'margin-bottom': '10px'}
|
|
visible_style = {'display': 'block', 'margin-bottom': '10px'}
|
|
|
|
# Default message visibility
|
|
message_style = {'display': 'block'} if not indicator_type else {'display': 'none'}
|
|
|
|
# Initialize all as hidden
|
|
sma_style = hidden_style
|
|
ema_style = hidden_style
|
|
rsi_style = hidden_style
|
|
macd_style = hidden_style
|
|
bb_style = hidden_style
|
|
|
|
# Show the relevant parameter section
|
|
if indicator_type == 'sma':
|
|
sma_style = visible_style
|
|
elif indicator_type == 'ema':
|
|
ema_style = visible_style
|
|
elif indicator_type == 'rsi':
|
|
rsi_style = visible_style
|
|
elif indicator_type == 'macd':
|
|
macd_style = visible_style
|
|
elif indicator_type == 'bollinger_bands':
|
|
bb_style = visible_style
|
|
|
|
return message_style, sma_style, ema_style, rsi_style, macd_style, bb_style
|
|
|
|
# Save indicator callback
|
|
@app.callback(
|
|
[Output('save-indicator-feedback', 'children'),
|
|
Output('overlay-indicators-checklist', 'options'),
|
|
Output('subplot-indicators-checklist', 'options')],
|
|
Input('save-indicator-btn', 'n_clicks'),
|
|
[State('indicator-name-input', 'value'),
|
|
State('indicator-type-dropdown', 'value'),
|
|
State('indicator-description-input', 'value'),
|
|
State('indicator-timeframe-dropdown', 'value'),
|
|
State('indicator-color-input', 'value'),
|
|
State('indicator-line-width-slider', 'value'),
|
|
# SMA parameters
|
|
State('sma-period-input', 'value'),
|
|
# EMA parameters
|
|
State('ema-period-input', 'value'),
|
|
# RSI parameters
|
|
State('rsi-period-input', 'value'),
|
|
# MACD parameters
|
|
State('macd-fast-period-input', 'value'),
|
|
State('macd-slow-period-input', 'value'),
|
|
State('macd-signal-period-input', 'value'),
|
|
# Bollinger Bands parameters
|
|
State('bb-period-input', 'value'),
|
|
State('bb-stddev-input', 'value'),
|
|
# Edit mode data
|
|
State('edit-indicator-store', 'data')],
|
|
prevent_initial_call=True
|
|
)
|
|
def save_new_indicator(n_clicks, name, indicator_type, description, timeframe, color, line_width,
|
|
sma_period, ema_period, rsi_period,
|
|
macd_fast, macd_slow, macd_signal,
|
|
bb_period, bb_stddev, edit_data):
|
|
"""Save a new indicator or update an existing one."""
|
|
if not n_clicks or not name or not indicator_type:
|
|
return "", no_update, no_update
|
|
|
|
try:
|
|
# Get indicator manager
|
|
from components.charts.indicator_manager import get_indicator_manager
|
|
manager = get_indicator_manager()
|
|
|
|
# Collect parameters based on indicator type and actual input values
|
|
parameters = {}
|
|
|
|
if indicator_type == 'sma':
|
|
parameters = {'period': sma_period or 20}
|
|
elif indicator_type == 'ema':
|
|
parameters = {'period': ema_period or 12}
|
|
elif indicator_type == 'rsi':
|
|
parameters = {'period': rsi_period or 14}
|
|
elif indicator_type == 'macd':
|
|
parameters = {
|
|
'fast_period': macd_fast or 12,
|
|
'slow_period': macd_slow or 26,
|
|
'signal_period': macd_signal or 9
|
|
}
|
|
elif indicator_type == 'bollinger_bands':
|
|
parameters = {
|
|
'period': bb_period or 20,
|
|
'std_dev': bb_stddev or 2.0
|
|
}
|
|
|
|
feedback_msg = None
|
|
# Check if this is an edit operation
|
|
is_edit = edit_data and edit_data.get('mode') == 'edit'
|
|
|
|
if is_edit:
|
|
# Update existing indicator
|
|
indicator_id = edit_data.get('indicator_id')
|
|
success = manager.update_indicator(
|
|
indicator_id,
|
|
name=name,
|
|
description=description or "",
|
|
parameters=parameters,
|
|
styling={'color': color or "#007bff", 'line_width': line_width or 2},
|
|
timeframe=timeframe or None
|
|
)
|
|
|
|
if success:
|
|
feedback_msg = dbc.Alert(f"Indicator '{name}' updated successfully!", color="success")
|
|
else:
|
|
feedback_msg = dbc.Alert("Failed to update indicator.", color="danger")
|
|
return feedback_msg, no_update, no_update
|
|
else:
|
|
# Create new indicator
|
|
new_indicator = manager.create_indicator(
|
|
name=name,
|
|
indicator_type=indicator_type,
|
|
parameters=parameters,
|
|
description=description or "",
|
|
color=color or "#007bff",
|
|
timeframe=timeframe or None
|
|
)
|
|
|
|
if not new_indicator:
|
|
feedback_msg = dbc.Alert("Failed to save indicator.", color="danger")
|
|
return feedback_msg, no_update, no_update
|
|
|
|
feedback_msg = dbc.Alert(f"Indicator '{name}' saved successfully!", color="success")
|
|
|
|
# Refresh the indicator options
|
|
overlay_indicators = manager.get_indicators_by_type('overlay')
|
|
subplot_indicators = manager.get_indicators_by_type('subplot')
|
|
|
|
overlay_options = []
|
|
for indicator in overlay_indicators:
|
|
display_name = f"{indicator.name} ({indicator.type.upper()})"
|
|
overlay_options.append({'label': display_name, 'value': indicator.id})
|
|
|
|
subplot_options = []
|
|
for indicator in subplot_indicators:
|
|
display_name = f"{indicator.name} ({indicator.type.upper()})"
|
|
subplot_options.append({'label': display_name, 'value': indicator.id})
|
|
|
|
return feedback_msg, overlay_options, subplot_options
|
|
|
|
except Exception as e:
|
|
logger.error(f"Indicator callback: Error saving indicator: {e}")
|
|
error_msg = dbc.Alert(f"Error: {str(e)}", color="danger")
|
|
return error_msg, no_update, no_update
|
|
|
|
# Update custom indicator lists with edit/delete buttons
|
|
@app.callback(
|
|
[Output('overlay-indicators-list', 'children'),
|
|
Output('subplot-indicators-list', 'children')],
|
|
[Input('overlay-indicators-checklist', 'options'),
|
|
Input('subplot-indicators-checklist', 'options'),
|
|
Input('overlay-indicators-checklist', 'value'),
|
|
Input('subplot-indicators-checklist', 'value')]
|
|
)
|
|
def update_custom_indicator_lists(overlay_options, subplot_options, overlay_values, subplot_values):
|
|
"""Create custom indicator lists with edit and delete buttons."""
|
|
|
|
def create_indicator_item(option, is_checked):
|
|
"""Create a single indicator item with checkbox and buttons."""
|
|
indicator_id = option['value']
|
|
indicator_name = option['label']
|
|
|
|
return html.Div([
|
|
# Checkbox and name
|
|
html.Div([
|
|
dcc.Checklist(
|
|
options=[{'label': '', 'value': indicator_id}],
|
|
value=[indicator_id] if is_checked else [],
|
|
id={'type': 'indicator-checkbox', 'index': indicator_id},
|
|
style={'display': 'inline-block', 'margin-right': '8px'}
|
|
),
|
|
html.Span(indicator_name, style={'display': 'inline-block', 'vertical-align': 'top'})
|
|
], style={'display': 'inline-block', 'width': '70%'}),
|
|
|
|
# Edit and Delete buttons
|
|
html.Div([
|
|
html.Button(
|
|
"✏️",
|
|
id={'type': 'edit-indicator-btn', 'index': indicator_id},
|
|
title="Edit indicator",
|
|
className="btn btn-sm btn-outline-primary",
|
|
style={'margin-left': '5px'}
|
|
),
|
|
html.Button(
|
|
"🗑️",
|
|
id={'type': 'delete-indicator-btn', 'index': indicator_id},
|
|
title="Delete indicator",
|
|
className="btn btn-sm btn-outline-danger",
|
|
style={'margin-left': '5px'}
|
|
)
|
|
], style={'display': 'inline-block', 'width': '30%', 'text-align': 'right'})
|
|
], style={
|
|
'display': 'block',
|
|
'padding': '5px 0',
|
|
'border-bottom': '1px solid #f0f0f0',
|
|
'margin-bottom': '5px'
|
|
})
|
|
|
|
# Create overlay indicators list
|
|
overlay_list = []
|
|
for option in overlay_options:
|
|
is_checked = option['value'] in (overlay_values or [])
|
|
overlay_list.append(create_indicator_item(option, is_checked))
|
|
|
|
# Create subplot indicators list
|
|
subplot_list = []
|
|
for option in subplot_options:
|
|
is_checked = option['value'] in (subplot_values or [])
|
|
subplot_list.append(create_indicator_item(option, is_checked))
|
|
|
|
return overlay_list, subplot_list
|
|
|
|
# Sync individual indicator checkboxes with main checklist
|
|
@app.callback(
|
|
Output('overlay-indicators-checklist', 'value', allow_duplicate=True),
|
|
[Input({'type': 'indicator-checkbox', 'index': dash.ALL}, 'value')],
|
|
[State('overlay-indicators-checklist', 'options')],
|
|
prevent_initial_call=True
|
|
)
|
|
def sync_overlay_indicators(checkbox_values, overlay_options):
|
|
"""Sync individual indicator checkboxes with main overlay checklist."""
|
|
if not checkbox_values or not overlay_options:
|
|
return []
|
|
|
|
selected_indicators = []
|
|
overlay_ids = [opt['value'] for opt in overlay_options]
|
|
|
|
# Flatten the checkbox values and filter for overlay indicators
|
|
for values in checkbox_values:
|
|
if values: # values is a list, check if not empty
|
|
for indicator_id in values:
|
|
if indicator_id in overlay_ids:
|
|
selected_indicators.append(indicator_id)
|
|
|
|
# Remove duplicates
|
|
return list(set(selected_indicators))
|
|
|
|
@app.callback(
|
|
Output('subplot-indicators-checklist', 'value', allow_duplicate=True),
|
|
[Input({'type': 'indicator-checkbox', 'index': dash.ALL}, 'value')],
|
|
[State('subplot-indicators-checklist', 'options')],
|
|
prevent_initial_call=True
|
|
)
|
|
def sync_subplot_indicators(checkbox_values, subplot_options):
|
|
"""Sync individual indicator checkboxes with main subplot checklist."""
|
|
if not checkbox_values or not subplot_options:
|
|
return []
|
|
|
|
selected_indicators = []
|
|
subplot_ids = [opt['value'] for opt in subplot_options]
|
|
|
|
# Flatten the checkbox values and filter for subplot indicators
|
|
for values in checkbox_values:
|
|
if values: # values is a list, check if not empty
|
|
for indicator_id in values:
|
|
if indicator_id in subplot_ids:
|
|
selected_indicators.append(indicator_id)
|
|
|
|
# Remove duplicates
|
|
return list(set(selected_indicators))
|
|
|
|
# Handle delete indicator
|
|
@app.callback(
|
|
[Output('save-indicator-feedback', 'children', allow_duplicate=True),
|
|
Output('overlay-indicators-checklist', 'options', allow_duplicate=True),
|
|
Output('subplot-indicators-checklist', 'options', allow_duplicate=True)],
|
|
[Input({'type': 'delete-indicator-btn', 'index': dash.ALL}, 'n_clicks')],
|
|
[State({'type': 'delete-indicator-btn', 'index': dash.ALL}, 'id')],
|
|
prevent_initial_call=True
|
|
)
|
|
def delete_indicator(delete_clicks, button_ids):
|
|
"""Delete an indicator when delete button is clicked."""
|
|
ctx = callback_context
|
|
if not ctx.triggered or not any(delete_clicks):
|
|
return no_update, no_update, no_update
|
|
|
|
# Find which button was clicked
|
|
triggered_id = ctx.triggered[0]['prop_id']
|
|
button_info = json.loads(triggered_id.split('.')[0])
|
|
indicator_id = button_info['index']
|
|
|
|
try:
|
|
# Get indicator manager and delete the indicator
|
|
from components.charts.indicator_manager import get_indicator_manager
|
|
manager = get_indicator_manager()
|
|
|
|
# Load indicator to get its name before deletion
|
|
indicator = manager.load_indicator(indicator_id)
|
|
indicator_name = indicator.name if indicator else indicator_id
|
|
|
|
if manager.delete_indicator(indicator_id):
|
|
# Refresh the indicator options
|
|
overlay_indicators = manager.get_indicators_by_type('overlay')
|
|
subplot_indicators = manager.get_indicators_by_type('subplot')
|
|
|
|
overlay_options = []
|
|
for indicator in overlay_indicators:
|
|
display_name = f"{indicator.name} ({indicator.type.upper()})"
|
|
overlay_options.append({'label': display_name, 'value': indicator.id})
|
|
|
|
subplot_options = []
|
|
for indicator in subplot_indicators:
|
|
display_name = f"{indicator.name} ({indicator.type.upper()})"
|
|
subplot_options.append({'label': display_name, 'value': indicator.id})
|
|
|
|
success_msg = dbc.Alert(f"Indicator '{indicator_name}' deleted.", color="warning")
|
|
|
|
return success_msg, overlay_options, subplot_options
|
|
else:
|
|
error_msg = dbc.Alert("Failed to delete indicator.", color="danger")
|
|
return error_msg, no_update, no_update
|
|
|
|
except Exception as e:
|
|
logger.error(f"Indicator callback: Error deleting indicator: {e}")
|
|
error_msg = dbc.Alert(f"Error: {str(e)}", color="danger")
|
|
return error_msg, no_update, no_update
|
|
|
|
# Handle edit indicator - open modal with existing data
|
|
@app.callback(
|
|
[Output('modal-title', 'children'),
|
|
Output('indicator-name-input', 'value'),
|
|
Output('indicator-type-dropdown', 'value'),
|
|
Output('indicator-description-input', 'value'),
|
|
Output('indicator-timeframe-dropdown', 'value'),
|
|
Output('indicator-color-input', 'value'),
|
|
Output('edit-indicator-store', 'data'),
|
|
# Add parameter field outputs
|
|
Output('sma-period-input', 'value'),
|
|
Output('ema-period-input', 'value'),
|
|
Output('rsi-period-input', 'value'),
|
|
Output('macd-fast-period-input', 'value'),
|
|
Output('macd-slow-period-input', 'value'),
|
|
Output('macd-signal-period-input', 'value'),
|
|
Output('bb-period-input', 'value'),
|
|
Output('bb-stddev-input', 'value')],
|
|
[Input({'type': 'edit-indicator-btn', 'index': dash.ALL}, 'n_clicks')],
|
|
[State({'type': 'edit-indicator-btn', 'index': dash.ALL}, 'id')],
|
|
prevent_initial_call=True
|
|
)
|
|
def edit_indicator(edit_clicks, button_ids):
|
|
"""Load indicator data for editing."""
|
|
ctx = callback_context
|
|
if not ctx.triggered or not any(edit_clicks):
|
|
return [no_update] * 15
|
|
|
|
# Find which button was clicked
|
|
triggered_id = ctx.triggered[0]['prop_id']
|
|
button_info = json.loads(triggered_id.split('.')[0])
|
|
indicator_id = button_info['index']
|
|
|
|
try:
|
|
# Load the indicator data
|
|
from components.charts.indicator_manager import get_indicator_manager
|
|
manager = get_indicator_manager()
|
|
indicator = manager.load_indicator(indicator_id)
|
|
|
|
if indicator:
|
|
# Store indicator ID for update
|
|
edit_data = {'indicator_id': indicator_id, 'mode': 'edit'}
|
|
|
|
# Extract parameter values based on indicator type
|
|
params = indicator.parameters
|
|
|
|
# Default parameter values
|
|
sma_period = None
|
|
ema_period = None
|
|
rsi_period = None
|
|
macd_fast = None
|
|
macd_slow = None
|
|
macd_signal = None
|
|
bb_period = None
|
|
bb_stddev = None
|
|
|
|
# Update with actual saved values
|
|
if indicator.type == 'sma':
|
|
sma_period = params.get('period')
|
|
elif indicator.type == 'ema':
|
|
ema_period = params.get('period')
|
|
elif indicator.type == 'rsi':
|
|
rsi_period = params.get('period')
|
|
elif indicator.type == 'macd':
|
|
macd_fast = params.get('fast_period')
|
|
macd_slow = params.get('slow_period')
|
|
macd_signal = params.get('signal_period')
|
|
elif indicator.type == 'bollinger_bands':
|
|
bb_period = params.get('period')
|
|
bb_stddev = params.get('std_dev')
|
|
|
|
return (
|
|
f"✏️ Edit Indicator: {indicator.name}",
|
|
indicator.name,
|
|
indicator.type,
|
|
indicator.description,
|
|
indicator.timeframe,
|
|
indicator.styling.color,
|
|
edit_data,
|
|
sma_period,
|
|
ema_period,
|
|
rsi_period,
|
|
macd_fast,
|
|
macd_slow,
|
|
macd_signal,
|
|
bb_period,
|
|
bb_stddev
|
|
)
|
|
else:
|
|
return [no_update] * 15
|
|
|
|
except Exception as e:
|
|
logger.error(f"Indicator callback: Error loading indicator for edit: {e}")
|
|
return [no_update] * 15
|
|
|
|
# Reset modal form when closed or saved
|
|
@app.callback(
|
|
[Output('indicator-name-input', 'value', allow_duplicate=True),
|
|
Output('indicator-type-dropdown', 'value', allow_duplicate=True),
|
|
Output('indicator-description-input', 'value', allow_duplicate=True),
|
|
Output('indicator-timeframe-dropdown', 'value', allow_duplicate=True),
|
|
Output('indicator-color-input', 'value', allow_duplicate=True),
|
|
Output('indicator-line-width-slider', 'value'),
|
|
Output('modal-title', 'children', allow_duplicate=True),
|
|
Output('edit-indicator-store', 'data', allow_duplicate=True),
|
|
# Add parameter field resets
|
|
Output('sma-period-input', 'value', allow_duplicate=True),
|
|
Output('ema-period-input', 'value', allow_duplicate=True),
|
|
Output('rsi-period-input', 'value', allow_duplicate=True),
|
|
Output('macd-fast-period-input', 'value', allow_duplicate=True),
|
|
Output('macd-slow-period-input', 'value', allow_duplicate=True),
|
|
Output('macd-signal-period-input', 'value', allow_duplicate=True),
|
|
Output('bb-period-input', 'value', allow_duplicate=True),
|
|
Output('bb-stddev-input', 'value', allow_duplicate=True)],
|
|
[Input('cancel-indicator-btn', 'n_clicks'),
|
|
Input('save-indicator-btn', 'n_clicks')], # Also reset on successful save
|
|
prevent_initial_call=True
|
|
)
|
|
def reset_modal_form(cancel_clicks, save_clicks):
|
|
"""Reset the modal form to its default state."""
|
|
return "", "", "", "", "", 2, "📊 Add New Indicator", None, 20, 12, 14, 12, 26, 9, 20, 2.0
|
|
|
|
logger.info("Indicator callbacks: registered successfully") |