3.9 Enhance chart functionality with indicator management and data export features
- Updated `ChartBuilder` to support dynamic indicator integration, allowing users to specify overlay and subplot indicators for enhanced chart analysis. - Implemented a new `get_indicator_data` method in `MarketDataIntegrator` for fetching indicator data based on user configurations. - Added `create_export_controls` in `chart_controls.py` to facilitate data export options (CSV/JSON) for user analysis. - Enhanced error handling and logging throughout the chart and data analysis processes to improve reliability and user feedback. - Updated documentation to reflect new features and usage guidelines for indicator management and data export functionalities.
This commit is contained in:
parent
8572a7a387
commit
c121b469f0
@ -15,6 +15,12 @@ from decimal import Decimal
|
|||||||
from database.operations import get_database_operations, DatabaseOperationError
|
from database.operations import get_database_operations, DatabaseOperationError
|
||||||
from utils.logger import get_logger
|
from utils.logger import get_logger
|
||||||
from .utils import validate_market_data, prepare_chart_data, get_indicator_colors
|
from .utils import validate_market_data, prepare_chart_data, get_indicator_colors
|
||||||
|
from .indicator_manager import get_indicator_manager
|
||||||
|
from .layers import (
|
||||||
|
LayerManager, CandlestickLayer, VolumeLayer,
|
||||||
|
SMALayer, EMALayer, BollingerBandsLayer,
|
||||||
|
RSILayer, MACDLayer, IndicatorLayerConfig
|
||||||
|
)
|
||||||
|
|
||||||
# Initialize logger
|
# Initialize logger
|
||||||
logger = get_logger("default_logger")
|
logger = get_logger("default_logger")
|
||||||
@ -153,13 +159,16 @@ class ChartBuilder:
|
|||||||
include_volume = kwargs.get('include_volume', has_volume)
|
include_volume = kwargs.get('include_volume', has_volume)
|
||||||
|
|
||||||
if include_volume and has_volume:
|
if include_volume and has_volume:
|
||||||
return self._create_candlestick_with_volume(df, symbol, timeframe, **kwargs)
|
fig, df_chart = self._create_candlestick_with_volume(df, symbol, timeframe, **kwargs)
|
||||||
|
return fig, df_chart
|
||||||
else:
|
else:
|
||||||
return self._create_basic_candlestick(df, symbol, timeframe, **kwargs)
|
fig, df_chart = self._create_basic_candlestick(df, symbol, timeframe, **kwargs)
|
||||||
|
return fig, df_chart
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Chart builder: Error creating candlestick chart for {symbol} {timeframe}: {e}")
|
self.logger.error(f"Chart builder: Error creating candlestick chart for {symbol} {timeframe}: {e}")
|
||||||
return self._create_error_chart(f"Error loading chart: {str(e)}")
|
error_fig = self._create_error_chart(f"Error loading chart: {str(e)}")
|
||||||
|
return error_fig, pd.DataFrame()
|
||||||
|
|
||||||
def _create_basic_candlestick(self, df: pd.DataFrame, symbol: str,
|
def _create_basic_candlestick(self, df: pd.DataFrame, symbol: str,
|
||||||
timeframe: str, **kwargs) -> go.Figure:
|
timeframe: str, **kwargs) -> go.Figure:
|
||||||
@ -194,7 +203,7 @@ class ChartBuilder:
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.logger.debug(f"Chart builder: Created basic candlestick chart for {symbol} {timeframe} with {len(df)} candles")
|
self.logger.debug(f"Chart builder: Created basic candlestick chart for {symbol} {timeframe} with {len(df)} candles")
|
||||||
return fig
|
return fig, df
|
||||||
|
|
||||||
def _create_candlestick_with_volume(self, df: pd.DataFrame, symbol: str,
|
def _create_candlestick_with_volume(self, df: pd.DataFrame, symbol: str,
|
||||||
timeframe: str, **kwargs) -> go.Figure:
|
timeframe: str, **kwargs) -> go.Figure:
|
||||||
@ -250,7 +259,8 @@ class ChartBuilder:
|
|||||||
showlegend=False,
|
showlegend=False,
|
||||||
height=height,
|
height=height,
|
||||||
xaxis_rangeslider_visible=False,
|
xaxis_rangeslider_visible=False,
|
||||||
hovermode='x unified'
|
hovermode='x unified',
|
||||||
|
dragmode='pan'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update axes
|
# Update axes
|
||||||
@ -258,8 +268,8 @@ class ChartBuilder:
|
|||||||
fig.update_yaxes(title_text="Volume", row=2, col=1)
|
fig.update_yaxes(title_text="Volume", row=2, col=1)
|
||||||
fig.update_xaxes(title_text="Time", row=2, col=1)
|
fig.update_xaxes(title_text="Time", row=2, col=1)
|
||||||
|
|
||||||
self.logger.debug(f"Chart builder: Created candlestick chart with volume for {symbol} {timeframe}")
|
self.logger.debug(f"Chart builder: Created candlestick chart with volume for {symbol} {timeframe} with {len(df)} candles")
|
||||||
return fig
|
return fig, df
|
||||||
|
|
||||||
def _create_empty_chart(self, message: str = "No data available") -> go.Figure:
|
def _create_empty_chart(self, message: str = "No data available") -> go.Figure:
|
||||||
"""Create an empty chart with a message."""
|
"""Create an empty chart with a message."""
|
||||||
@ -356,7 +366,7 @@ class ChartBuilder:
|
|||||||
subplot_indicators: List[str] = None,
|
subplot_indicators: List[str] = None,
|
||||||
days_back: int = 7, **kwargs) -> go.Figure:
|
days_back: int = 7, **kwargs) -> go.Figure:
|
||||||
"""
|
"""
|
||||||
Create a chart with dynamically selected indicators.
|
Create a candlestick chart with specified technical indicators.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
symbol: Trading pair
|
symbol: Trading pair
|
||||||
@ -367,35 +377,27 @@ class ChartBuilder:
|
|||||||
**kwargs: Additional chart parameters
|
**kwargs: Additional chart parameters
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Plotly Figure object with selected indicators
|
Plotly Figure object and a pandas DataFrame with all chart data.
|
||||||
"""
|
"""
|
||||||
|
overlay_indicators = overlay_indicators or []
|
||||||
|
subplot_indicators = subplot_indicators or []
|
||||||
try:
|
try:
|
||||||
# Fetch market data
|
# 1. Fetch and Prepare Base Data
|
||||||
candles = self.fetch_market_data_enhanced(symbol, timeframe, days_back)
|
candles = self.fetch_market_data_enhanced(symbol, timeframe, days_back)
|
||||||
|
|
||||||
if not candles:
|
if not candles:
|
||||||
self.logger.warning(f"Chart builder: No data available for {symbol} {timeframe}")
|
self.logger.warning(f"No data for {symbol} {timeframe}, creating empty chart.")
|
||||||
return self._create_empty_chart(f"No data available for {symbol} {timeframe}")
|
return self._create_empty_chart(f"No data for {symbol} {timeframe}"), pd.DataFrame()
|
||||||
|
|
||||||
# Validate and prepare data
|
|
||||||
if not validate_market_data(candles):
|
|
||||||
self.logger.error(f"Chart builder: Invalid market data for {symbol} {timeframe}")
|
|
||||||
return self._create_error_chart("Invalid market data format")
|
|
||||||
|
|
||||||
df = prepare_chart_data(candles)
|
df = prepare_chart_data(candles)
|
||||||
|
if df.empty:
|
||||||
|
self.logger.warning(f"DataFrame empty for {symbol} {timeframe}, creating empty chart.")
|
||||||
|
return self._create_empty_chart(f"No data for {symbol} {timeframe}"), pd.DataFrame()
|
||||||
|
|
||||||
# Import layer classes
|
# Initialize final DataFrame for export
|
||||||
from .layers import (
|
final_df = df.copy()
|
||||||
LayerManager, CandlestickLayer, VolumeLayer,
|
|
||||||
SMALayer, EMALayer, BollingerBandsLayer,
|
|
||||||
RSILayer, MACDLayer, IndicatorLayerConfig
|
|
||||||
)
|
|
||||||
from .indicator_manager import get_indicator_manager
|
|
||||||
|
|
||||||
# Get user indicators instead of default configurations
|
# 2. Setup Subplots
|
||||||
indicator_manager = get_indicator_manager()
|
# Count subplot indicators to configure rows
|
||||||
|
|
||||||
# Calculate subplot requirements
|
|
||||||
subplot_count = 0
|
subplot_count = 0
|
||||||
volume_enabled = 'volume' in df.columns and df['volume'].sum() > 0
|
volume_enabled = 'volume' in df.columns and df['volume'].sum() > 0
|
||||||
if volume_enabled:
|
if volume_enabled:
|
||||||
@ -440,8 +442,8 @@ class ChartBuilder:
|
|||||||
|
|
||||||
current_row = 1
|
current_row = 1
|
||||||
|
|
||||||
# Add candlestick layer (always included)
|
# 4. Add Candlestick Trace
|
||||||
candlestick_trace = go.Candlestick(
|
fig.add_trace(go.Candlestick(
|
||||||
x=df['timestamp'],
|
x=df['timestamp'],
|
||||||
open=df['open'],
|
open=df['open'],
|
||||||
high=df['high'],
|
high=df['high'],
|
||||||
@ -449,72 +451,10 @@ class ChartBuilder:
|
|||||||
close=df['close'],
|
close=df['close'],
|
||||||
name=symbol,
|
name=symbol,
|
||||||
increasing_line_color=self.default_colors['bullish'],
|
increasing_line_color=self.default_colors['bullish'],
|
||||||
decreasing_line_color=self.default_colors['bearish'],
|
decreasing_line_color=self.default_colors['bearish']
|
||||||
showlegend=False
|
), row=current_row, col=1)
|
||||||
)
|
|
||||||
fig.add_trace(candlestick_trace, row=current_row, col=1)
|
|
||||||
|
|
||||||
# Add overlay indicators
|
# 5. Add Volume Trace (if applicable)
|
||||||
if overlay_indicators:
|
|
||||||
for indicator_id in overlay_indicators:
|
|
||||||
try:
|
|
||||||
# Load user indicator
|
|
||||||
user_indicator = indicator_manager.load_indicator(indicator_id)
|
|
||||||
|
|
||||||
if user_indicator is None:
|
|
||||||
self.logger.warning(f"Overlay indicator {indicator_id} not found")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Create appropriate indicator layer using user configuration
|
|
||||||
if user_indicator.type == 'sma':
|
|
||||||
period = user_indicator.parameters.get('period', 20)
|
|
||||||
layer_config = IndicatorLayerConfig(
|
|
||||||
name=user_indicator.name,
|
|
||||||
indicator_type='sma',
|
|
||||||
color=user_indicator.styling.color,
|
|
||||||
parameters={'period': period},
|
|
||||||
line_width=user_indicator.styling.line_width
|
|
||||||
)
|
|
||||||
sma_layer = SMALayer(layer_config)
|
|
||||||
traces = sma_layer.create_traces(df.to_dict('records'))
|
|
||||||
for trace in traces:
|
|
||||||
fig.add_trace(trace, row=current_row, col=1)
|
|
||||||
|
|
||||||
elif user_indicator.type == 'ema':
|
|
||||||
period = user_indicator.parameters.get('period', 12)
|
|
||||||
layer_config = IndicatorLayerConfig(
|
|
||||||
name=user_indicator.name,
|
|
||||||
indicator_type='ema',
|
|
||||||
color=user_indicator.styling.color,
|
|
||||||
parameters={'period': period},
|
|
||||||
line_width=user_indicator.styling.line_width
|
|
||||||
)
|
|
||||||
ema_layer = EMALayer(layer_config)
|
|
||||||
traces = ema_layer.create_traces(df.to_dict('records'))
|
|
||||||
for trace in traces:
|
|
||||||
fig.add_trace(trace, row=current_row, col=1)
|
|
||||||
|
|
||||||
elif user_indicator.type == 'bollinger_bands':
|
|
||||||
period = user_indicator.parameters.get('period', 20)
|
|
||||||
std_dev = user_indicator.parameters.get('std_dev', 2.0)
|
|
||||||
layer_config = IndicatorLayerConfig(
|
|
||||||
name=user_indicator.name,
|
|
||||||
indicator_type='bollinger_bands',
|
|
||||||
color=user_indicator.styling.color,
|
|
||||||
parameters={'period': period, 'std_dev': std_dev},
|
|
||||||
line_width=user_indicator.styling.line_width,
|
|
||||||
show_middle_line=True
|
|
||||||
)
|
|
||||||
bb_layer = BollingerBandsLayer(layer_config)
|
|
||||||
traces = bb_layer.create_traces(df.to_dict('records'))
|
|
||||||
for trace in traces:
|
|
||||||
fig.add_trace(trace, row=current_row, col=1)
|
|
||||||
|
|
||||||
self.logger.debug(f"Added overlay indicator: {user_indicator.name}")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Chart builder: Error adding overlay indicator {indicator_id}: {e}")
|
|
||||||
|
|
||||||
# Move to next row for volume if enabled
|
|
||||||
if volume_enabled:
|
if volume_enabled:
|
||||||
current_row += 1
|
current_row += 1
|
||||||
volume_colors = [self.default_colors['bullish'] if close >= open else self.default_colors['bearish']
|
volume_colors = [self.default_colors['bullish'] if close >= open else self.default_colors['bearish']
|
||||||
@ -525,56 +465,89 @@ class ChartBuilder:
|
|||||||
y=df['volume'],
|
y=df['volume'],
|
||||||
name='Volume',
|
name='Volume',
|
||||||
marker_color=volume_colors,
|
marker_color=volume_colors,
|
||||||
opacity=0.7,
|
opacity=0.7
|
||||||
showlegend=False
|
|
||||||
)
|
)
|
||||||
fig.add_trace(volume_trace, row=current_row, col=1)
|
fig.add_trace(volume_trace, row=current_row, col=1)
|
||||||
fig.update_yaxes(title_text="Volume", row=current_row, col=1)
|
fig.update_yaxes(title_text="Volume", row=current_row, col=1)
|
||||||
|
|
||||||
# Add subplot indicators
|
# 6. Add Indicator Traces
|
||||||
if subplot_indicators:
|
indicator_manager = get_indicator_manager()
|
||||||
for indicator_id in subplot_indicators:
|
all_indicator_configs = []
|
||||||
current_row += 1
|
|
||||||
try:
|
|
||||||
# Load user indicator
|
|
||||||
user_indicator = indicator_manager.load_indicator(indicator_id)
|
|
||||||
|
|
||||||
if user_indicator is None:
|
# Create IndicatorLayerConfig objects from indicator IDs
|
||||||
self.logger.warning(f"Subplot indicator {indicator_id} not found")
|
indicator_ids = (overlay_indicators or []) + (subplot_indicators or [])
|
||||||
continue
|
for ind_id in indicator_ids:
|
||||||
|
indicator = indicator_manager.load_indicator(ind_id)
|
||||||
|
if indicator:
|
||||||
|
config = IndicatorLayerConfig(
|
||||||
|
id=indicator.id,
|
||||||
|
name=indicator.name,
|
||||||
|
indicator_type=indicator.type,
|
||||||
|
parameters=indicator.parameters
|
||||||
|
)
|
||||||
|
all_indicator_configs.append(config)
|
||||||
|
|
||||||
# Create appropriate subplot indicator layer
|
if all_indicator_configs:
|
||||||
if user_indicator.type == 'rsi':
|
indicator_data_map = self.data_integrator.get_indicator_data(
|
||||||
period = user_indicator.parameters.get('period', 14)
|
df, all_indicator_configs, indicator_manager
|
||||||
rsi_layer = RSILayer(period=period, color=user_indicator.styling.color, name=user_indicator.name)
|
)
|
||||||
|
|
||||||
# Use the render method
|
for indicator_id, indicator_df in indicator_data_map.items():
|
||||||
fig = rsi_layer.render(fig, df, row=current_row, col=1)
|
indicator = indicator_manager.load_indicator(indicator_id)
|
||||||
|
if not indicator:
|
||||||
|
self.logger.warning(f"Could not load indicator '{indicator_id}' for plotting.")
|
||||||
|
continue
|
||||||
|
|
||||||
# Add RSI reference lines
|
if indicator_df is not None and not indicator_df.empty:
|
||||||
fig.add_hline(y=70, line_dash="dash", line_color="red", opacity=0.5, row=current_row, col=1)
|
final_df = pd.merge(final_df, indicator_df, on='timestamp', how='left')
|
||||||
fig.add_hline(y=30, line_dash="dash", line_color="green", opacity=0.5, row=current_row, col=1)
|
|
||||||
fig.update_yaxes(title_text="RSI", range=[0, 100], row=current_row, col=1)
|
|
||||||
|
|
||||||
elif user_indicator.type == 'macd':
|
# Determine target row for plotting
|
||||||
fast_period = user_indicator.parameters.get('fast_period', 12)
|
target_row = 1 # Default to overlay on the main chart
|
||||||
slow_period = user_indicator.parameters.get('slow_period', 26)
|
if indicator.id in subplot_indicators:
|
||||||
signal_period = user_indicator.parameters.get('signal_period', 9)
|
current_row += 1
|
||||||
macd_layer = MACDLayer(fast_period=fast_period, slow_period=slow_period,
|
target_row = current_row
|
||||||
signal_period=signal_period, color=user_indicator.styling.color, name=user_indicator.name)
|
fig.update_yaxes(title_text=indicator.name, row=target_row, col=1)
|
||||||
|
|
||||||
# Use the render method
|
if indicator.type == 'bollinger_bands':
|
||||||
fig = macd_layer.render(fig, df, row=current_row, col=1)
|
if all(c in indicator_df.columns for c in ['upper_band', 'lower_band', 'middle_band']):
|
||||||
|
# Prepare data for the filled area
|
||||||
|
x_vals = indicator_df['timestamp']
|
||||||
|
y_upper = indicator_df['upper_band']
|
||||||
|
y_lower = indicator_df['lower_band']
|
||||||
|
|
||||||
# Add zero line for MACD
|
# Convert hex color to rgba for the fill
|
||||||
fig.add_hline(y=0, line_dash="dash", line_color="gray", opacity=0.5, row=current_row, col=1)
|
hex_color = indicator.styling.color.lstrip('#')
|
||||||
fig.update_yaxes(title_text="MACD", row=current_row, col=1)
|
rgb = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
|
||||||
|
fill_color = f'rgba({rgb[0]}, {rgb[1]}, {rgb[2]}, 0.1)'
|
||||||
|
|
||||||
self.logger.debug(f"Added subplot indicator: {user_indicator.name}")
|
# Add the transparent fill trace
|
||||||
except Exception as e:
|
fig.add_trace(go.Scatter(
|
||||||
self.logger.error(f"Chart builder: Error adding subplot indicator {indicator_id}: {e}")
|
x=pd.concat([x_vals, x_vals[::-1]]),
|
||||||
|
y=pd.concat([y_upper, y_lower[::-1]]),
|
||||||
|
fill='toself',
|
||||||
|
fillcolor=fill_color,
|
||||||
|
line={'color': 'rgba(255,255,255,0)'},
|
||||||
|
hoverinfo='none',
|
||||||
|
showlegend=False
|
||||||
|
), row=target_row, col=1)
|
||||||
|
|
||||||
# Update layout
|
# Add the visible line traces for the bands
|
||||||
|
fig.add_trace(go.Scatter(x=x_vals, y=y_upper, name=f'{indicator.name} Upper', mode='lines', line=dict(color=indicator.styling.color, width=1.5)), row=target_row, col=1)
|
||||||
|
fig.add_trace(go.Scatter(x=x_vals, y=y_lower, name=f'{indicator.name} Lower', mode='lines', line=dict(color=indicator.styling.color, width=1.5)), row=target_row, col=1)
|
||||||
|
fig.add_trace(go.Scatter(x=x_vals, y=indicator_df['middle_band'], name=f'{indicator.name} Middle', mode='lines', line=dict(color=indicator.styling.color, width=1.5, dash='dash')), row=target_row, col=1)
|
||||||
|
else:
|
||||||
|
# Generic plotting for other indicators
|
||||||
|
for col in indicator_df.columns:
|
||||||
|
if col != 'timestamp':
|
||||||
|
fig.add_trace(go.Scatter(
|
||||||
|
x=indicator_df['timestamp'],
|
||||||
|
y=indicator_df[col],
|
||||||
|
mode='lines',
|
||||||
|
name=f"{indicator.name} ({col})",
|
||||||
|
line=dict(color=indicator.styling.color)
|
||||||
|
), row=target_row, col=1)
|
||||||
|
|
||||||
|
# 7. Final Layout Updates
|
||||||
height = kwargs.get('height', self.default_height)
|
height = kwargs.get('height', self.default_height)
|
||||||
template = kwargs.get('template', self.default_template)
|
template = kwargs.get('template', self.default_template)
|
||||||
|
|
||||||
@ -594,8 +567,9 @@ class ChartBuilder:
|
|||||||
|
|
||||||
indicator_count = len(overlay_indicators or []) + len(subplot_indicators or [])
|
indicator_count = len(overlay_indicators or []) + len(subplot_indicators or [])
|
||||||
self.logger.debug(f"Created chart for {symbol} {timeframe} with {indicator_count} indicators")
|
self.logger.debug(f"Created chart for {symbol} {timeframe} with {indicator_count} indicators")
|
||||||
return fig
|
self.logger.info(f"Successfully created chart for {symbol} with {len(overlay_indicators + subplot_indicators)} indicators.")
|
||||||
|
return fig, final_df
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Chart builder: Error creating chart with indicators: {e}")
|
self.logger.error(f"Error in create_chart_with_indicators for {symbol}: {e}", exc_info=True)
|
||||||
return self._create_error_chart(f"Chart creation failed: {str(e)}")
|
return self._create_error_chart(f"Error generating indicator chart: {e}"), pd.DataFrame()
|
||||||
@ -457,6 +457,49 @@ class MarketDataIntegrator:
|
|||||||
self._cache.clear()
|
self._cache.clear()
|
||||||
self.logger.debug("Data Integration: Data cache cleared")
|
self.logger.debug("Data Integration: Data cache cleared")
|
||||||
|
|
||||||
|
def get_indicator_data(
|
||||||
|
self,
|
||||||
|
main_df: pd.DataFrame,
|
||||||
|
indicator_configs: List['IndicatorLayerConfig'],
|
||||||
|
indicator_manager: 'IndicatorManager'
|
||||||
|
) -> Dict[str, pd.DataFrame]:
|
||||||
|
|
||||||
|
indicator_data_map = {}
|
||||||
|
if main_df.empty:
|
||||||
|
return indicator_data_map
|
||||||
|
|
||||||
|
for config in indicator_configs:
|
||||||
|
indicator_id = config.id
|
||||||
|
indicator = indicator_manager.load_indicator(indicator_id)
|
||||||
|
|
||||||
|
if not indicator:
|
||||||
|
logger.warning(f"Data Integrator: Could not load indicator with ID: {indicator_id}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# The new `calculate` method in TechnicalIndicators handles DataFrame input
|
||||||
|
indicator_result_pkg = self.indicators.calculate(
|
||||||
|
indicator.type,
|
||||||
|
main_df,
|
||||||
|
**indicator.parameters
|
||||||
|
)
|
||||||
|
|
||||||
|
if indicator_result_pkg and 'data' in indicator_result_pkg and indicator_result_pkg['data']:
|
||||||
|
# The result is a list of IndicatorResult objects. Convert to DataFrame.
|
||||||
|
indicator_results = indicator_result_pkg['data']
|
||||||
|
result_df = pd.DataFrame([
|
||||||
|
{'timestamp': r.timestamp, **r.values}
|
||||||
|
for r in indicator_results
|
||||||
|
])
|
||||||
|
indicator_data_map[indicator.id] = result_df
|
||||||
|
else:
|
||||||
|
self.logger.warning(f"No data returned for indicator '{indicator.name}'")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error calculating indicator '{indicator.name}': {e}", exc_info=True)
|
||||||
|
|
||||||
|
return indicator_data_map
|
||||||
|
|
||||||
|
|
||||||
# Convenience functions for common operations
|
# Convenience functions for common operations
|
||||||
def get_market_data_integrator(config: DataIntegrationConfig = None) -> MarketDataIntegrator:
|
def get_market_data_integrator(config: DataIntegrationConfig = None) -> MarketDataIntegrator:
|
||||||
|
|||||||
@ -28,6 +28,7 @@ logger = get_logger("default_logger")
|
|||||||
@dataclass
|
@dataclass
|
||||||
class IndicatorLayerConfig(LayerConfig):
|
class IndicatorLayerConfig(LayerConfig):
|
||||||
"""Extended configuration for indicator layers"""
|
"""Extended configuration for indicator layers"""
|
||||||
|
id: str = ""
|
||||||
indicator_type: str = "" # e.g., 'sma', 'ema', 'rsi'
|
indicator_type: str = "" # e.g., 'sma', 'ema', 'rsi'
|
||||||
parameters: Dict[str, Any] = None # Indicator-specific parameters
|
parameters: Dict[str, Any] = None # Indicator-specific parameters
|
||||||
line_width: int = 2
|
line_width: int = 2
|
||||||
|
|||||||
@ -1,19 +1,19 @@
|
|||||||
{
|
{
|
||||||
"id": "ema_ca5fd53d",
|
"id": "ema_ca5fd53d",
|
||||||
"name": "EMA 10",
|
"name": "EMA 12",
|
||||||
"description": "12-period Exponential Moving Average for fast signals",
|
"description": "12-period Exponential Moving Average for fast signals",
|
||||||
"type": "ema",
|
"type": "ema",
|
||||||
"display_type": "overlay",
|
"display_type": "overlay",
|
||||||
"parameters": {
|
"parameters": {
|
||||||
"period": 10
|
"period": 12
|
||||||
},
|
},
|
||||||
"styling": {
|
"styling": {
|
||||||
"color": "#ff6b35",
|
"color": "#8880ff",
|
||||||
"line_width": 2,
|
"line_width": 2,
|
||||||
"opacity": 1.0,
|
"opacity": 1.0,
|
||||||
"line_style": "solid"
|
"line_style": "solid"
|
||||||
},
|
},
|
||||||
"visible": true,
|
"visible": true,
|
||||||
"created_date": "2025-06-04T04:16:35.455729+00:00",
|
"created_date": "2025-06-04T04:16:35.455729+00:00",
|
||||||
"modified_date": "2025-06-04T04:54:49.608549+00:00"
|
"modified_date": "2025-06-06T04:14:33.123102+00:00"
|
||||||
}
|
}
|
||||||
@ -2,19 +2,21 @@
|
|||||||
Chart-related callbacks for the dashboard.
|
Chart-related callbacks for the dashboard.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dash import Output, Input, State, Patch, ctx, html, no_update
|
from dash import Output, Input, State, Patch, ctx, html, no_update, dcc
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from utils.logger import get_logger
|
from utils.logger import get_logger
|
||||||
from components.charts import (
|
from components.charts import (
|
||||||
create_strategy_chart,
|
create_strategy_chart,
|
||||||
create_chart_with_indicators,
|
create_chart_with_indicators,
|
||||||
create_error_chart,
|
create_error_chart,
|
||||||
get_market_statistics
|
|
||||||
)
|
)
|
||||||
|
from dashboard.components.data_analysis import get_market_statistics
|
||||||
from components.charts.config import get_all_example_strategies
|
from components.charts.config import get_all_example_strategies
|
||||||
from database.connection import DatabaseManager
|
from database.connection import DatabaseManager
|
||||||
from components.charts.builder import ChartBuilder
|
from components.charts.builder import ChartBuilder
|
||||||
from components.charts.utils import prepare_chart_data
|
from components.charts.utils import prepare_chart_data
|
||||||
|
import pandas as pd
|
||||||
|
import io
|
||||||
|
|
||||||
logger = get_logger("default_logger")
|
logger = get_logger("default_logger")
|
||||||
|
|
||||||
@ -22,53 +24,37 @@ logger = get_logger("default_logger")
|
|||||||
def calculate_time_range(time_range_quick, custom_start_date, custom_end_date, analysis_mode, n_intervals):
|
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."""
|
"""Calculate days_back and status message based on time range controls."""
|
||||||
try:
|
try:
|
||||||
# Define predefined quick select options (excluding 'custom' and 'realtime')
|
|
||||||
predefined_ranges = ['1h', '4h', '6h', '12h', '1d', '3d', '7d', '30d']
|
predefined_ranges = ['1h', '4h', '6h', '12h', '1d', '3d', '7d', '30d']
|
||||||
|
|
||||||
# PRIORITY 1: Explicit Predefined Dropdown Selection
|
|
||||||
if time_range_quick in predefined_ranges:
|
if time_range_quick in predefined_ranges:
|
||||||
time_map = {
|
time_map = {
|
||||||
'1h': (1/24, '🕐 Last 1 Hour'),
|
'1h': (1/24, '🕐 Last 1 Hour'), '4h': (4/24, '🕐 Last 4 Hours'), '6h': (6/24, '🕐 Last 6 Hours'),
|
||||||
'4h': (4/24, '🕐 Last 4 Hours'),
|
'12h': (12/24, '🕐 Last 12 Hours'), '1d': (1, '📅 Last 1 Day'), '3d': (3, '📅 Last 3 Days'),
|
||||||
'6h': (6/24, '🕐 Last 6 Hours'),
|
'7d': (7, '📅 Last 7 Days'), '30d': (30, '📅 Last 30 Days')
|
||||||
'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]
|
days_back_fractional, label = time_map[time_range_quick]
|
||||||
mode_text = "🔒 Locked" if analysis_mode == 'locked' else "🔴 Live"
|
mode_text = "🔒 Locked" if analysis_mode == 'locked' else "🔴 Live"
|
||||||
status = f"{label} | {mode_text}"
|
status = f"{label} | {mode_text}"
|
||||||
days_back = days_back_fractional if days_back_fractional < 1 else int(days_back_fractional)
|
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
|
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:
|
if time_range_quick == 'custom' and custom_start_date and custom_end_date:
|
||||||
start_date = datetime.fromisoformat(custom_start_date.split('T')[0])
|
start_date = datetime.fromisoformat(custom_start_date.split('T')[0])
|
||||||
end_date = datetime.fromisoformat(custom_end_date.split('T')[0])
|
end_date = datetime.fromisoformat(custom_end_date.split('T')[0])
|
||||||
days_diff = (end_date - start_date).days
|
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)"
|
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
|
return max(1, days_diff), status
|
||||||
|
|
||||||
# PRIORITY 3: Real-time (uses default lookback, typically 7 days for context)
|
|
||||||
if time_range_quick == 'realtime':
|
if time_range_quick == 'realtime':
|
||||||
mode_text = "🔒 Analysis Mode" if analysis_mode == 'locked' else "🔴 Real-time Updates"
|
mode_text = "🔒 Analysis Mode" if analysis_mode == 'locked' else "🔴 Real-time Updates"
|
||||||
status = f"📈 Real-time Mode | {mode_text} (Default: Last 7 Days)"
|
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
|
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"
|
mode_text = "🔒 Analysis Mode" if analysis_mode == 'locked' else "🔴 Live"
|
||||||
default_label = "📅 Default (Last 7 Days)"
|
default_label = "📅 Default (Last 7 Days)"
|
||||||
if time_range_quick == 'custom' and not (custom_start_date and custom_end_date):
|
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
|
default_label = "⏳ Select Custom Dates"
|
||||||
|
|
||||||
status = f"{default_label} | {mode_text}"
|
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
|
return 7, status
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -81,7 +67,8 @@ def register_chart_callbacks(app):
|
|||||||
|
|
||||||
@app.callback(
|
@app.callback(
|
||||||
[Output('price-chart', 'figure'),
|
[Output('price-chart', 'figure'),
|
||||||
Output('time-range-status', 'children')],
|
Output('time-range-status', 'children'),
|
||||||
|
Output('chart-data-store', 'data')],
|
||||||
[Input('symbol-dropdown', 'value'),
|
[Input('symbol-dropdown', 'value'),
|
||||||
Input('timeframe-dropdown', 'value'),
|
Input('timeframe-dropdown', 'value'),
|
||||||
Input('overlay-indicators-checklist', 'value'),
|
Input('overlay-indicators-checklist', 'value'),
|
||||||
@ -98,120 +85,38 @@ def register_chart_callbacks(app):
|
|||||||
def update_price_chart(symbol, timeframe, overlay_indicators, subplot_indicators, selected_strategy,
|
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,
|
time_range_quick, custom_start_date, custom_end_date, analysis_mode, n_intervals,
|
||||||
relayout_data, current_figure):
|
relayout_data, current_figure):
|
||||||
"""Update the price chart with latest market data and selected indicators."""
|
|
||||||
try:
|
try:
|
||||||
triggered_id = ctx.triggered_id
|
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':
|
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, no_update
|
||||||
return no_update, no_update
|
|
||||||
|
|
||||||
days_back, status_message = calculate_time_range(
|
days_back, status_message = calculate_time_range(
|
||||||
time_range_quick, custom_start_date, custom_end_date, analysis_mode, n_intervals
|
time_range_quick, custom_start_date, custom_end_date, analysis_mode, n_intervals
|
||||||
)
|
)
|
||||||
|
|
||||||
# Condition for attempting to use Patch()
|
chart_df = pd.DataFrame()
|
||||||
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':
|
if selected_strategy and selected_strategy != 'basic':
|
||||||
fig = create_strategy_chart(symbol, timeframe, selected_strategy, days_back=days_back)
|
fig, chart_df = 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:
|
else:
|
||||||
fig = create_chart_with_indicators(
|
fig, chart_df = create_chart_with_indicators(
|
||||||
symbol=symbol,
|
symbol=symbol, timeframe=timeframe,
|
||||||
timeframe=timeframe,
|
overlay_indicators=overlay_indicators or [], subplot_indicators=subplot_indicators or [],
|
||||||
overlay_indicators=overlay_indicators or [],
|
|
||||||
subplot_indicators=subplot_indicators or [],
|
|
||||||
days_back=days_back
|
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}")
|
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:
|
if relayout_data and 'xaxis.range' in relayout_data:
|
||||||
fig.update_layout(
|
fig.update_layout(xaxis=dict(range=relayout_data['xaxis.range']), yaxis=dict(range=relayout_data.get('yaxis.range')))
|
||||||
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
|
return fig, status_message, stored_data
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating price chart: {e}")
|
logger.error(f"Error updating price chart: {e}", exc_info=True)
|
||||||
error_fig = create_error_chart(f"Error loading chart: {str(e)}")
|
error_fig = create_error_chart(f"Error loading chart: {str(e)}")
|
||||||
error_status = f"❌ Error: {str(e)}"
|
return error_fig, f"❌ Error: {str(e)}", None
|
||||||
return error_fig, error_status
|
|
||||||
|
|
||||||
@app.callback(
|
@app.callback(
|
||||||
Output('analysis-mode-toggle', 'value'),
|
Output('analysis-mode-toggle', 'value'),
|
||||||
@ -220,212 +125,79 @@ def register_chart_callbacks(app):
|
|||||||
prevent_initial_call=True
|
prevent_initial_call=True
|
||||||
)
|
)
|
||||||
def auto_lock_chart_on_interaction(relayout_data, current_mode):
|
def auto_lock_chart_on_interaction(relayout_data, current_mode):
|
||||||
"""Automatically switch to 'locked' mode when the user zooms or pans."""
|
if relayout_data and 'xaxis.range' in relayout_data and current_mode != 'locked':
|
||||||
# relayout_data is triggered by zoom/pan actions.
|
return 'locked'
|
||||||
if relayout_data and 'xaxis.range' in relayout_data:
|
return no_update
|
||||||
if current_mode != 'locked':
|
|
||||||
logger.debug("User chart interaction detected (zoom/pan). Switching to 'locked' analysis mode.")
|
@app.callback(
|
||||||
return 'locked'
|
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):
|
||||||
|
if not stored_data:
|
||||||
|
return html.Div("Statistics will be available once chart data is loaded.")
|
||||||
|
try:
|
||||||
|
df = pd.read_json(io.StringIO(stored_data), orient='split')
|
||||||
|
if df.empty:
|
||||||
|
return html.Div("Not enough data to calculate statistics.")
|
||||||
|
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 html.Div(f"Error loading statistics: {e}", style={'color': 'red'})
|
||||||
|
|
||||||
|
@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):
|
||||||
|
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
|
return no_update
|
||||||
|
|
||||||
# Strategy selection callback - automatically load strategy indicators
|
|
||||||
@app.callback(
|
@app.callback(
|
||||||
[Output('overlay-indicators-checklist', 'value'),
|
[Output('overlay-indicators-checklist', 'value'),
|
||||||
Output('subplot-indicators-checklist', 'value')],
|
Output('subplot-indicators-checklist', 'value')],
|
||||||
[Input('strategy-dropdown', 'value')]
|
[Input('strategy-dropdown', 'value')]
|
||||||
)
|
)
|
||||||
def update_indicators_from_strategy(selected_strategy):
|
def update_indicators_from_strategy(selected_strategy):
|
||||||
"""Update indicator selections when a strategy is chosen."""
|
|
||||||
if not selected_strategy or selected_strategy == 'basic':
|
if not selected_strategy or selected_strategy == 'basic':
|
||||||
return [], []
|
return [], []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get strategy configuration
|
|
||||||
all_strategies = get_all_example_strategies()
|
all_strategies = get_all_example_strategies()
|
||||||
if selected_strategy in all_strategies:
|
if selected_strategy in all_strategies:
|
||||||
strategy_example = all_strategies[selected_strategy]
|
strategy_example = all_strategies[selected_strategy]
|
||||||
config = strategy_example.config
|
config = strategy_example.config
|
||||||
|
|
||||||
# Extract overlay and subplot indicators from strategy
|
|
||||||
overlay_indicators = config.overlay_indicators or []
|
overlay_indicators = config.overlay_indicators or []
|
||||||
|
|
||||||
# Extract subplot indicators from subplot configs
|
|
||||||
subplot_indicators = []
|
subplot_indicators = []
|
||||||
for subplot_config in config.subplot_configs or []:
|
for subplot_config in config.subplot_configs or []:
|
||||||
subplot_indicators.extend(subplot_config.indicators 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
|
return overlay_indicators, subplot_indicators
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Chart callback: Strategy {selected_strategy} not found")
|
|
||||||
return [], []
|
return [], []
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Chart callback: Error loading strategy indicators: {e}")
|
logger.error(f"Error loading strategy indicators: {e}", exc_info=True)
|
||||||
return [], []
|
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(
|
@app.callback(
|
||||||
[Output('custom-date-range', 'start_date'),
|
[Output('custom-date-range', 'start_date'),
|
||||||
Output('custom-date-range', 'end_date'),
|
Output('custom-date-range', 'end_date'),
|
||||||
@ -434,13 +206,8 @@ def create_price_movement_section(price_stats, days_back=7):
|
|||||||
prevent_initial_call=True
|
prevent_initial_call=True
|
||||||
)
|
)
|
||||||
def clear_custom_date_range(n_clicks):
|
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:
|
if n_clicks and n_clicks > 0:
|
||||||
logger.debug("Clear button clicked: Clearing custom dates and setting dropdown to 7d.")
|
return None, None, '7d'
|
||||||
return None, None, '7d' # Clear dates AND set dropdown to default '7d'
|
return no_update, no_update, no_update
|
||||||
# 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")
|
logger.info("Chart callback: Chart callbacks registered successfully")
|
||||||
@ -182,3 +182,44 @@ def create_time_range_controls():
|
|||||||
'background-color': '#f0f8ff',
|
'background-color': '#f0f8ff',
|
||||||
'margin-bottom': '20px'
|
'margin-bottom': '20px'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def create_export_controls():
|
||||||
|
"""Create the data export control panel."""
|
||||||
|
return html.Div([
|
||||||
|
html.H5("💾 Data Export", style={'color': '#2c3e50', 'margin-bottom': '15px'}),
|
||||||
|
html.Button(
|
||||||
|
"Export to CSV",
|
||||||
|
id="export-csv-btn",
|
||||||
|
className="btn btn-primary",
|
||||||
|
style={
|
||||||
|
'background-color': '#28a745',
|
||||||
|
'color': 'white',
|
||||||
|
'border': 'none',
|
||||||
|
'padding': '8px 16px',
|
||||||
|
'border-radius': '4px',
|
||||||
|
'cursor': 'pointer',
|
||||||
|
'margin-right': '10px'
|
||||||
|
}
|
||||||
|
),
|
||||||
|
html.Button(
|
||||||
|
"Export to JSON",
|
||||||
|
id="export-json-btn",
|
||||||
|
className="btn btn-primary",
|
||||||
|
style={
|
||||||
|
'background-color': '#17a2b8',
|
||||||
|
'color': 'white',
|
||||||
|
'border': 'none',
|
||||||
|
'padding': '8px 16px',
|
||||||
|
'border-radius': '4px',
|
||||||
|
'cursor': 'pointer'
|
||||||
|
}
|
||||||
|
),
|
||||||
|
dcc.Download(id="download-chart-data")
|
||||||
|
], style={
|
||||||
|
'border': '1px solid #bdc3c7',
|
||||||
|
'border-radius': '8px',
|
||||||
|
'padding': '15px',
|
||||||
|
'background-color': '#f8f9fa',
|
||||||
|
'margin-bottom': '20px'
|
||||||
|
})
|
||||||
@ -26,89 +26,54 @@ class VolumeAnalyzer:
|
|||||||
self.db_manager = DatabaseManager()
|
self.db_manager = DatabaseManager()
|
||||||
self.db_manager.initialize()
|
self.db_manager.initialize()
|
||||||
|
|
||||||
def get_volume_statistics(self, symbol: str, timeframe: str = "1h", days_back: int = 7) -> Dict[str, Any]:
|
def get_volume_statistics(self, df: pd.DataFrame) -> Dict[str, Any]:
|
||||||
"""Calculate comprehensive volume statistics."""
|
"""Calculate comprehensive volume statistics from a DataFrame."""
|
||||||
try:
|
try:
|
||||||
# Fetch recent market data
|
if df.empty or 'volume' not in df.columns:
|
||||||
end_time = datetime.now(timezone.utc)
|
return {'error': 'DataFrame is empty or missing volume column'}
|
||||||
start_time = end_time - timedelta(days=days_back)
|
|
||||||
|
|
||||||
with self.db_manager.get_session() as session:
|
# Calculate volume statistics
|
||||||
from sqlalchemy import text
|
total_volume = df['volume'].sum()
|
||||||
|
avg_volume = df['volume'].mean()
|
||||||
|
volume_std = df['volume'].std()
|
||||||
|
|
||||||
query = text("""
|
# Volume trend analysis
|
||||||
SELECT timestamp, open, high, low, close, volume, trades_count
|
recent_volume = df['volume'].tail(10).mean() # Last 10 periods
|
||||||
FROM market_data
|
older_volume = df['volume'].head(10).mean() # First 10 periods
|
||||||
WHERE symbol = :symbol
|
volume_trend = "Increasing" if recent_volume > older_volume else "Decreasing"
|
||||||
AND timeframe = :timeframe
|
|
||||||
AND timestamp >= :start_time
|
|
||||||
AND timestamp <= :end_time
|
|
||||||
ORDER BY timestamp ASC
|
|
||||||
""")
|
|
||||||
|
|
||||||
result = session.execute(query, {
|
# High volume periods (above 2 standard deviations)
|
||||||
'symbol': symbol,
|
high_volume_threshold = avg_volume + (2 * volume_std)
|
||||||
'timeframe': timeframe,
|
high_volume_periods = len(df[df['volume'] > high_volume_threshold])
|
||||||
'start_time': start_time,
|
|
||||||
'end_time': end_time
|
|
||||||
})
|
|
||||||
|
|
||||||
candles = []
|
# Volume-Price correlation
|
||||||
for row in result:
|
price_change = df['close'] - df['open']
|
||||||
candles.append({
|
volume_price_corr = df['volume'].corr(price_change.abs())
|
||||||
'timestamp': row.timestamp,
|
|
||||||
'open': float(row.open),
|
|
||||||
'high': float(row.high),
|
|
||||||
'low': float(row.low),
|
|
||||||
'close': float(row.close),
|
|
||||||
'volume': float(row.volume),
|
|
||||||
'trades_count': int(row.trades_count) if row.trades_count else 0
|
|
||||||
})
|
|
||||||
|
|
||||||
if not candles:
|
# Average trade size (volume per trade)
|
||||||
return {'error': 'No data available'}
|
if 'trades_count' in df.columns:
|
||||||
|
|
||||||
df = pd.DataFrame(candles)
|
|
||||||
|
|
||||||
# Calculate volume statistics
|
|
||||||
total_volume = df['volume'].sum()
|
|
||||||
avg_volume = df['volume'].mean()
|
|
||||||
volume_std = df['volume'].std()
|
|
||||||
|
|
||||||
# Volume trend analysis
|
|
||||||
recent_volume = df['volume'].tail(10).mean() # Last 10 periods
|
|
||||||
older_volume = df['volume'].head(10).mean() # First 10 periods
|
|
||||||
volume_trend = "Increasing" if recent_volume > older_volume else "Decreasing"
|
|
||||||
|
|
||||||
# High volume periods (above 2 standard deviations)
|
|
||||||
high_volume_threshold = avg_volume + (2 * volume_std)
|
|
||||||
high_volume_periods = len(df[df['volume'] > high_volume_threshold])
|
|
||||||
|
|
||||||
# Volume-Price correlation
|
|
||||||
price_change = df['close'] - df['open']
|
|
||||||
volume_price_corr = df['volume'].corr(price_change.abs())
|
|
||||||
|
|
||||||
# Average trade size (volume per trade)
|
|
||||||
df['avg_trade_size'] = df['volume'] / df['trades_count'].replace(0, 1)
|
df['avg_trade_size'] = df['volume'] / df['trades_count'].replace(0, 1)
|
||||||
avg_trade_size = df['avg_trade_size'].mean()
|
avg_trade_size = df['avg_trade_size'].mean()
|
||||||
|
else:
|
||||||
|
avg_trade_size = None # Not available
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'total_volume': total_volume,
|
'total_volume': total_volume,
|
||||||
'avg_volume': avg_volume,
|
'avg_volume': avg_volume,
|
||||||
'volume_std': volume_std,
|
'volume_std': volume_std,
|
||||||
'volume_trend': volume_trend,
|
'volume_trend': volume_trend,
|
||||||
'high_volume_periods': high_volume_periods,
|
'high_volume_periods': high_volume_periods,
|
||||||
'volume_price_correlation': volume_price_corr,
|
'volume_price_correlation': volume_price_corr,
|
||||||
'avg_trade_size': avg_trade_size,
|
'avg_trade_size': avg_trade_size,
|
||||||
'max_volume': df['volume'].max(),
|
'max_volume': df['volume'].max(),
|
||||||
'min_volume': df['volume'].min(),
|
'min_volume': df['volume'].min(),
|
||||||
'volume_percentiles': {
|
'volume_percentiles': {
|
||||||
'25th': df['volume'].quantile(0.25),
|
'25th': df['volume'].quantile(0.25),
|
||||||
'50th': df['volume'].quantile(0.50),
|
'50th': df['volume'].quantile(0.50),
|
||||||
'75th': df['volume'].quantile(0.75),
|
'75th': df['volume'].quantile(0.75),
|
||||||
'95th': df['volume'].quantile(0.95)
|
'95th': df['volume'].quantile(0.95)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Volume analysis error: {e}")
|
logger.error(f"Volume analysis error: {e}")
|
||||||
@ -122,120 +87,83 @@ class PriceMovementAnalyzer:
|
|||||||
self.db_manager = DatabaseManager()
|
self.db_manager = DatabaseManager()
|
||||||
self.db_manager.initialize()
|
self.db_manager.initialize()
|
||||||
|
|
||||||
def get_price_movement_statistics(self, symbol: str, timeframe: str = "1h", days_back: int = 7) -> Dict[str, Any]:
|
def get_price_movement_statistics(self, df: pd.DataFrame) -> Dict[str, Any]:
|
||||||
"""Calculate comprehensive price movement statistics."""
|
"""Calculate comprehensive price movement statistics from a DataFrame."""
|
||||||
try:
|
try:
|
||||||
# Fetch recent market data
|
if df.empty or not all(col in df.columns for col in ['open', 'high', 'low', 'close']):
|
||||||
end_time = datetime.now(timezone.utc)
|
return {'error': 'DataFrame is empty or missing required price columns'}
|
||||||
start_time = end_time - timedelta(days=days_back)
|
|
||||||
|
|
||||||
with self.db_manager.get_session() as session:
|
# Basic price statistics
|
||||||
from sqlalchemy import text
|
current_price = df['close'].iloc[-1]
|
||||||
|
period_start_price = df['open'].iloc[0]
|
||||||
|
period_return = ((current_price - period_start_price) / period_start_price) * 100
|
||||||
|
|
||||||
query = text("""
|
# Daily returns (percentage changes)
|
||||||
SELECT timestamp, open, high, low, close, volume
|
df['returns'] = df['close'].pct_change() * 100
|
||||||
FROM market_data
|
df['returns'] = df['returns'].fillna(0)
|
||||||
WHERE symbol = :symbol
|
|
||||||
AND timeframe = :timeframe
|
|
||||||
AND timestamp >= :start_time
|
|
||||||
AND timestamp <= :end_time
|
|
||||||
ORDER BY timestamp ASC
|
|
||||||
""")
|
|
||||||
|
|
||||||
result = session.execute(query, {
|
# Volatility metrics
|
||||||
'symbol': symbol,
|
volatility = df['returns'].std()
|
||||||
'timeframe': timeframe,
|
avg_return = df['returns'].mean()
|
||||||
'start_time': start_time,
|
|
||||||
'end_time': end_time
|
|
||||||
})
|
|
||||||
|
|
||||||
candles = []
|
# Price range analysis
|
||||||
for row in result:
|
df['range'] = df['high'] - df['low']
|
||||||
candles.append({
|
df['range_pct'] = (df['range'] / df['open']) * 100
|
||||||
'timestamp': row.timestamp,
|
avg_range_pct = df['range_pct'].mean()
|
||||||
'open': float(row.open),
|
|
||||||
'high': float(row.high),
|
|
||||||
'low': float(row.low),
|
|
||||||
'close': float(row.close),
|
|
||||||
'volume': float(row.volume)
|
|
||||||
})
|
|
||||||
|
|
||||||
if not candles:
|
# Directional analysis
|
||||||
return {'error': 'No data available'}
|
bullish_periods = len(df[df['close'] > df['open']])
|
||||||
|
bearish_periods = len(df[df['close'] < df['open']])
|
||||||
|
neutral_periods = len(df[df['close'] == df['open']])
|
||||||
|
|
||||||
df = pd.DataFrame(candles)
|
total_periods = len(df)
|
||||||
|
bullish_ratio = (bullish_periods / total_periods) * 100 if total_periods > 0 else 0
|
||||||
|
|
||||||
# Basic price statistics
|
# Price extremes
|
||||||
current_price = df['close'].iloc[-1]
|
period_high = df['high'].max()
|
||||||
period_start_price = df['open'].iloc[0]
|
period_low = df['low'].min()
|
||||||
period_return = ((current_price - period_start_price) / period_start_price) * 100
|
|
||||||
|
|
||||||
# Daily returns (percentage changes)
|
# Momentum indicators
|
||||||
df['returns'] = df['close'].pct_change() * 100
|
# Simple momentum (current vs N periods ago)
|
||||||
df['returns'] = df['returns'].fillna(0)
|
momentum_periods = min(10, len(df) - 1)
|
||||||
|
if momentum_periods > 0:
|
||||||
|
momentum = ((current_price - df['close'].iloc[-momentum_periods-1]) / df['close'].iloc[-momentum_periods-1]) * 100
|
||||||
|
else:
|
||||||
|
momentum = 0
|
||||||
|
|
||||||
# Volatility metrics
|
# Trend strength (linear regression slope)
|
||||||
volatility = df['returns'].std()
|
if len(df) > 2:
|
||||||
avg_return = df['returns'].mean()
|
x = np.arange(len(df))
|
||||||
|
slope, _ = np.polyfit(x, df['close'], 1)
|
||||||
|
trend_strength = slope / df['close'].mean() * 100 # Normalize by average price
|
||||||
|
else:
|
||||||
|
trend_strength = 0
|
||||||
|
|
||||||
# Price range analysis
|
return {
|
||||||
df['range'] = df['high'] - df['low']
|
'current_price': current_price,
|
||||||
df['range_pct'] = (df['range'] / df['open']) * 100
|
'period_return': period_return,
|
||||||
avg_range_pct = df['range_pct'].mean()
|
'volatility': volatility,
|
||||||
|
'avg_return': avg_return,
|
||||||
# Directional analysis
|
'avg_range_pct': avg_range_pct,
|
||||||
bullish_periods = len(df[df['close'] > df['open']])
|
'bullish_periods': bullish_periods,
|
||||||
bearish_periods = len(df[df['close'] < df['open']])
|
'bearish_periods': bearish_periods,
|
||||||
neutral_periods = len(df[df['close'] == df['open']])
|
'neutral_periods': neutral_periods,
|
||||||
|
'bullish_ratio': bullish_ratio,
|
||||||
total_periods = len(df)
|
'period_high': period_high,
|
||||||
bullish_ratio = (bullish_periods / total_periods) * 100 if total_periods > 0 else 0
|
'period_low': period_low,
|
||||||
|
'momentum': momentum,
|
||||||
# Price extremes
|
'trend_strength': trend_strength,
|
||||||
period_high = df['high'].max()
|
'return_percentiles': {
|
||||||
period_low = df['low'].min()
|
'5th': df['returns'].quantile(0.05),
|
||||||
|
'25th': df['returns'].quantile(0.25),
|
||||||
# Momentum indicators
|
'75th': df['returns'].quantile(0.75),
|
||||||
# Simple momentum (current vs N periods ago)
|
'95th': df['returns'].quantile(0.95)
|
||||||
momentum_periods = min(10, len(df) - 1)
|
},
|
||||||
if momentum_periods > 0:
|
'max_gain': df['returns'].max(),
|
||||||
momentum = ((current_price - df['close'].iloc[-momentum_periods-1]) / df['close'].iloc[-momentum_periods-1]) * 100
|
'max_loss': df['returns'].min(),
|
||||||
else:
|
'positive_returns': len(df[df['returns'] > 0]),
|
||||||
momentum = 0
|
'negative_returns': len(df[df['returns'] < 0])
|
||||||
|
}
|
||||||
# Trend strength (linear regression slope)
|
|
||||||
if len(df) > 2:
|
|
||||||
x = np.arange(len(df))
|
|
||||||
slope, _ = np.polyfit(x, df['close'], 1)
|
|
||||||
trend_strength = slope / df['close'].mean() * 100 # Normalize by average price
|
|
||||||
else:
|
|
||||||
trend_strength = 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
'current_price': current_price,
|
|
||||||
'period_return': period_return,
|
|
||||||
'volatility': volatility,
|
|
||||||
'avg_return': avg_return,
|
|
||||||
'avg_range_pct': avg_range_pct,
|
|
||||||
'bullish_periods': bullish_periods,
|
|
||||||
'bearish_periods': bearish_periods,
|
|
||||||
'neutral_periods': neutral_periods,
|
|
||||||
'bullish_ratio': bullish_ratio,
|
|
||||||
'period_high': period_high,
|
|
||||||
'period_low': period_low,
|
|
||||||
'momentum': momentum,
|
|
||||||
'trend_strength': trend_strength,
|
|
||||||
'return_percentiles': {
|
|
||||||
'5th': df['returns'].quantile(0.05),
|
|
||||||
'25th': df['returns'].quantile(0.25),
|
|
||||||
'75th': df['returns'].quantile(0.75),
|
|
||||||
'95th': df['returns'].quantile(0.95)
|
|
||||||
},
|
|
||||||
'max_gain': df['returns'].max(),
|
|
||||||
'max_loss': df['returns'].min(),
|
|
||||||
'positive_returns': len(df[df['returns'] > 0]),
|
|
||||||
'negative_returns': len(df[df['returns'] < 0])
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Price movement analysis error: {e}")
|
logger.error(f"Price movement analysis error: {e}")
|
||||||
@ -719,3 +647,38 @@ def create_price_stats_display(stats: Dict[str, Any]) -> html.Div:
|
|||||||
], p="md", shadow="sm")
|
], p="md", shadow="sm")
|
||||||
|
|
||||||
], cols=3, spacing="md", style={'margin-top': '20px'})
|
], cols=3, spacing="md", style={'margin-top': '20px'})
|
||||||
|
|
||||||
|
|
||||||
|
def get_market_statistics(df: pd.DataFrame, symbol: str, timeframe: str) -> html.Div:
|
||||||
|
"""
|
||||||
|
Generate a comprehensive market statistics component from a DataFrame.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
volume_analyzer = VolumeAnalyzer()
|
||||||
|
price_analyzer = PriceMovementAnalyzer()
|
||||||
|
|
||||||
|
volume_stats = volume_analyzer.get_volume_statistics(df)
|
||||||
|
price_stats = price_analyzer.get_price_movement_statistics(df)
|
||||||
|
|
||||||
|
if 'error' in volume_stats or 'error' in price_stats:
|
||||||
|
error_msg = volume_stats.get('error') or price_stats.get('error')
|
||||||
|
return html.Div(f"Error generating statistics: {error_msg}", style={'color': 'red'})
|
||||||
|
|
||||||
|
# Time range for display
|
||||||
|
start_date = df['timestamp'].min().strftime('%Y-%m-%d %H:%M')
|
||||||
|
end_date = df['timestamp'].max().strftime('%Y-%m-%d %H:%M')
|
||||||
|
days_back = (df['timestamp'].max() - df['timestamp'].min()).days
|
||||||
|
time_status = f"📅 Analysis Range: {start_date} to {end_date} (~{days_back} days)"
|
||||||
|
|
||||||
|
return html.Div([
|
||||||
|
html.H3("📊 Enhanced Market Statistics"),
|
||||||
|
html.P(
|
||||||
|
time_status,
|
||||||
|
style={'font-weight': 'bold', 'margin-bottom': '15px', 'color': '#4A4A4A', 'text-align': 'center', 'font-size': '1.1em'}
|
||||||
|
),
|
||||||
|
create_price_stats_display(price_stats),
|
||||||
|
create_volume_stats_display(volume_stats)
|
||||||
|
])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in get_market_statistics: {e}", exc_info=True)
|
||||||
|
return html.Div(f"Error generating statistics display: {e}", style={'color': 'red'})
|
||||||
@ -10,7 +10,8 @@ from components.charts.indicator_manager import get_indicator_manager
|
|||||||
from components.charts.indicator_defaults import ensure_default_indicators
|
from components.charts.indicator_defaults import ensure_default_indicators
|
||||||
from dashboard.components.chart_controls import (
|
from dashboard.components.chart_controls import (
|
||||||
create_chart_config_panel,
|
create_chart_config_panel,
|
||||||
create_time_range_controls
|
create_time_range_controls,
|
||||||
|
create_export_controls
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = get_logger("default_logger")
|
logger = get_logger("default_logger")
|
||||||
@ -80,6 +81,7 @@ def get_market_data_layout():
|
|||||||
# Create components using the new modular functions
|
# Create components using the new modular functions
|
||||||
chart_config_panel = create_chart_config_panel(strategy_options, overlay_options, subplot_options)
|
chart_config_panel = create_chart_config_panel(strategy_options, overlay_options, subplot_options)
|
||||||
time_range_controls = create_time_range_controls()
|
time_range_controls = create_time_range_controls()
|
||||||
|
export_controls = create_export_controls()
|
||||||
|
|
||||||
return html.Div([
|
return html.Div([
|
||||||
# Title and basic controls
|
# Title and basic controls
|
||||||
@ -115,9 +117,15 @@ def get_market_data_layout():
|
|||||||
# Time Range Controls (positioned under indicators, next to chart)
|
# Time Range Controls (positioned under indicators, next to chart)
|
||||||
time_range_controls,
|
time_range_controls,
|
||||||
|
|
||||||
|
# Export Controls
|
||||||
|
export_controls,
|
||||||
|
|
||||||
# Chart
|
# Chart
|
||||||
dcc.Graph(id='price-chart'),
|
dcc.Graph(id='price-chart'),
|
||||||
|
|
||||||
|
# Hidden store for chart data
|
||||||
|
dcc.Store(id='chart-data-store'),
|
||||||
|
|
||||||
# Enhanced Market statistics with integrated data analysis
|
# Enhanced Market statistics with integrated data analysis
|
||||||
html.Div(id='market-stats', style={'margin-top': '20px'})
|
html.Div(id='market-stats', style={'margin-top': '20px'})
|
||||||
])
|
])
|
||||||
@ -415,6 +415,67 @@ class TechnicalIndicators:
|
|||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
def calculate(self, indicator_type: str, candles: Union[pd.DataFrame, List[OHLCVCandle]], **kwargs) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Generic method to calculate any supported indicator by type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
indicator_type: The type of indicator to calculate (e.g., 'sma', 'ema').
|
||||||
|
candles: The input data, either a DataFrame or a list of OHLCVCandle objects.
|
||||||
|
**kwargs: Keyword arguments for the specific indicator function.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dictionary containing the indicator results, or None if the type is unknown.
|
||||||
|
"""
|
||||||
|
# If input is a DataFrame, convert it to list of OHLCVCandle objects.
|
||||||
|
# This is a temporary adaptation to the existing methods.
|
||||||
|
# Future optimization should standardize on DataFrames.
|
||||||
|
if isinstance(candles, pd.DataFrame):
|
||||||
|
from .data_types import OHLCVCandle
|
||||||
|
|
||||||
|
# Ensure required columns are present
|
||||||
|
required_cols = {'open', 'high', 'low', 'close', 'volume'}
|
||||||
|
if not required_cols.issubset(candles.columns):
|
||||||
|
if self.logger:
|
||||||
|
self.logger.error("Indicators: DataFrame missing required columns for OHLCVCandle conversion.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
symbol = kwargs.get('symbol', 'UNKNOWN')
|
||||||
|
timeframe = kwargs.get('timeframe', 'UNKNOWN')
|
||||||
|
|
||||||
|
candles_list = [
|
||||||
|
OHLCVCandle(
|
||||||
|
symbol=symbol,
|
||||||
|
timeframe=timeframe,
|
||||||
|
start_time=row['timestamp'],
|
||||||
|
end_time=row['timestamp'],
|
||||||
|
open=Decimal(str(row['open'])),
|
||||||
|
high=Decimal(str(row['high'])),
|
||||||
|
low=Decimal(str(row['low'])),
|
||||||
|
close=Decimal(str(row['close'])),
|
||||||
|
volume=Decimal(str(row['volume'])),
|
||||||
|
trade_count=int(row.get('trade_count', 0))
|
||||||
|
) for _, row in candles.iterrows()
|
||||||
|
]
|
||||||
|
candles = candles_list
|
||||||
|
|
||||||
|
indicator_method = getattr(self, indicator_type, None)
|
||||||
|
if indicator_method and callable(indicator_method):
|
||||||
|
# We need to construct a proper IndicatorResult object here
|
||||||
|
# For now, let's adapt to what the methods return
|
||||||
|
raw_result = indicator_method(candles, **kwargs)
|
||||||
|
|
||||||
|
# The methods return List[IndicatorResult], let's package that
|
||||||
|
if raw_result:
|
||||||
|
return {
|
||||||
|
"data": raw_result
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.logger:
|
||||||
|
self.logger.warning(f"TechnicalIndicators: Unknown indicator type '{indicator_type}'")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def create_default_indicators_config() -> Dict[str, Dict[str, Any]]:
|
def create_default_indicators_config() -> Dict[str, Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -86,8 +86,8 @@
|
|||||||
- [x] 3.5 Create market data monitoring dashboard (real-time data feed status)
|
- [x] 3.5 Create market data monitoring dashboard (real-time data feed status)
|
||||||
- [x] 3.6 Build simple data analysis tools (volume analysis, price movement statistics)
|
- [x] 3.6 Build simple data analysis tools (volume analysis, price movement statistics)
|
||||||
- [x] 3.7 Add the chart time range selector and trigger for realtime data or historical data (when i analyze specified time range i do not want it to reset with realtime data triggers and callbacks)
|
- [x] 3.7 Add the chart time range selector and trigger for realtime data or historical data (when i analyze specified time range i do not want it to reset with realtime data triggers and callbacks)
|
||||||
- [ ] 3.8 Setup real-time dashboard updates using Redis callbacks
|
- [-] 3.8 Setup real-time dashboard updates using Redis callbacks (DEFERRED: Redis is not used for real-time dashboard updates now)
|
||||||
- [ ] 3.9 Add data export functionality for analysis (CSV/JSON export)
|
- [x] 3.9 Add data export functionality for analysis (CSV/JSON export)
|
||||||
- [ ] 3.10 Unit test basic dashboard components and data visualization
|
- [ ] 3.10 Unit test basic dashboard components and data visualization
|
||||||
|
|
||||||
- [ ] 4.0 Strategy Engine and Bot Management Framework
|
- [ ] 4.0 Strategy Engine and Bot Management Framework
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user