2025-06-12 13:27:30 +08:00

421 lines
19 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
from config.indicators.config_utils import (
get_parameter_field_outputs,
get_parameter_field_states,
get_parameter_field_edit_outputs,
get_parameter_field_reset_outputs,
get_parameter_visibility_styles,
collect_parameter_values,
set_parameter_values,
reset_parameter_values
)
logger = get_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 - now fully dynamic!
@app.callback(
get_parameter_field_outputs(),
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."""
return get_parameter_visibility_styles(indicator_type)
# Save indicator callback - now fully dynamic!
@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'),
State('edit-indicator-store', 'data')] + get_parameter_field_states(),
prevent_initial_call=True
)
def save_new_indicator(n_clicks, name, indicator_type, description, timeframe, color, line_width, edit_data, *parameter_values):
"""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()
# Create mapping of parameter field IDs to values
parameter_states = get_parameter_field_states()
all_parameter_values = {}
for i, state in enumerate(parameter_states):
if i < len(parameter_values):
field_id = state.component_id
all_parameter_values[field_id] = parameter_values[i]
# Collect parameters for the specific indicator type
parameters = collect_parameter_values(indicator_type, all_parameter_values)
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 - now fully dynamic!
@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')] + get_parameter_field_edit_outputs(),
[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 the correct number of no_updates for all outputs
basic_outputs = 7 # Modal title, name, type, description, timeframe, color, edit_data
parameter_outputs = len(get_parameter_field_edit_outputs())
return [no_update] * (basic_outputs + parameter_outputs)
# 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'}
# Generate parameter values for all fields
parameter_values = set_parameter_values(indicator.type, indicator.parameters)
# Return all values: basic fields + dynamic parameter fields
return (
[f"✏️ Edit Indicator: {indicator.name}",
indicator.name,
indicator.type,
indicator.description,
indicator.timeframe,
indicator.styling.color,
edit_data] + parameter_values
)
else:
basic_outputs = 7
parameter_outputs = len(get_parameter_field_edit_outputs())
return [no_update] * (basic_outputs + parameter_outputs)
except Exception as e:
logger.error(f"Indicator callback: Error loading indicator for edit: {e}")
basic_outputs = 7
parameter_outputs = len(get_parameter_field_edit_outputs())
return [no_update] * (basic_outputs + parameter_outputs)
# Reset modal form when closed or saved - now fully dynamic!
@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)] + get_parameter_field_reset_outputs(),
[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."""
# Basic form reset values
basic_values = ["", "", "", "", "", 2, "📊 Add New Indicator", None]
# Dynamic parameter reset values
parameter_values = reset_parameter_values()
return basic_values + parameter_values
logger.info("Indicator callbacks: registered successfully")