Source code for kaxanuk.attribution_analysis.modules.attribution_visualizers

"""
Attribution visualization module — Plotly versions of the matplotlib charts.

Provides chart-creation functions that return Plotly Figure objects
for use in the Dash dashboard.
"""

import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots

from kaxanuk.attribution_analysis.dashboard.chart_colors import CHART_COLORS
from kaxanuk.attribution_analysis.dashboard.constants import PROFESSIONAL_COLORS


[docs] def create_factor_attribution_chart( simulated_rets: pd.DataFrame, ) -> go.Figure: """Create the main 3-panel factor attribution line chart. Replicates FactorModelAttribution.attribution_plots() in Plotly. Panels: 1. Total / Factor / Idiosyncratic cumulative returns 2. Style Factors 3. Industry Returns (non-zero columns only) """ df = simulated_rets totals_cols = [c for c in ["f_total_excess_returns", "f_total_factor_returns", "f_idyo_returns"] if c in df.columns] style_cols = [c for c in ["f_size", "f_momentum", "f_beta", "f_residual volatility", "f_value"] if c in df.columns] industry_cols = [ c for c in [ "f_Communication Services", "f_Consumer Discretionary", "f_Consumer Staples", "f_Energy", "f_Financials", "f_Health Care", "f_Industrials", "f_Information Technology", "f_Materials", "f_Real Estate", "f_Utilities", ] if c in df.columns ] industries_df = df[industry_cols] industry_cols = [c for c in industry_cols if (industries_df[c] != 0).any()] fig = make_subplots( rows=3, cols=1, subplot_titles=( "Total / Factor / Idiosyncratic", "Style Factors", "Industry Returns", ), shared_xaxes=True, vertical_spacing=0.08, ) for color_idx, col in enumerate(totals_cols): fig.add_trace( go.Scatter( x=df.index, y=df[col], mode='lines', name=col, line={'color': CHART_COLORS[color_idx % len(CHART_COLORS)], 'width': 2}, hovertemplate=f'<b>{col}</b><br>Date: %{{x}}<br>Return: %{{y:.4f}}<extra></extra>', ), row=1, col=1, ) for color_idx, col in enumerate(style_cols): fig.add_trace( go.Scatter( x=df.index, y=df[col], mode='lines', name=col, line={'color': CHART_COLORS[color_idx % len(CHART_COLORS)], 'width': 2}, hovertemplate=f'<b>{col}</b><br>Date: %{{x}}<br>Return: %{{y:.4f}}<extra></extra>', ), row=2, col=1, ) for color_idx, col in enumerate(industry_cols): fig.add_trace( go.Scatter( x=df.index, y=df[col], mode='lines', name=col, line={'color': CHART_COLORS[color_idx % len(CHART_COLORS)], 'width': 1.5}, hovertemplate=f'<b>{col}</b><br>Date: %{{x}}<br>Return: %{{y:.4f}}<extra></extra>', ), row=3, col=1, ) for row in range(1, 4): fig.add_hline(y=0, line_dash="solid", line_color="black", line_width=1, row=row, col=1) fig.update_yaxes(tickformat='.1%', row=row, col=1) fig.update_layout( height=900, template='plotly_white', hovermode='x unified', legend={ 'orientation': 'v', 'yanchor': 'top', 'y': 1, 'xanchor': 'left', 'x': 1.02, 'font': {'size': 10, 'family': 'Segoe UI'}, }, margin={'l': 60, 'r': 160, 't': 60, 'b': 40}, ) return fig
[docs] def create_factor_attribution_summary_chart( simulated_rets: pd.DataFrame, ) -> go.Figure: """Create a compact attribution chart showing just the 3 main series. Used in the main charts row (large chart). """ df = simulated_rets cols = [c for c in ["f_total_excess_returns", "f_total_factor_returns", "f_idyo_returns"] if c in df.columns] fig = go.Figure() for i, col in enumerate(cols): fig.add_trace(go.Scatter( x=df.index, y=df[col], mode='lines', name=col, line={'color': CHART_COLORS[i], 'width': 2}, hovertemplate=f'<b>{col}</b><br>Date: %{{x}}<br>Return: %{{y:.4f}}<extra></extra>', )) fig.add_hline(y=0, line_dash="solid", line_color="black", line_width=1) fig.update_layout( title="Cumulative Factor Attribution", xaxis_title="Date", yaxis_title="Cumulative Return", yaxis={'tickformat': '.1%'}, template='plotly_white', height=500, autosize=True, hovermode='x unified', legend={'yanchor': "top", 'y': 0.99, 'xanchor': "left", 'x': 0.01}, ) return fig
[docs] def create_stacked_decomposition_chart( pct_df_returns: pd.DataFrame, days_of_smoothing: int = 45, ) -> go.Figure: """Create a normalized stacked area chart of factor return decomposition. Replicates FactorModelAttribution.plot_stacked_weights_normalized() in Plotly. Each factor's share = abs(factor) / sum(abs(all factors)), always sums to 100%. """ df = pct_df_returns.copy().ffill() if not isinstance(df.index, pd.DatetimeIndex): df.index = pd.to_datetime(df.index) df = df.sort_index() abs_components = df.abs() abs_total = abs_components.sum(axis=1) pct = abs_components.div(abs_total, axis=0).rolling(days_of_smoothing).mean() pct = pct.fillna(0.0) num_cols = len(pct.columns) colors = [CHART_COLORS[i % len(CHART_COLORS)] for i in range(num_cols)] fig = go.Figure() for i, col in enumerate(pct.columns): hex_color = colors[i] rgb = tuple(int(hex_color[j:j + 2], 16) for j in (1, 3, 5)) rgba_fill = f'rgba({rgb[0]}, {rgb[1]}, {rgb[2]}, 0.75)' fig.add_trace(go.Scatter( x=pct.index, y=pct[col], mode='lines', name=col, stackgroup='one', groupnorm='percent', line={'width': 0.5, 'color': hex_color}, fillcolor=rgba_fill, hovertemplate=f'<b>{col}</b><br>Date: %{{x}}<br>Share: %{{y:.1f}}%<extra></extra>', )) fig.update_layout( title=f"Return Decomposition (Rolling {days_of_smoothing}d)", xaxis_title="Date", yaxis_title="Share of Total Return", yaxis={'tickformat': '.0%', 'range': [0, 100]}, template='plotly_white', height=500, hovermode='x unified', legend={ 'orientation': 'v', 'yanchor': 'top', 'y': 1, 'xanchor': 'left', 'x': 1.02, 'font': {'size': 10, 'family': 'Segoe UI'}, }, margin={'l': 60, 'r': 160, 't': 60, 'b': 40}, ) return fig
[docs] def create_decomposition_bar_chart( total_pct: pd.Series, ) -> go.Figure: """Create a horizontal bar chart of end-of-period factor contributions. Replicates FactorModelAttribution.last_day_decomposition() in Plotly. """ s = total_pct.sort_values() colors = ['#DC2626' if v < 0 else '#059669' for v in s.values] fig = go.Figure(go.Bar( x=s.values, y=s.index, orientation='h', marker_color=colors, text=[f"{v:.2%}" for v in s.values], textposition='outside', hovertemplate='<b>%{y}</b><br>Contribution: %{x:.2%}<extra></extra>', )) fig.update_layout( title="Factor Contributions to Strategy Returns", xaxis_title="Contribution", xaxis={'tickformat': '.0%'}, template='plotly_white', height=max(350, len(s) * 45 + 100), autosize=True, margin={'l': 180, 'r': 80, 't': 60, 'b': 40}, ) return fig
[docs] def create_brinson_fachler_chart( brinson_fachler_indexes: pd.DataFrame, ) -> go.Figure: """Create the 3-panel Brinson-Fachler attribution chart. Replicates BrinstonFachlerAttribution.attribution_plots() in Plotly. Panels: 1. Strategy vs Benchmark Returns 2. Cumulative Alpha 3. Allocation, Selection, Interaction Effects """ df = brinson_fachler_indexes fig = make_subplots( rows=3, cols=1, subplot_titles=( "Strategy vs Benchmark Returns", "Cumulative Alpha", "Allocation, Selection and Interaction Effects", ), shared_xaxes=True, vertical_spacing=0.08, ) for i, col in enumerate(['portfolio_returns', 'benchmark_returns']): if col in df.columns: fig.add_trace( go.Scatter( x=df.index, y=df[col], mode='lines', name=col, line={'color': CHART_COLORS[i], 'width': 2}, ), row=1, col=1, ) if 'alpha' in df.columns: fig.add_trace( go.Scatter( x=df.index, y=df['alpha'], mode='lines', name='alpha', line={'color': CHART_COLORS[2], 'width': 2}, ), row=2, col=1, ) for i, col in enumerate(['allocation', 'selection', 'interaction']): if col in df.columns: fig.add_trace( go.Scatter( x=df.index, y=df[col], mode='lines', name=col, line={'color': CHART_COLORS[i + 3], 'width': 2}, ), row=3, col=1, ) for row in range(1, 4): fig.add_hline(y=0, line_dash="solid", line_color="black", line_width=1, row=row, col=1) fig.update_yaxes(tickformat='.2%', row=row, col=1) fig.update_layout( height=900, template='plotly_white', hovermode='x unified', legend={ 'orientation': 'v', 'yanchor': 'top', 'y': 1, 'xanchor': 'left', 'x': 1.02, 'font': {'size': 10, 'family': 'Segoe UI'}, }, margin={'l': 60, 'r': 160, 't': 60, 'b': 40}, ) return fig
[docs] def create_returns_distribution( portfolio_returns: pd.Series, ) -> go.Figure: """Create returns distribution analysis (histogram + time series).""" returns = portfolio_returns.dropna() fig = make_subplots( rows=1, cols=2, subplot_titles=('Returns Distribution', 'Returns Over Time'), column_widths=[0.4, 0.6], ) fig.add_trace( go.Histogram( x=returns, nbinsx=50, name='Returns Distribution', marker={'color': PROFESSIONAL_COLORS["kaxanuk_azul"], 'opacity': 0.7}, hovertemplate='Return: %{x:.2%}<br>Count: %{y}<extra></extra>', ), row=1, col=1, ) fig.add_trace( go.Scatter( x=returns.index, y=returns, mode='lines', name='Daily Returns', line={'color': PROFESSIONAL_COLORS["kaxanuk_azul"], 'width': 1}, hovertemplate='Date: %{x}<br>Return: %{y:.2%}<extra></extra>', ), row=1, col=2, ) fig.add_hline(y=0, line_dash="dash", line_color="red", row=1, col=2) fig.update_layout( height=400, template='plotly_white', showlegend=False, ) fig.update_xaxes(title_text="Return", row=1, col=1) fig.update_yaxes(title_text="Frequency", row=1, col=1) fig.update_xaxes(title_text="Date", row=1, col=2) fig.update_yaxes(title_text="Return", row=1, col=2, tickformat='.1%') return fig
[docs] def create_drawdown_chart( portfolio_returns: pd.Series, ) -> go.Figure: """Create drawdown analysis chart.""" returns = portfolio_returns.dropna() cumulative = (1 + returns).cumprod() rolling_max = cumulative.expanding().max() drawdown = cumulative / rolling_max - 1 fig = go.Figure() fig.add_trace(go.Scatter( x=drawdown.index, y=drawdown, fill='tozeroy', fillcolor='rgba(255, 0, 0, 0.3)', line={'color': 'red', 'width': 2}, name='Portfolio Drawdown', hovertemplate='Date: %{x}<br>Drawdown: %{y:.2%}<extra></extra>', )) fig.add_hline(y=0, line_dash="solid", line_color="black", line_width=1) fig.update_layout( title="Drawdown Analysis", xaxis_title="Date", yaxis_title="Drawdown", yaxis={'tickformat': '.1%'}, template='plotly_white', height=400, legend={ 'orientation': 'h', 'yanchor': 'bottom', 'y': 1.02, 'xanchor': 'right', 'x': 1, }, ) return fig