"""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