Source code for kaxanuk.attribution_analysis.interfaces.brinston_fachler_arrow_method

"""
Concrete Brinson-Fachler return attribution implementation using PyArrow.

Implements :class:`BrinstonFachlerArrowInterface` by decomposing active
portfolio return into allocation, selection, and interaction effects at
the asset level for each period, then aggregating across time.
"""

import pyarrow as pa
import pyarrow.compute as pc
import matplotlib.pyplot as plt

from kaxanuk.attribution_analysis.interfaces.brinston_fachler_arrow_interface import BrinstonFachlerArrowInterface


[docs] class BrinstonFachlerArrowAttribution(BrinstonFachlerArrowInterface): """ Brinson-Fachler return attribution implementation using PyArrow. Decomposes active portfolio return into allocation, selection, and interaction effects at the asset level for each period, then aggregates across time. Parameters ---------- returns_investable_assets : pa.Table Asset returns with a ``date_column`` and one float64 column per asset. complete_portfolio_weights : pa.Table Portfolio weights with a ``date_column`` and one float64 column per asset. Rows should sum to 1.0 for each date. complete_benchmark_weights : pa.Table Benchmark weights with a ``date_column`` and one float64 column per asset. Rows should sum to 1.0 for each date. date_column : str, default ``"date"`` Name of the date column common to all input tables. Attributes ---------- detail_table : pa.Table Per-asset detail from the most recent ``brinson_fachler_model`` call. totals : pa.Table Single-row totals from the most recent ``brinson_fachler_model`` call. df : pa.Table Concatenated totals time series after ``time_series_calculation``. output_dict : dict Mapping of date to per-asset detail table after ``time_series_calculation``. brinston_fach_indexes : pa.Table Cumulative attribution indexes after ``attribution_plots``. """ def __init__( self, returns_investable_assets: pa.Table, complete_portfolio_weights: pa.Table, complete_benchmark_weights: pa.Table, date_column: str = "date", ) -> None: self.returns_investable_assets = returns_investable_assets self.complete_portfolio_weights = complete_portfolio_weights self.complete_benchmark_weights = complete_benchmark_weights self.date_column = date_column def _get_asset_columns(self, table: pa.Table) -> list[str]: """ Return column names excluding the date column. Parameters ---------- table : pa.Table Any input table with a date column plus asset columns. Returns ------- list[str] Asset column names. """ return [name for name in table.column_names if name != self.date_column] def _filter_row(self, table: pa.Table, date) -> dict[str, float]: """ Extract a single date's row as a dictionary. Parameters ---------- table : pa.Table Table to filter. date : scalar Date value to match against the date column. Returns ------- dict[str, float] Mapping of asset name to its value for the given date. Returns an empty dict if the date is not found. """ mask = pc.equal(table.column(self.date_column), date) filtered = table.filter(mask) if filtered.num_rows == 0: return {} result = {} for name in self._get_asset_columns(table): val = filtered.column(name)[0].as_py() result[name] = val if val is not None else 0.0 return result
[docs] def brinson_fachler_model(self, date: str) -> tuple[pa.Table, pa.Table]: """ Calculate the Brinson-Fachler attribution for a single date. Decomposes the active return into allocation, selection, and interaction effects for every asset, then sums to produce portfolio-level totals. Parameters ---------- date : scalar Target date matching a value in the date column. Returns ------- detail_table : pa.Table Per-asset breakdown with columns: ``asset``, ``portfolio_weights``, ``benchmark_weights``, ``returns_data``, ``portfolio_returns``, ``benchmark_returns``, ``alpha``, ``allocation``, ``selection``, ``interaction``. totals_table : pa.Table Single-row aggregate with columns: ``date``, ``portfolio_returns``, ``benchmark_returns``, ``alpha``, ``allocation``, ``selection``, ``interaction``. """ assets = self._get_asset_columns(self.returns_investable_assets) r_dict = self._filter_row(self.returns_investable_assets, date) w_p_dict = self._filter_row(self.complete_portfolio_weights, date) w_b_dict = self._filter_row(self.complete_benchmark_weights, date) r = pa.array([r_dict.get(a, 0.0) for a in assets], type=pa.float64()) w_p = pa.array([w_p_dict.get(a, 0.0) for a in assets], type=pa.float64()) w_b = pa.array([w_b_dict.get(a, 0.0) for a in assets], type=pa.float64()) r_p = pc.multiply(w_p, r) r_b = pc.multiply(w_b, r) alpha_index = pc.subtract(r_p, r_b) allocation = pc.multiply(pc.subtract(w_p, w_b), r_b) selection = pc.multiply(alpha_index, w_p) interaction = pc.subtract(pc.subtract(alpha_index, allocation), selection) detail_table = pa.table({ "asset": pa.array(assets, type=pa.string()), "portfolio_weights": w_p, "benchmark_weights": w_b, "returns_data": r, "portfolio_returns": r_p, "benchmark_returns": r_b, "alpha": alpha_index, "allocation": allocation, "selection": selection, "interaction": interaction, }) totals_table = pa.table({ "date": pa.array([date]), "portfolio_returns": pa.array([pc.sum(r_p).as_py()], type=pa.float64()), "benchmark_returns": pa.array([pc.sum(r_b).as_py()], type=pa.float64()), "alpha": pa.array([pc.sum(alpha_index).as_py()], type=pa.float64()), "allocation": pa.array([pc.sum(allocation).as_py()], type=pa.float64()), "selection": pa.array([pc.sum(selection).as_py()], type=pa.float64()), "interaction": pa.array([pc.sum(interaction).as_py()], type=pa.float64()), }) self.detail_table = detail_table self.totals = totals_table return detail_table, totals_table
[docs] def time_series_calculation(self) -> None: """ Run Brinson-Fachler attribution across all common dates. Finds dates present in both portfolio and benchmark weights, runs ``brinson_fachler_model`` for each date that also has return data, and concatenates results. Sets Attributes --------------- df : pa.Table Concatenated totals time series with columns: ``date``, ``portfolio_returns``, ``benchmark_returns``, ``alpha``, ``allocation``, ``selection``, ``interaction``. output_dict : dict Mapping of each date to its per-asset ``pa.Table`` detail. """ dates_portfolio = self.complete_portfolio_weights.column(self.date_column) dates_benchmark = self.complete_benchmark_weights.column(self.date_column) dates_returns = self.returns_investable_assets.column(self.date_column) portfolio_set = set(dates_portfolio.to_pylist()) benchmark_set = set(dates_benchmark.to_pylist()) returns_set = set(dates_returns.to_pylist()) common_dates = sorted(portfolio_set.intersection(benchmark_set)) output_dict = {} totals_list = [] for date in common_dates: if date in returns_set: detail_table, totals_table = self.brinson_fachler_model(date) output_dict[date] = detail_table totals_list.append(totals_table) if totals_list: self.df = pa.concat_tables(totals_list) else: self.df = pa.table({ "date": pa.array([], type=pa.timestamp("ns")), "portfolio_returns": pa.array([], type=pa.float64()), "benchmark_returns": pa.array([], type=pa.float64()), "alpha": pa.array([], type=pa.float64()), "allocation": pa.array([], type=pa.float64()), "selection": pa.array([], type=pa.float64()), "interaction": pa.array([], type=pa.float64()), }) self.output_dict = output_dict
[docs] def attribution_plots(self) -> None: """ Plot cumulative Brinson-Fachler attribution effects. Produces a 3-panel figure: 1. Cumulative portfolio vs benchmark returns. 2. Cumulative alpha. 3. Cumulative allocation, selection, and interaction effects. Must be called after ``time_series_calculation``. Sets Attributes --------------- brinston_fach_indexes : pa.Table Cumulative sums of all attribution columns, keyed by date. """ date_col = self.df.column("date").to_pylist() numeric_cols = ["portfolio_returns", "benchmark_returns", "alpha", "allocation", "selection", "interaction"] cumulative = {} for col_name in numeric_cols: col = self.df.column(col_name) cumulative[col_name] = pc.cumulative_sum(col, skip_nulls=True).to_pylist() self.brinston_fach_indexes = pa.table( {"date": pa.array(date_col), **{k: pa.array(v, type=pa.float64()) for k, v in cumulative.items()}} ) _fig, axes = plt.subplots(3, 1, figsize=(10, 14)) axes[0].plot(date_col, cumulative["portfolio_returns"], label="portfolio_returns") axes[0].plot(date_col, cumulative["benchmark_returns"], label="benchmark_returns") axes[0].set_title("Strategy vs Benchmark Returns") axes[0].legend() axes[0].grid(visible=True) axes[1].plot(date_col, cumulative["alpha"], label="alpha") axes[1].set_title("Cumulative Alpha") axes[1].legend() axes[1].grid(visible=True) axes[2].plot(date_col, cumulative["allocation"], label="allocation") axes[2].plot(date_col, cumulative["selection"], label="selection") axes[2].plot(date_col, cumulative["interaction"], label="interaction") axes[2].set_title("Allocation, Selection and Interaction Effects") axes[2].legend() axes[2].grid(visible=True) plt.tight_layout(rect=[0, 0, 1, 0.95]) plt.show()