"""Plotly visualization utilities for financial data. Provides professional, interactive charts for portfolio analysis including: - Portfolio allocation pie charts - Risk metrics gauge charts - Performance time series - Correlation heatmaps - Efficient frontier visualizations """ import plotly.graph_objects as go import plotly.express as px from plotly.subplots import make_subplots import pandas as pd import numpy as np import os import logging from typing import Dict, List, Any, Optional from decimal import Decimal logger = logging.getLogger(__name__) def get_plotly_template() -> str: """Get Plotly template that supports light/dark mode. Returns: Template name for Plotly charts that auto-adapts to theme """ return "plotly_dark" def get_chart_theme() -> Dict[str, Any]: """Detect theme and return appropriate colours for charts. Returns: Dictionary with template and colour settings for light/dark mode """ return { 'template': 'plotly_dark', 'font_color': '#f2f5fa', 'grid_color': 'rgba(128, 128, 128, 0.2)', 'plot_bgcolor': 'rgba(0,0,0,0)', 'paper_bgcolor': 'rgba(0,0,0,0)', } def get_optimised_chart_config() -> Dict[str, Any]: """Get optimised configuration for chart rendering performance. Returns: Configuration dict with optimised settings for reduced file size and better UX """ return { 'displayModeBar': True, 'displaylogo': False, 'modeBarButtonsToRemove': [ 'pan2d', 'select2d', 'lasso2d', 'resetScale2d', 'zoomIn2d', 'zoomOut2d', 'autoScale2d' ], 'responsive': True, 'toImageButtonOptions': { 'format': 'png', 'filename': 'portfolio_chart', 'height': 800, 'width': 1200, 'scale': 2 } } def safe_create_chart(chart_function, *args, **kwargs): """Error boundary wrapper for chart creation functions. Args: chart_function: The chart creation function to wrap *args: Positional arguments for the chart function **kwargs: Keyword arguments for the chart function Returns: Plotly figure or None if chart creation fails """ import logging logger = logging.getLogger(__name__) try: return chart_function(*args, **kwargs) except Exception as e: logger.error(f"Chart creation failed in {chart_function.__name__}: {e}") return None def create_portfolio_allocation_chart(holdings: List[Dict[str, Any]]) -> go.Figure: """Create interactive portfolio allocation pie chart. Args: holdings: List of portfolio holdings with ticker, market_value, weight Returns: Plotly figure with interactive pie chart """ tickers = [h.get('ticker', 'Unknown') for h in holdings] values = [float(h.get('market_value', 0)) for h in holdings] weights = [float(h.get('weight', 0)) * 100 for h in holdings] fig = go.Figure(go.Pie( labels=tickers, values=values, customdata=list(zip(weights)), hovertemplate=( "%{label}
" + "Value: $%{value:,.2f}
" + "Weight: %{customdata[0]:.2f}%
" + "" ), textposition='inside', textinfo='percent+label', marker=dict( line=dict(width=2), colors=px.colors.qualitative.Set3 ) )) theme = get_chart_theme() fig.update_layout( title={ 'text': 'Portfolio Allocation', 'x': 0.5, 'xanchor': 'center', 'font': {'size': 20, 'color': theme.get('font_color', '#f2f5fa')} }, showlegend=True, uniformtext_minsize=12, uniformtext_mode='hide', autosize=True, template=theme['template'], plot_bgcolor=theme['plot_bgcolor'], paper_bgcolor=theme['paper_bgcolor'], font=dict( color=theme.get('font_color', '#f2f5fa'), family='Inter, sans-serif' ) ) return fig def create_risk_metrics_dashboard( sharpe: float, var: float, cvar: float, volatility: float ) -> go.Figure: """Create comprehensive risk metrics dashboard with gauges. Args: sharpe: Sharpe ratio var: Value at Risk (percentage, negative) cvar: Conditional VaR (percentage, negative) volatility: Annual volatility (decimal) Returns: Plotly figure with 4 gauge charts """ # Convert to float in case values are strings from serialization sharpe = float(sharpe) if sharpe else 0 var = float(var) if var else 0 cvar = float(cvar) if cvar else 0 volatility = float(volatility) if volatility else 0 fig = make_subplots( rows=2, cols=2, specs=[ [{'type': 'indicator'}, {'type': 'indicator'}], [{'type': 'indicator'}, {'type': 'indicator'}] ], subplot_titles=("Sharpe Ratio", "Volatility", "VaR (95%)", "CVaR (95%)") ) # Sharpe Ratio (top-left) sharpe_color = "#28a745" if sharpe > 1 else "#ffc107" if sharpe > 0.5 else "#dc3545" fig.add_trace(go.Indicator( mode="gauge+number+delta", value=sharpe, delta={'reference': 1.0, 'increasing': {'color': "#28a745"}, 'decreasing': {'color': "#dc3545"}}, gauge={ 'axis': {'range': [0, 4], 'tickwidth': 1}, 'bar': {'color': sharpe_color}, 'steps': [ {'range': [0, 1], 'color': '#fee5e5'}, {'range': [1, 2], 'color': '#fff4e5'}, {'range': [2, 4], 'color': '#e5f5e5'} ], 'threshold': { 'line': {'color': "red", 'width': 3}, 'thickness': 0.75, 'value': 3.0 } } ), row=1, col=1) # Volatility (top-right) vol_pct = volatility * 100 vol_color = "#dc3545" if vol_pct > 30 else "#ffc107" if vol_pct > 20 else "#28a745" fig.add_trace(go.Indicator( mode="gauge+number", value=vol_pct, number={'suffix': "%", 'font': {'size': 40}}, gauge={ 'axis': {'range': [0, 50], 'ticksuffix': "%"}, 'bar': {'color': vol_color}, 'steps': [ {'range': [0, 15], 'color': '#e5f5e5'}, {'range': [15, 25], 'color': '#fff4e5'}, {'range': [25, 50], 'color': '#fee5e5'} ] } ), row=1, col=2) # VaR (bottom-left) var_abs = abs(var) fig.add_trace(go.Indicator( mode="gauge+number", value=var_abs, number={'suffix': "%", 'font': {'size': 40}}, gauge={ 'axis': {'range': [0, 30], 'ticksuffix': "%"}, 'bar': {'color': "#dc3545"}, 'steps': [ {'range': [0, 10], 'color': '#e5f5e5'}, {'range': [10, 20], 'color': '#fff4e5'}, {'range': [20, 30], 'color': '#fee5e5'} ], 'threshold': { 'line': {'color': "darkred", 'width': 4}, 'thickness': 0.75, 'value': 15 } } ), row=2, col=1) # CVaR (bottom-right) cvar_abs = abs(cvar) fig.add_trace(go.Indicator( mode="gauge+number", value=cvar_abs, number={'suffix': "%", 'font': {'size': 40}}, gauge={ 'axis': {'range': [0, 30], 'ticksuffix': "%"}, 'bar': {'color': "#ff6b6b"}, 'steps': [ {'range': [0, 10], 'color': '#e5f5e5'}, {'range': [10, 20], 'color': '#fff4e5'}, {'range': [20, 30], 'color': '#fee5e5'} ], 'threshold': { 'line': {'color': "darkred", 'width': 4}, 'thickness': 0.75, 'value': 18 } } ), row=2, col=2) theme = get_chart_theme() fig.update_layout( autosize=True, height=600, template=theme['template'], plot_bgcolor=theme['plot_bgcolor'], paper_bgcolor=theme['paper_bgcolor'], font=dict( color=theme.get('font_color', '#f2f5fa'), family='Inter, sans-serif' ), margin=dict(t=80, b=50, l=50, r=50) ) return fig def create_performance_chart( holdings: List[Dict[str, Any]], historical_data: Dict[str, Dict[str, Any]], benchmark_data: Optional[Dict[str, Any]] = None ) -> Optional[go.Figure]: """Create performance time series chart with optional benchmark comparison. Args: holdings: Portfolio holdings historical_data: Historical price data by ticker benchmark_data: Optional benchmark data (e.g., S&P 500) Returns: Plotly figure with time series or None if insufficient data """ if not historical_data: return None fig = go.Figure() for holding in holdings: ticker = holding.get('ticker') if ticker not in historical_data: continue hist = historical_data[ticker] dates = hist.get('dates', []) prices = hist.get('close_prices', []) if not dates or not prices: continue # Convert prices to float in case they're strings prices = [float(p) for p in prices] # Calculate percentage returns (normalise to 100) if prices[0] > 0: normalized = [(p / prices[0]) * 100 for p in prices] else: normalized = prices fig.add_trace(go.Scatter( x=dates, y=normalized, name=ticker, mode='lines', line=dict(width=2), hovertemplate=( f"{ticker}
" + "%{x|%B %d, %Y}
" + "Price: %{y:.2f}
" + "" ) )) # Add benchmark if provided if benchmark_data: benchmark_dates = benchmark_data.get('dates', []) benchmark_prices = benchmark_data.get('close_prices', []) if benchmark_dates and benchmark_prices: # Convert benchmark prices to float in case they're strings benchmark_prices = [float(p) for p in benchmark_prices] if benchmark_prices[0] > 0: normalized_benchmark = [(p / benchmark_prices[0]) * 100 for p in benchmark_prices] fig.add_trace(go.Scatter( x=benchmark_dates, y=normalized_benchmark, name="S&P 500 (Benchmark)", mode='lines', line=dict(width=3, dash='dash', color='#FFA500'), hovertemplate=( "S&P 500
" + "%{x|%B %d, %Y}
" + "Value: %{y:.2f}
" + "" ) )) theme = get_chart_theme() fig.update_layout( title={ 'text': 'Historical Performance (Normalised to 100)', 'x': 0.5, 'xanchor': 'center', 'font': {'size': 20, 'color': theme.get('font_color', '#f2f5fa')} }, xaxis=dict( title='Date', title_font=dict(color=theme.get('font_color', '#f2f5fa')), showgrid=True, gridcolor=theme['grid_color'], rangeslider=dict(visible=True, bgcolor=theme['plot_bgcolor']), tickfont=dict(color=theme.get('font_color', '#f2f5fa')) ), yaxis=dict( title='Normalised Value', title_font=dict(color=theme.get('font_color', '#f2f5fa')), showgrid=True, gridcolor=theme['grid_color'], tickformat='.1f', tickfont=dict(color=theme.get('font_color', '#f2f5fa')) ), hovermode='x unified', autosize=True, showlegend=True, legend=dict( orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1 ), template=theme['template'], plot_bgcolor=theme['plot_bgcolor'], paper_bgcolor=theme['paper_bgcolor'], font=dict( color=theme.get('font_color', '#f2f5fa'), family='Inter, sans-serif' ) ) return fig def create_correlation_heatmap( historical_data: Dict[str, Dict[str, Any]] ) -> Optional[go.Figure]: """Create correlation heatmap for assets. Args: historical_data: Historical price data by ticker Returns: Plotly figure with heatmap or None if insufficient data """ if not historical_data or len(historical_data) < 2: return None # Build returns dataframe - ENSURE ALL ASSETS ARE INCLUDED returns_data = {} min_length = float('inf') # First pass: calculate all returns and find minimum length for ticker, hist in historical_data.items(): prices = hist.get('close_prices', []) if len(prices) > 1: returns = pd.Series(prices).pct_change().dropna() returns_data[ticker] = returns min_length = min(min_length, len(returns)) if len(returns_data) < 2: logger.warning("Insufficient returns data for correlation matrix") return None # Second pass: align all series to same length aligned_returns = {} for ticker, returns in returns_data.items(): aligned_returns[ticker] = returns.iloc[-min_length:] # Create DataFrame and calculate correlation df = pd.DataFrame(aligned_returns) # VERIFY: Check that df contains all expected tickers logger.info(f"Correlation matrix includes {len(df.columns)} assets: {list(df.columns)}") corr_matrix = df.corr() fig = go.Figure(data=go.Heatmap( z=corr_matrix.values, x=corr_matrix.columns, y=corr_matrix.index, colorscale='RdBu', zmid=0, zmin=-1, zmax=1, text=np.round(corr_matrix.values, 2), texttemplate='%{text}', textfont={"size": 10}, colorbar=dict( title="Correlation", tickmode='linear', tick0=-1, dtick=0.5 ), hovertemplate=( "%{y} vs %{x}
" + "Correlation: %{z:.3f}
" + "" ) )) theme = get_chart_theme() fig.update_layout( title={ 'text': 'Asset Correlation Matrix', 'x': 0.5, 'xanchor': 'center', 'font': {'size': 20, 'color': theme.get('font_color', '#f2f5fa')} }, xaxis=dict( showgrid=False, side='bottom', tickangle=-45, tickfont=dict(color=theme.get('font_color', '#f2f5fa')) ), yaxis=dict( showgrid=False, autorange='reversed', tickfont=dict(color=theme.get('font_color', '#f2f5fa')) ), autosize=True, width=700, height=700, template=theme['template'], plot_bgcolor=theme['plot_bgcolor'], paper_bgcolor=theme['paper_bgcolor'], font=dict( color=theme.get('font_color', '#f2f5fa'), family='Inter, sans-serif' ) ) return fig def create_optimization_comparison( optimization_results: Dict[str, Any] ) -> Optional[go.Figure]: """Create bar chart comparing optimization methods. Args: optimization_results: Results from portfolio optimizer Returns: Plotly figure with comparison chart or None if no data """ if not optimization_results: return None methods = [] sharpe_ratios = [] volatilities = [] for method, result in optimization_results.items(): if isinstance(result, dict): sharpe = result.get('sharpe_ratio', 0) vol = result.get('volatility', 0) # Convert to float in case values are strings sharpe = float(sharpe) if sharpe else 0 vol = float(vol) if vol else 0 methods.append(method.replace('_', ' ').title()) sharpe_ratios.append(sharpe) volatilities.append(vol * 100 if vol < 1 else vol) # Convert to percentage if not methods: return None fig = make_subplots( rows=1, cols=2, subplot_titles=('Sharpe Ratio Comparison', 'Volatility Comparison'), specs=[[{'type': 'bar'}, {'type': 'bar'}]] ) # Sharpe ratios fig.add_trace( go.Bar( x=methods, y=sharpe_ratios, name='Sharpe Ratio', marker_color='#288cfa', text=[f'{s:.3f}' for s in sharpe_ratios], textposition='outside' ), row=1, col=1 ) # Volatilities fig.add_trace( go.Bar( x=methods, y=volatilities, name='Volatility (%)', marker_color='#dc3545', text=[f'{v:.2f}%' for v in volatilities], textposition='outside' ), row=1, col=2 ) theme = get_chart_theme() fig.update_layout( title={ 'text': 'Portfolio Optimisation Methods Comparison', 'x': 0.5, 'xanchor': 'center', 'font': {'size': 20, 'color': theme.get('font_color', '#f2f5fa')} }, showlegend=False, autosize=True, height=400, template=theme['template'], plot_bgcolor=theme['plot_bgcolor'], paper_bgcolor=theme['paper_bgcolor'], font=dict( color=theme.get('font_color', '#f2f5fa'), family='Inter, sans-serif' ) ) fig.update_xaxes( tickangle=-45, tickfont=dict(color=theme.get('font_color', '#f2f5fa')), row=1, col=1 ) fig.update_xaxes( tickangle=-45, tickfont=dict(color=theme.get('font_color', '#f2f5fa')), row=1, col=2 ) fig.update_yaxes( title_text="Sharpe Ratio", title_font=dict(color=theme.get('font_color', '#f2f5fa')), tickfont=dict(color=theme.get('font_color', '#f2f5fa')), row=1, col=1 ) fig.update_yaxes( title_text="Volatility (%)", title_font=dict(color=theme.get('font_color', '#f2f5fa')), tickfont=dict(color=theme.get('font_color', '#f2f5fa')), row=1, col=2 ) return fig def apply_financial_theme(fig: go.Figure) -> go.Figure: """Apply professional financial styling to any Plotly figure. Args: fig: Plotly figure to style Returns: Styled figure """ theme = get_chart_theme() fig.update_layout( font=dict( family="Inter, Arial, sans-serif", size=12, color=theme.get('font_color', '#f2f5fa') ), title_font=dict( family="Inter, Arial, sans-serif", size=18, color=theme.get('font_color', '#f2f5fa') ), template=theme['template'], plot_bgcolor=theme['plot_bgcolor'], paper_bgcolor=theme['paper_bgcolor'], xaxis=dict( showgrid=True, gridwidth=1, gridcolor=theme['grid_color'], zeroline=False, tickfont=dict(color=theme.get('font_color', '#f2f5fa')) ), yaxis=dict( showgrid=True, gridwidth=1, gridcolor=theme['grid_color'], zeroline=False, tickfont=dict(color=theme.get('font_color', '#f2f5fa')) ), legend=dict( borderwidth=1 ), hoverlabel=dict( font_size=12, font_family="Inter, Arial, sans-serif" ) ) return fig