Spaces:
Running
on
Zero
Running
on
Zero
| """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=( | |
| "<b>%{label}</b><br>" + | |
| "Value: $%{value:,.2f}<br>" + | |
| "Weight: %{customdata[0]:.2f}%<br>" + | |
| "<extra></extra>" | |
| ), | |
| 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"<b>{ticker}</b><br>" + | |
| "%{x|%B %d, %Y}<br>" + | |
| "Price: %{y:.2f}<br>" + | |
| "<extra></extra>" | |
| ) | |
| )) | |
| # 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=( | |
| "<b>S&P 500</b><br>" + | |
| "%{x|%B %d, %Y}<br>" + | |
| "Value: %{y:.2f}<br>" + | |
| "<extra></extra>" | |
| ) | |
| )) | |
| 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}<br>" + | |
| "Correlation: %{z:.3f}<br>" + | |
| "<extra></extra>" | |
| ) | |
| )) | |
| 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 | |