- Enhanced the `UserIndicator` class to include an optional `timeframe` attribute for custom indicator timeframes. - Updated the `get_indicator_data` method in `MarketDataIntegrator` to fetch and calculate indicators based on the specified timeframe, ensuring proper data alignment and handling. - Modified the `ChartBuilder` to pass the correct DataFrame for plotting indicators with different timeframes. - Added UI elements in the indicator modal for selecting timeframes, improving user experience. - Updated relevant JSON templates to include the new `timeframe` field for all indicators. - Refactored the `prepare_chart_data` function to ensure it returns a DataFrame with a `DatetimeIndex` for consistent calculations. This commit enhances the flexibility and usability of the indicator system, allowing users to analyze data across various timeframes.
582 lines
24 KiB
Python
582 lines
24 KiB
Python
"""
|
|
ChartBuilder - Main orchestrator for chart creation
|
|
|
|
This module contains the ChartBuilder class which serves as the main entry point
|
|
for creating charts with various configurations, indicators, and layers.
|
|
"""
|
|
|
|
import plotly.graph_objects as go
|
|
from plotly.subplots import make_subplots
|
|
import pandas as pd
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import List, Dict, Any, Optional, Union
|
|
from decimal import Decimal
|
|
|
|
from database.operations import get_database_operations, DatabaseOperationError
|
|
from utils.logger import get_logger
|
|
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
|
|
logger = get_logger("default_logger")
|
|
|
|
|
|
class ChartBuilder:
|
|
"""
|
|
Main chart builder class for creating modular, configurable charts.
|
|
|
|
This class orchestrates the creation of charts by coordinating between
|
|
data fetching, layer rendering, and configuration management.
|
|
"""
|
|
|
|
def __init__(self, logger_instance: Optional = None):
|
|
"""
|
|
Initialize the ChartBuilder.
|
|
|
|
Args:
|
|
logger_instance: Optional logger instance
|
|
"""
|
|
self.logger = logger_instance or logger
|
|
self.db_ops = get_database_operations(self.logger)
|
|
|
|
# Initialize market data integrator
|
|
from .data_integration import get_market_data_integrator
|
|
self.data_integrator = get_market_data_integrator()
|
|
|
|
# Chart styling defaults
|
|
self.default_colors = get_indicator_colors()
|
|
self.default_height = 600
|
|
self.default_template = "plotly_white"
|
|
|
|
def fetch_market_data(self, symbol: str, timeframe: str,
|
|
days_back: int = 7, exchange: str = "okx") -> List[Dict[str, Any]]:
|
|
"""
|
|
Fetch market data from the database.
|
|
|
|
Args:
|
|
symbol: Trading pair (e.g., 'BTC-USDT')
|
|
timeframe: Timeframe (e.g., '1h', '1d')
|
|
days_back: Number of days to look back
|
|
exchange: Exchange name
|
|
|
|
Returns:
|
|
List of candle data dictionaries
|
|
"""
|
|
try:
|
|
# Calculate time range
|
|
end_time = datetime.now(timezone.utc)
|
|
start_time = end_time - timedelta(days=days_back)
|
|
|
|
# Fetch candles using the database operations API
|
|
candles = self.db_ops.market_data.get_candles(
|
|
symbol=symbol,
|
|
timeframe=timeframe,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
exchange=exchange
|
|
)
|
|
|
|
self.logger.debug(f"Chart builder: Fetched {len(candles)} candles for {symbol} {timeframe}")
|
|
return candles
|
|
|
|
except DatabaseOperationError as e:
|
|
self.logger.error(f"Chart builder: Database error fetching market data: {e}")
|
|
return []
|
|
except Exception as e:
|
|
self.logger.error(f"Chart builder: Unexpected error fetching market data: {e}")
|
|
return []
|
|
|
|
def fetch_market_data_enhanced(self, symbol: str, timeframe: str,
|
|
days_back: int = 7, exchange: str = "okx") -> List[Dict[str, Any]]:
|
|
"""
|
|
Enhanced market data fetching with validation and caching.
|
|
|
|
Args:
|
|
symbol: Trading pair (e.g., 'BTC-USDT')
|
|
timeframe: Timeframe (e.g., '1h', '1d')
|
|
days_back: Number of days to look back
|
|
exchange: Exchange name
|
|
|
|
Returns:
|
|
List of validated candle data dictionaries
|
|
"""
|
|
try:
|
|
# Use the data integrator for enhanced data handling
|
|
raw_candles, ohlcv_candles = self.data_integrator.get_market_data_for_indicators(
|
|
symbol, timeframe, days_back, exchange
|
|
)
|
|
|
|
if not raw_candles:
|
|
self.logger.warning(f"Chart builder: No market data available for {symbol} {timeframe}")
|
|
return []
|
|
|
|
self.logger.debug(f"Chart builder: Enhanced fetch: {len(raw_candles)} candles for {symbol} {timeframe}")
|
|
return raw_candles
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Chart builder: Error in enhanced market data fetch: {e}")
|
|
# Fallback to original method
|
|
return self.fetch_market_data(symbol, timeframe, days_back, exchange)
|
|
|
|
def create_candlestick_chart(self, symbol: str, timeframe: str,
|
|
days_back: int = 7, **kwargs) -> go.Figure:
|
|
"""
|
|
Create a basic candlestick chart.
|
|
|
|
Args:
|
|
symbol: Trading pair
|
|
timeframe: Timeframe
|
|
days_back: Number of days to look back
|
|
**kwargs: Additional chart parameters
|
|
|
|
Returns:
|
|
Plotly Figure object with candlestick chart
|
|
"""
|
|
try:
|
|
# Fetch market data
|
|
candles = self.fetch_market_data(symbol, timeframe, days_back)
|
|
|
|
# Handle empty data
|
|
if not candles:
|
|
self.logger.warning(f"Chart builder: No data available for {symbol} {timeframe}")
|
|
return self._create_empty_chart(f"No data available for {symbol} {timeframe}")
|
|
|
|
# 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")
|
|
|
|
# Prepare chart data
|
|
df = prepare_chart_data(candles)
|
|
|
|
# Determine if we need volume subplot
|
|
has_volume = 'volume' in df.columns and df['volume'].sum() > 0
|
|
include_volume = kwargs.get('include_volume', has_volume)
|
|
|
|
if include_volume and has_volume:
|
|
fig, df_chart = self._create_candlestick_with_volume(df, symbol, timeframe, **kwargs)
|
|
return fig, df_chart
|
|
else:
|
|
fig, df_chart = self._create_basic_candlestick(df, symbol, timeframe, **kwargs)
|
|
return fig, df_chart
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Chart builder: Error creating candlestick chart for {symbol} {timeframe}: {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,
|
|
timeframe: str, **kwargs) -> go.Figure:
|
|
"""Create a basic candlestick chart without volume."""
|
|
|
|
# Get custom parameters
|
|
height = kwargs.get('height', self.default_height)
|
|
template = kwargs.get('template', self.default_template)
|
|
|
|
# Create candlestick chart
|
|
fig = go.Figure(data=go.Candlestick(
|
|
x=df['timestamp'],
|
|
open=df['open'],
|
|
high=df['high'],
|
|
low=df['low'],
|
|
close=df['close'],
|
|
name=symbol,
|
|
increasing_line_color=self.default_colors['bullish'],
|
|
decreasing_line_color=self.default_colors['bearish']
|
|
))
|
|
|
|
# Update layout
|
|
fig.update_layout(
|
|
title=f"{symbol} - {timeframe} Chart",
|
|
xaxis_title="Time",
|
|
yaxis_title="Price (USDT)",
|
|
template=template,
|
|
showlegend=False,
|
|
height=height,
|
|
xaxis_rangeslider_visible=False,
|
|
hovermode='x unified'
|
|
)
|
|
|
|
self.logger.debug(f"Chart builder: Created basic candlestick chart for {symbol} {timeframe} with {len(df)} candles")
|
|
return fig, df
|
|
|
|
def _create_candlestick_with_volume(self, df: pd.DataFrame, symbol: str,
|
|
timeframe: str, **kwargs) -> go.Figure:
|
|
"""Create a candlestick chart with volume subplot."""
|
|
|
|
# Get custom parameters
|
|
height = kwargs.get('height', 700) # Taller for volume subplot
|
|
template = kwargs.get('template', self.default_template)
|
|
|
|
# Create subplots
|
|
fig = make_subplots(
|
|
rows=2, cols=1,
|
|
shared_xaxes=True,
|
|
vertical_spacing=0.03,
|
|
subplot_titles=(f'{symbol} Price', 'Volume'),
|
|
row_heights=[0.7, 0.3] # 70% for price, 30% for volume
|
|
)
|
|
|
|
# Add candlestick chart
|
|
fig.add_trace(
|
|
go.Candlestick(
|
|
x=df['timestamp'],
|
|
open=df['open'],
|
|
high=df['high'],
|
|
low=df['low'],
|
|
close=df['close'],
|
|
name=symbol,
|
|
increasing_line_color=self.default_colors['bullish'],
|
|
decreasing_line_color=self.default_colors['bearish']
|
|
),
|
|
row=1, col=1
|
|
)
|
|
|
|
# Add volume bars with color coding
|
|
colors = [self.default_colors['bullish'] if close >= open else self.default_colors['bearish']
|
|
for close, open in zip(df['close'], df['open'])]
|
|
|
|
fig.add_trace(
|
|
go.Bar(
|
|
x=df['timestamp'],
|
|
y=df['volume'],
|
|
name='Volume',
|
|
marker_color=colors,
|
|
opacity=0.7
|
|
),
|
|
row=2, col=1
|
|
)
|
|
|
|
# Update layout
|
|
fig.update_layout(
|
|
title=f"{symbol} - {timeframe} Chart with Volume",
|
|
template=template,
|
|
showlegend=False,
|
|
height=height,
|
|
xaxis_rangeslider_visible=False,
|
|
hovermode='x unified',
|
|
dragmode='pan'
|
|
)
|
|
|
|
# Update axes
|
|
fig.update_yaxes(title_text="Price (USDT)", row=1, col=1)
|
|
fig.update_yaxes(title_text="Volume", 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} with {len(df)} candles")
|
|
return fig, df
|
|
|
|
def _create_empty_chart(self, message: str = "No data available") -> go.Figure:
|
|
"""Create an empty chart with a message."""
|
|
fig = go.Figure()
|
|
|
|
fig.add_annotation(
|
|
text=message,
|
|
xref="paper", yref="paper",
|
|
x=0.5, y=0.5,
|
|
xanchor='center', yanchor='middle',
|
|
showarrow=False,
|
|
font=dict(size=16, color="#7f8c8d")
|
|
)
|
|
|
|
fig.update_layout(
|
|
template=self.default_template,
|
|
height=self.default_height,
|
|
showlegend=False,
|
|
xaxis=dict(visible=False),
|
|
yaxis=dict(visible=False)
|
|
)
|
|
|
|
return fig
|
|
|
|
def _create_error_chart(self, error_message: str) -> go.Figure:
|
|
"""Create an error chart with error message."""
|
|
fig = go.Figure()
|
|
|
|
fig.add_annotation(
|
|
text=f"⚠️ {error_message}",
|
|
xref="paper", yref="paper",
|
|
x=0.5, y=0.5,
|
|
xanchor='center', yanchor='middle',
|
|
showarrow=False,
|
|
font=dict(size=16, color="#e74c3c")
|
|
)
|
|
|
|
fig.update_layout(
|
|
template=self.default_template,
|
|
height=self.default_height,
|
|
showlegend=False,
|
|
xaxis=dict(visible=False),
|
|
yaxis=dict(visible=False)
|
|
)
|
|
|
|
return fig
|
|
|
|
def create_strategy_chart(self, symbol: str, timeframe: str,
|
|
strategy_name: str, **kwargs) -> go.Figure:
|
|
"""
|
|
Create a strategy-specific chart (placeholder for future implementation).
|
|
|
|
Args:
|
|
symbol: Trading pair
|
|
timeframe: Timeframe
|
|
strategy_name: Name of the strategy configuration
|
|
**kwargs: Additional parameters
|
|
|
|
Returns:
|
|
Plotly Figure object
|
|
"""
|
|
# For now, return a basic candlestick chart
|
|
# This will be enhanced in later tasks with strategy configurations
|
|
self.logger.info(f"Chart builder: Creating strategy chart for {strategy_name} (basic implementation)")
|
|
return self.create_candlestick_chart(symbol, timeframe, **kwargs)
|
|
|
|
def check_data_quality(self, symbol: str, timeframe: str,
|
|
exchange: str = "okx") -> Dict[str, Any]:
|
|
"""
|
|
Check data quality and availability for chart creation.
|
|
|
|
Args:
|
|
symbol: Trading pair
|
|
timeframe: Timeframe
|
|
exchange: Exchange name
|
|
|
|
Returns:
|
|
Dictionary with data quality information
|
|
"""
|
|
try:
|
|
return self.data_integrator.check_data_availability(symbol, timeframe, exchange)
|
|
except Exception as e:
|
|
self.logger.error(f"Chart builder: Error checking data quality: {e}")
|
|
return {
|
|
'available': False,
|
|
'latest_timestamp': None,
|
|
'data_age_minutes': None,
|
|
'sufficient_for_indicators': False,
|
|
'message': f"Error checking data: {str(e)}"
|
|
}
|
|
|
|
def create_chart_with_indicators(self, symbol: str, timeframe: str,
|
|
overlay_indicators: List[str] = None,
|
|
subplot_indicators: List[str] = None,
|
|
days_back: int = 7, **kwargs) -> go.Figure:
|
|
"""
|
|
Create a candlestick chart with specified technical indicators.
|
|
|
|
Args:
|
|
symbol: Trading pair
|
|
timeframe: Timeframe
|
|
overlay_indicators: List of overlay indicator names
|
|
subplot_indicators: List of subplot indicator names
|
|
days_back: Number of days to look back
|
|
**kwargs: Additional chart parameters
|
|
|
|
Returns:
|
|
Plotly Figure object and a pandas DataFrame with all chart data.
|
|
"""
|
|
overlay_indicators = overlay_indicators or []
|
|
subplot_indicators = subplot_indicators or []
|
|
try:
|
|
# 1. Fetch and Prepare Base Data
|
|
candles = self.fetch_market_data_enhanced(symbol, timeframe, days_back)
|
|
if not candles:
|
|
self.logger.warning(f"No data for {symbol} {timeframe}, creating empty chart.")
|
|
return self._create_empty_chart(f"No data for {symbol} {timeframe}"), pd.DataFrame()
|
|
|
|
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()
|
|
|
|
# Initialize final DataFrame for export
|
|
final_df = df.copy()
|
|
|
|
# 2. Setup Subplots
|
|
# Count subplot indicators to configure rows
|
|
subplot_count = 0
|
|
volume_enabled = 'volume' in df.columns and df['volume'].sum() > 0
|
|
if volume_enabled:
|
|
subplot_count += 1
|
|
|
|
if subplot_indicators:
|
|
subplot_count += len(subplot_indicators)
|
|
|
|
# Create subplot structure if needed
|
|
if subplot_count > 0:
|
|
# Calculate height ratios
|
|
main_height = 0.7 # Main chart gets 70%
|
|
subplot_height = 0.3 / subplot_count if subplot_count > 0 else 0
|
|
|
|
# Create subplot specifications
|
|
subplot_specs = [[{"secondary_y": False}]] # Main chart
|
|
row_heights = [main_height]
|
|
|
|
if volume_enabled:
|
|
subplot_specs.append([{"secondary_y": False}])
|
|
row_heights.append(subplot_height)
|
|
|
|
if subplot_indicators:
|
|
for _ in subplot_indicators:
|
|
subplot_specs.append([{"secondary_y": False}])
|
|
row_heights.append(subplot_height)
|
|
|
|
# Create subplots figure
|
|
from plotly.subplots import make_subplots
|
|
fig = make_subplots(
|
|
rows=len(subplot_specs),
|
|
cols=1,
|
|
shared_xaxes=True,
|
|
vertical_spacing=0.02,
|
|
row_heights=row_heights,
|
|
specs=subplot_specs,
|
|
subplot_titles=[f"{symbol} - {timeframe}"] + [""] * (len(subplot_specs) - 1)
|
|
)
|
|
else:
|
|
# Create simple figure for main chart only
|
|
fig = go.Figure()
|
|
|
|
current_row = 1
|
|
|
|
# 4. Add Candlestick Trace
|
|
fig.add_trace(go.Candlestick(
|
|
x=df['timestamp'],
|
|
open=df['open'],
|
|
high=df['high'],
|
|
low=df['low'],
|
|
close=df['close'],
|
|
name=symbol,
|
|
increasing_line_color=self.default_colors['bullish'],
|
|
decreasing_line_color=self.default_colors['bearish']
|
|
), row=current_row, col=1)
|
|
|
|
# 5. Add Volume Trace (if applicable)
|
|
if volume_enabled:
|
|
current_row += 1
|
|
volume_colors = [self.default_colors['bullish'] if close >= open else self.default_colors['bearish']
|
|
for close, open in zip(df['close'], df['open'])]
|
|
|
|
volume_trace = go.Bar(
|
|
x=df['timestamp'],
|
|
y=df['volume'],
|
|
name='Volume',
|
|
marker_color=volume_colors,
|
|
opacity=0.7
|
|
)
|
|
fig.add_trace(volume_trace, row=current_row, col=1)
|
|
fig.update_yaxes(title_text="Volume", row=current_row, col=1)
|
|
|
|
# 6. Add Indicator Traces
|
|
indicator_manager = get_indicator_manager()
|
|
all_indicator_configs = []
|
|
|
|
# Create IndicatorLayerConfig objects from indicator IDs
|
|
indicator_ids = (overlay_indicators or []) + (subplot_indicators or [])
|
|
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)
|
|
|
|
if all_indicator_configs:
|
|
indicator_data_map = self.data_integrator.get_indicator_data(
|
|
main_df=df,
|
|
main_timeframe=timeframe,
|
|
indicator_configs=all_indicator_configs,
|
|
indicator_manager=indicator_manager,
|
|
symbol=symbol,
|
|
exchange="okx"
|
|
)
|
|
|
|
for indicator_id, indicator_df in indicator_data_map.items():
|
|
indicator = indicator_manager.load_indicator(indicator_id)
|
|
if not indicator:
|
|
self.logger.warning(f"Could not load indicator '{indicator_id}' for plotting.")
|
|
continue
|
|
|
|
if indicator_df is not None and not indicator_df.empty:
|
|
# Add a suffix to the indicator's columns before joining to prevent overlap
|
|
# when multiple indicators of the same type are added.
|
|
final_df = final_df.join(indicator_df, how='left', rsuffix=f'_{indicator.id}')
|
|
|
|
# Determine target row for plotting
|
|
target_row = 1 # Default to overlay on the main chart
|
|
if indicator.id in subplot_indicators:
|
|
current_row += 1
|
|
target_row = current_row
|
|
fig.update_yaxes(title_text=indicator.name, row=target_row, col=1)
|
|
|
|
if indicator.type == 'bollinger_bands':
|
|
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.index
|
|
y_upper = indicator_df['upper_band']
|
|
y_lower = indicator_df['lower_band']
|
|
|
|
# Convert hex color to rgba for the fill
|
|
hex_color = indicator.styling.color.lstrip('#')
|
|
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)'
|
|
|
|
# Add the transparent fill trace
|
|
fig.add_trace(go.Scatter(
|
|
x=pd.concat([x_vals.to_series(), x_vals.to_series()[::-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)
|
|
|
|
# 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.index,
|
|
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)
|
|
template = kwargs.get('template', self.default_template)
|
|
|
|
fig.update_layout(
|
|
title=f"{symbol} - {timeframe} Chart",
|
|
template=template,
|
|
height=height,
|
|
showlegend=True,
|
|
legend=dict(yanchor="top", y=0.99, xanchor="left", x=0.01),
|
|
xaxis_rangeslider_visible=False,
|
|
hovermode='x unified'
|
|
)
|
|
|
|
# Update x-axis for all subplots
|
|
fig.update_xaxes(title_text="Time", row=current_row, col=1)
|
|
fig.update_yaxes(title_text="Price (USDT)", row=1, col=1)
|
|
|
|
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.info(f"Successfully created chart for {symbol} with {len(overlay_indicators + subplot_indicators)} indicators.")
|
|
return fig, final_df
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error in create_chart_with_indicators for {symbol}: {e}", exc_info=True)
|
|
return self._create_error_chart(f"Error generating indicator chart: {e}"), pd.DataFrame() |