446 lines
24 KiB
Python
446 lines
24 KiB
Python
"""
|
|
Chart-related callbacks for the dashboard.
|
|
"""
|
|
|
|
from dash import Output, Input, State, Patch, ctx, html, no_update
|
|
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 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
|
|
|
|
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')
|
|
}
|
|
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
|
|
|
|
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:
|
|
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')],
|
|
[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):
|
|
"""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
|
|
|
|
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}")
|
|
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}")
|
|
else:
|
|
fig = 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}")
|
|
|
|
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")
|
|
|
|
return fig, status_message
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error updating price chart: {e}")
|
|
error_fig = create_error_chart(f"Error loading chart: {str(e)}")
|
|
error_status = f"❌ Error: {str(e)}"
|
|
return error_fig, error_status
|
|
|
|
@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 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'
|
|
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}")
|
|
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'),
|
|
Output('time-range-quick-select', 'value')],
|
|
[Input('clear-date-range-btn', 'n_clicks')],
|
|
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
|
|
|
|
|
|
|
|
logger.info("Chart callback: Chart callbacks registered successfully") |