Source code for kaxanuk.attribution_analysis.modules.attribution_visualizers_arrow

"""
Attribution visualization module — Plotly charts using PyArrow tables.

Provides chart-creation functions that return Plotly Figure objects
for use in the Dash dashboard, without any pandas dependency.
All data extraction uses pa.Table / pyarrow.compute and plain Python lists.
"""

import math

import plotly.graph_objects as go
import pyarrow as pa
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


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _ffill(values: list) -> list:
    """Forward-fill None / NaN values in a Python list."""
    result = []
    last_valid = 0.0
    for v in values:
        if v is None or (isinstance(v, float) and math.isnan(v)):
            result.append(last_valid)
        else:
            last_valid = v
            result.append(v)
    return result


def _rolling_mean(values: list, window: int) -> list:
    """Simple rolling mean. Returns None for the leading warm-up period."""
    n = len(values)
    result = [None] * n
    if window < 1 or n == 0:
        return result
    running_sum = 0.0
    count = 0
    for i in range(n):
        v = values[i] if values[i] is not None else 0.0
        running_sum += v
        count += 1
        if count > window:
            old = values[i - window] if values[i - window] is not None else 0.0
            running_sum -= old
            count -= 1
        if count >= window:
            result[i] = running_sum / window
    return result


def _get_non_date_columns(table: pa.Table, date_column: str) -> list[str]:
    return [c for c in table.column_names if c != date_column]


def _extract_clean_series(
    table: pa.Table,
    date_column: str,
    value_column: str,
) -> tuple[list, list]:
    """Return (dates, values) with null rows removed."""
    dates = table.column(date_column).to_pylist()
    values = table.column(value_column).to_pylist()
    clean_dates, clean_values = [], []
    for d, v in zip(dates, values, strict=False):
        if v is not None:
            clean_dates.append(d)
            clean_values.append(v)
    return clean_dates, clean_values


# ---------------------------------------------------------------------------
# Chart functions
# ---------------------------------------------------------------------------

[docs] def create_factor_attribution_chart( simulated_rets: pa.Table, date_column: str = "date", ) -> 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) """ dates = simulated_rets.column(date_column).to_pylist() all_cols = _get_non_date_columns(simulated_rets, date_column) totals_cols = [c for c in ["f_total_excess_returns", "f_total_factor_returns", "f_idyo_returns"] if c in all_cols] style_cols = [c for c in ["f_size", "f_momentum", "f_beta", "f_residual volatility", "f_value"] if c in all_cols] industry_candidates = [ "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", ] industry_cols = [] for c in industry_candidates: if c in all_cols: col_values = simulated_rets.column(c).to_pylist() if any(v != 0 and v is not None for v in col_values): industry_cols.append(c) 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=dates, y=simulated_rets.column(col).to_pylist(), 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=dates, y=simulated_rets.column(col).to_pylist(), 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=dates, y=simulated_rets.column(col).to_pylist(), 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: pa.Table, date_column: str = "date", ) -> go.Figure: """Create a compact attribution chart showing just the 3 main series. Used in the main charts row (large chart). """ dates = simulated_rets.column(date_column).to_pylist() all_cols = _get_non_date_columns(simulated_rets, date_column) cols = [c for c in ["f_total_excess_returns", "f_total_factor_returns", "f_idyo_returns"] if c in all_cols] fig = go.Figure() for i, col in enumerate(cols): fig.add_trace(go.Scatter( x=dates, y=simulated_rets.column(col).to_pylist(), 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: pa.Table, date_column: str = "date", days_of_smoothing: int = 45, ) -> go.Figure: """Create a normalized stacked area chart of factor return decomposition. Each factor's share = abs(factor) / sum(abs(all factors)), always sums to 100%. Implemented with pure pyarrow + Python lists (no pandas). """ factor_cols = _get_non_date_columns(pct_df_returns, date_column) dates = pct_df_returns.column(date_column).to_pylist() n_rows = len(dates) # Extract and forward-fill each column columns_data = {} for col in factor_cols: columns_data[col] = _ffill(pct_df_returns.column(col).to_pylist()) # Compute absolute values and row-wise sums abs_total = [0.0] * n_rows abs_columns = {} for col in factor_cols: abs_vals = [abs(v) if v is not None else 0.0 for v in columns_data[col]] abs_columns[col] = abs_vals for i in range(n_rows): abs_total[i] += abs_vals[i] # Normalize: each column = abs(col) / abs_total pct_columns = {} for col in factor_cols: pct_columns[col] = [ abs_columns[col][i] / abs_total[i] if abs_total[i] != 0 else 0.0 for i in range(n_rows) ] # Rolling mean smoothed = {} for col in factor_cols: raw = _rolling_mean(pct_columns[col], days_of_smoothing) smoothed[col] = [v if v is not None else 0.0 for v in raw] # Build chart num_cols = len(factor_cols) colors = [CHART_COLORS[i % len(CHART_COLORS)] for i in range(num_cols)] fig = go.Figure() for i, col in enumerate(factor_cols): 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=dates, y=smoothed[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: dict, ) -> go.Figure: """Create a horizontal bar chart of end-of-period factor contributions. Parameters ---------- total_pct : dict {factor_name: contribution_fraction} — e.g. from FactorModelArrowAttribution.cummulative_pct_decomp(). """ sorted_items = sorted(total_pct.items(), key=lambda x: x[1]) names = [item[0] for item in sorted_items] values = [item[1] for item in sorted_items] colors = ['#DC2626' if v < 0 else '#059669' for v in values] fig = go.Figure(go.Bar( x=values, y=names, orientation='h', marker_color=colors, text=[f"{v:.2%}" for v in 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(values) * 45 + 100), autosize=True, margin={'l': 180, 'r': 80, 't': 60, 'b': 40}, ) return fig
[docs] def create_brinson_fachler_chart( brinson_fachler_indexes: pa.Table, date_column: str = "date", ) -> go.Figure: """Create the 3-panel Brinson-Fachler attribution chart. Panels: 1. Strategy vs Benchmark Returns 2. Cumulative Alpha 3. Allocation, Selection, Interaction Effects """ dates = brinson_fachler_indexes.column(date_column).to_pylist() all_cols = _get_non_date_columns(brinson_fachler_indexes, date_column) 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 all_cols: fig.add_trace( go.Scatter( x=dates, y=brinson_fachler_indexes.column(col).to_pylist(), mode='lines', name=col, line={'color': CHART_COLORS[i], 'width': 2}, ), row=1, col=1, ) if 'alpha' in all_cols: fig.add_trace( go.Scatter( x=dates, y=brinson_fachler_indexes.column('alpha').to_pylist(), 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 all_cols: fig.add_trace( go.Scatter( x=dates, y=brinson_fachler_indexes.column(col).to_pylist(), 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: pa.Table, date_column: str = "date", value_column: str = "portfolio_returns", ) -> go.Figure: """Create returns distribution analysis (histogram + time series).""" clean_dates, clean_returns = _extract_clean_series( portfolio_returns, date_column, value_column, ) 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=clean_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=clean_dates, y=clean_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: pa.Table, date_column: str = "date", value_column: str = "portfolio_returns", ) -> go.Figure: """Create drawdown analysis chart. Computes cumulative returns, running max, and drawdown entirely with Python lists (no pandas). """ clean_dates, clean_returns = _extract_clean_series( portfolio_returns, date_column, value_column, ) # Cumulative product: (1 + r_0) * (1 + r_1) * ... cumulative = [] cum_val = 1.0 for r in clean_returns: cum_val *= (1.0 + r) cumulative.append(cum_val) # Running (expanding) max running_max = [] max_val = float('-inf') for c in cumulative: max_val = max(max_val, c) running_max.append(max_val) # Drawdown = cumulative / running_max - 1 drawdown = [ (c / m - 1.0) if m != 0 else 0.0 for c, m in zip(cumulative, running_max, strict=False) ] fig = go.Figure() fig.add_trace(go.Scatter( x=clean_dates, 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