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