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()
|
|
|
|
|
|
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() |