Source code for kaxanuk.attribution_analysis.modules.table_conversion_arrow

"""
Conversion utilities for reshaping PyArrow tables into the format expected
by the Brinson-Fachler Arrow attribution engine.

Provides functions equivalent to table_conversion.py but without converting
to pandas — all operations stay in PyArrow.
"""

import pyarrow as pa
import pyarrow.compute as pc

from kaxanuk.attribution_analysis.entities.portfolio_weights_arrow import PortfolioWeights
from kaxanuk.attribution_analysis.entities.returns_table import ReturnsTable


[docs] def pyarrow_prices_to_attribution_format( table: pa.Table, date_column: str, ) -> pa.Table: """ Reshape a prices table into the attribution format. The date column is cast to a ``timestamp`` type and all remaining (numeric) columns are cast to ``float64``. Parameters ---------- table : pyarrow.Table Prices table containing a date column and one or more numeric asset price columns. date_column : str Name of the column in `table` containing the dates. It's cast from a ``"%Y-%m-%d"`` string (if not already a timestamp type) and always renamed to ``"date"`` in the output, regardless of its original name. Returns ------- pyarrow.Table Table with columns ``[date, asset1, asset2, ...]``, where ``date`` is of ``timestamp("ns")`` type and every asset column is ``float64``. """ date_col = table.column(date_column) if not pa.types.is_timestamp(date_col.type): date_col = pc.cast(pc.strptime(date_col.cast(pa.string()), format="%Y-%m-%d", unit="ns"), pa.timestamp("ns")) columns = [date_col] column_names = ["date"] for name in table.column_names: if name == date_column: continue col = table.column(name) col = pc.cast(col, pa.float64()) columns.append(col) column_names.append(name) return pa.table(columns, names=column_names)
[docs] def portfolio_table_to_arrow(table: pa.Table) -> PortfolioWeights: """ Transpose a wide portfolio/benchmark weights table into attribution format. This is the PyArrow equivalent of ``portfolio_table_to_pandas``, which does ``set_index("Ticker").T``. Parameters ---------- table : pyarrow.Table Wide-format weights table with columns ``[Ticker, date1, date2, ...]``, one row per ticker and one column per date. Date column names are parsed as ``"%Y-%m-%d"``, falling back to ``"%d/%m/%Y"`` if that fails. Returns ------- PortfolioWeights Wrapper around a table with columns ``[date, asset1, asset2, ...]``, i.e. one row per date and one column per ticker. """ tickers = table.column("Ticker").to_pylist() date_columns = [name for name in table.column_names if name != "Ticker"] date_strings = date_columns try: dates = pc.strptime(pa.array(date_strings, type=pa.string()), format="%Y-%m-%d", unit="ns") except (pa.ArrowInvalid, pa.ArrowNotImplementedError, ValueError): dates = pc.strptime(pa.array(date_strings, type=pa.string()), format="%d/%m/%Y", unit="ns") asset_arrays = {} for i, ticker in enumerate(tickers): row_values = [] for date_col_name in date_columns: col = table.column(date_col_name) val = col[i].as_py() row_values.append(float(val) if val is not None else None) asset_arrays[ticker] = pa.array(row_values, type=pa.float64()) columns = [dates] column_names = ["date"] for ticker in tickers: columns.append(asset_arrays[ticker]) column_names.append(ticker) return PortfolioWeights(table=pa.table(columns, names=column_names))
[docs] def compute_returns_from_prices_arrow( prices_table: pa.Table, date_column: str, ) -> ReturnsTable: """ Compute percentage returns from a prices table. Equivalent to ``prices_df.pct_change(fill_method=None)`` — the first row is kept with null values so the date range stays aligned with the original prices table (and therefore with the weights tables). Parameters ---------- prices_table : pyarrow.Table Prices table containing a date column and one or more numeric asset price columns. date_column : str Name of the column in `prices_table` containing the dates. It's passed through unchanged and always renamed to ``"date"`` in the output, regardless of its original name. Returns ------- ReturnsTable Wrapper around a table with the same columns and row count as `prices_table`, where each asset column holds period-over-period percentage returns and the first row is null. """ date_col = prices_table.column(date_column) result_columns = [date_col] result_names = ["date"] for name in prices_table.column_names: if name == date_column: continue col = prices_table.column(name) previous = col.slice(0, len(col) - 1) current = col.slice(1) returns = pc.divide( pc.subtract(current, previous), previous ) if isinstance(returns, pa.ChunkedArray): returns = returns.combine_chunks() first_null = pa.array([None], type=pa.float64()) returns_with_null = pa.concat_arrays([first_null, returns]) result_columns.append(returns_with_null) result_names.append(name) return ReturnsTable(table=pa.table(result_columns, names=result_names))
[docs] def returns_table_to_arrow(table: pa.Table) -> ReturnsTable: """ Transpose a wide returns table into attribution format. Same transpose logic as :func:`portfolio_table_to_arrow`, but wraps the result in a :class:`ReturnsTable` (no weight constraints). Parameters ---------- table : pyarrow.Table Wide-format returns table with columns ``[Ticker, date1, date2, ...]``, one row per ticker and one column per date. Date column names are parsed as ``"%Y-%m-%d"``, falling back to ``"%d/%m/%Y"`` if that fails. Returns ------- ReturnsTable Wrapper around a table with columns ``[date, asset1, asset2, ...]``, i.e. one row per date and one column per ticker. """ tickers = table.column("Ticker").to_pylist() date_columns = [name for name in table.column_names if name != "Ticker"] try: dates = pc.strptime(pa.array(date_columns, type=pa.string()), format="%Y-%m-%d", unit="ns") except (pa.ArrowInvalid, pa.ArrowNotImplementedError, ValueError): dates = pc.strptime(pa.array(date_columns, type=pa.string()), format="%d/%m/%Y", unit="ns") asset_arrays = {} for i, ticker in enumerate(tickers): row_values = [] for date_col_name in date_columns: col = table.column(date_col_name) val = col[i].as_py() row_values.append(float(val) if val is not None else None) asset_arrays[ticker] = pa.array(row_values, type=pa.float64()) columns = [dates] column_names = ["date"] for ticker in tickers: columns.append(asset_arrays[ticker]) column_names.append(ticker) return ReturnsTable(table=pa.table(columns, names=column_names))
def _filter_arrow_table_by_date_range( table: pa.Table, start: pa.Scalar, end: pa.Scalar, date_col: str = "date", ) -> pa.Table: """ Filter a table to rows whose date column falls within an inclusive range. Parameters ---------- table : pyarrow.Table Table to filter. Must contain a column named `date_col`. start : pyarrow.Scalar Earliest date to include (inclusive). Must be comparable to the `date_col` column's type (e.g. a ``timestamp("ns")`` scalar). end : pyarrow.Scalar Latest date to include (inclusive). Must be comparable to the `date_col` column's type. date_col : str, default "date" Name of the date column to filter on. Returns ------- pyarrow.Table A new table containing only the rows where `date_col` is between `start` and `end`, inclusive. """ col = table.column(date_col) mask = pc.and_(pc.greater_equal(col, start), pc.less_equal(col, end)) return table.filter(mask)