Source code for kaxanuk.attribution_analysis.performance_attribution

"""
Performance Attribution Pipeline
=================================

Main orchestration module for running multi-method portfolio performance
attribution analysis.

This module provides the top-level ``main`` function that coordinates the
full attribution workflow: loading market and portfolio data, aligning date
ranges, executing Brinson-Fachler and/or factor-model attribution, and
optionally launching an interactive dashboard.

Notes
-----
The pipeline currently supports two attribution methodologies that can be
enabled independently via the ``Configuration`` object:

* **Brinson-Fachler** -- decomposes active return into allocation and
  selection effects using sector/asset weights.
* **Factor Model** -- regresses portfolio returns against
  user-supplied factor return series and decomposes performance into
  factor exposures plus a residual (alpha).

All intermediate computations use PyArrow columnar tables for memory
efficiency and vectorized operations.

Examples
--------
>>> from kaxanuk.attribution_analysis.entities.configuration import Configuration
>>> from kaxanuk.attribution_analysis.performance_attribution import main
>>>
>>> config = Configuration(
...     input_directory="data/input",
...     weights_portfolio_directory="weights/portfolio",
...     weights_benchmark_directory="weights/benchmark",
...     benchmark_returns_directory="returns/benchmark",
...     investable_assets_directory="data/prices",
...     factor_returns_by_factor_directory="data/factors",
...     portfolio_file_name="portfolio_weights.csv",
...     benchmark_file_name="benchmark_weights.csv",
...     benchmark_return_file_name="benchmark_returns.csv",
...     user_column_date="date",
...     user_column_price="close",
...     market_data_input_format="csv",
...     portfolio_input_format="csv",
...     start_date="auto",
...     end_date="auto",
...     brinson_fachler_method=True,
...     factor_model_method=True,
... )
>>> main(config, launch_dashboard=True, dashboard_port=8050)
"""

import logging

import pyarrow as pa
import pyarrow.compute as pc
from pathlib import Path

from kaxanuk.attribution_analysis.interfaces import (
    FactorModelArrowAttribution,
    BrinstonFachlerArrowAttribution
)
from kaxanuk.attribution_analysis.entities.configuration import Configuration
from kaxanuk.attribution_analysis.input_handlers.csv_input import CsvInput
from kaxanuk.attribution_analysis.input_handlers.parquet_input import ParquetInput
from kaxanuk.attribution_analysis.input_handlers.csv_portfolio_input_handler import CsvPortfolioInputHandler
from kaxanuk.attribution_analysis.input_handlers.excel_portfolio_input_handler import ExcelPortfolioInputHandler
from kaxanuk.attribution_analysis.market_data_pipeline.market_data_service import MarketDataService
from kaxanuk.attribution_analysis.market_data_pipeline.field_definitions import StandardField
from kaxanuk.attribution_analysis.portfolio_data_pipeline.portfolio_service import PortfolioService
from kaxanuk.attribution_analysis.entities.returns_table import ReturnsTable
from kaxanuk.attribution_analysis.entities.portfolio_weights_arrow import PortfolioWeights
from kaxanuk.attribution_analysis.entities.factor_returns import FactorReturns
from kaxanuk.attribution_analysis.modules.table_conversion_arrow import (
    pyarrow_prices_to_attribution_format,
    portfolio_table_to_arrow,
    returns_table_to_arrow,
    compute_returns_from_prices_arrow,
    _filter_arrow_table_by_date_range,
)
from kaxanuk.attribution_analysis.modules.data_wrangling import (
    load_by_factor_returns_arrow,
)
from kaxanuk.attribution_analysis.modules.performance_functions import (
    summary_stats,
)
from kaxanuk.attribution_analysis.services.license_validator import validate_license

# Note: the Dash dashboard is imported lazily inside ``main`` (only when
# ``launch_dashboard=True``) so that importing this module — and therefore
# ``import kaxanuk.attribution_analysis`` — does not pull in Dash.

logger = logging.getLogger("kaxanuk.attribution_analysis")


[docs] def main( configuration: Configuration, *, launch_dashboard: bool = True, dashboard_port: int, ) -> None: """ Run the full performance attribution pipeline. Orchestrates data loading, date-range alignment, attribution computation, and optional dashboard visualisation for a portfolio defined by ``configuration``. Parameters ---------- configuration : Configuration Frozen dataclass holding all runtime settings including: * **Directory paths** -- ``input_directory``, ``weights_portfolio_directory``, ``weights_benchmark_directory``, ``benchmark_returns_directory``, ``investable_assets_directory``, ``factor_returns_by_factor_directory``. * **File names** -- ``portfolio_file_name``, ``benchmark_file_name``, ``benchmark_return_file_name``. * **Column mappings** -- ``user_column_date``, ``user_column_price``. * **Input formats** -- ``market_data_input_format`` (``"csv"`` | ``"parquet"``), ``portfolio_input_format`` (``"csv"`` | ``"excel"``). * **Date range** -- ``start_date``, ``end_date`` (``datetime.date`` | ``"auto"``). * **Method flags** -- ``brinson_fachler_method``, ``factor_model_method`` (both ``bool``). launch_dashboard : bool, default True If ``True`` and the factor-model method is enabled, launch a Dash web application displaying interactive attribution charts and summary statistics. When ``False``, static matplotlib plots are generated instead. dashboard_port : int TCP port on which the Dash server listens when ``launch_dashboard=True``. Required (no default); typically read from the configuration via ``ExcelConfigurator.get_dashboard_port()``. Returns ------- None All outputs are side-effects: log messages, plots, and/or a running Dash server. Raises ------ LicenseError Raised before anything is read when the license does not validate (``services.license_validator.validate_license()``): one of ``LicenseNotFoundError``, ``LicenseValidationError``, ``LicenseServerError`` or ``LicenseConfigurationError``. FileNotFoundError If any configured data directory or file does not exist. ConfigurationError If ``configuration`` contains invalid field values (raised during ``Configuration.__post_init__``). Notes ----- The function aligns all input tables (asset returns, portfolio weights, benchmark weights, benchmark returns, and factor returns) to the intersection of their date ranges before running any attribution calculation. This prevents shape mismatches and ensures consistent time-series indexing across methods. The pipeline proceeds in four stages: 1. **Data loading** -- market prices, portfolio/benchmark weights, benchmark returns, and per-factor return series are read from disk using the appropriate input handlers. 2. **Date alignment** -- the common start/end dates across all tables are computed and each table is filtered accordingly. 3. **Attribution** -- enabled methods (Brinson-Fachler, Factor Model) are executed sequentially. 4. **Visualisation** -- results are either plotted (matplotlib) or served via an interactive Dash dashboard. Examples -------- Run attribution without the dashboard (useful in batch/CI contexts): >>> main(config, launch_dashboard=False) Run only the Brinson-Fachler method with the dashboard disabled: >>> config = Configuration(..., brinson_fachler_method=True, ... factor_model_method=False) >>> main(config, launch_dashboard=False) """ validate_license() input_route = Path(configuration.input_directory) # --- Build input handlers --- if configuration.market_data_input_format == "parquet": market_handlers = [ ParquetInput(input_dir=configuration.investable_assets_directory) ] else: market_handlers = [ CsvInput(input_dir=configuration.investable_assets_directory) ] if configuration.portfolio_input_format == "excel": portfolio_handlers = [ ExcelPortfolioInputHandler(base_dir=str(input_route / configuration.weights_portfolio_directory)) ] benchmark_weight_handlers = [ ExcelPortfolioInputHandler(base_dir=str(input_route / configuration.weights_benchmark_directory)) ] else: portfolio_handlers = [ CsvPortfolioInputHandler(base_dir=str(input_route / configuration.weights_portfolio_directory)) ] benchmark_weight_handlers = [ CsvPortfolioInputHandler(base_dir=str(input_route / configuration.weights_benchmark_directory)) ] benchmark_return_handlers = [ CsvPortfolioInputHandler(base_dir=str(input_route / configuration.benchmark_returns_directory)) ] market_data_service = MarketDataService() portfolio_service = PortfolioService() # --- Load data --- # The small weight/returns files (portfolio, benchmark weights, benchmark # returns) are loaded and date-validated first, against the configured # start/end dates. This fails fast on a misaligned date range before the # (often much heavier) per-ticker market data and factor returns are read. logger.info("Loading data") portfolio_weights_table = portfolio_service.load_portfolio( portfolio_handlers=portfolio_handlers, file_name=configuration.portfolio_file_name, start_date=configuration.start_date, end_date=configuration.end_date, ) benchmark_weights_table = portfolio_service.load_portfolio( portfolio_handlers=benchmark_weight_handlers, file_name=configuration.benchmark_file_name, start_date=configuration.start_date, end_date=configuration.end_date, ) benchmark_returns_table = portfolio_service.load_portfolio( portfolio_handlers=benchmark_return_handlers, file_name=configuration.benchmark_return_file_name, start_date=configuration.start_date, end_date=configuration.end_date, ) portfolio_weights_arrow = portfolio_table_to_arrow(portfolio_weights_table) benchmark_weights_arrow = portfolio_table_to_arrow(benchmark_weights_table) benchmark_returns_arrow = returns_table_to_arrow(benchmark_returns_table) tickers = set(portfolio_weights_arrow.asset_names) prices_table = market_data_service.load_and_concat_prices( input_handlers=market_handlers, tickers=tickers, configuration=configuration, ) prices_arrow = pyarrow_prices_to_attribution_format(prices_table, date_column=StandardField.DATE.value) returns_investible_assets_arrow = compute_returns_from_prices_arrow(prices_arrow, date_column="date") available_factors = [ entry.name for entry in Path(configuration.factor_returns_by_factor_directory).iterdir() ] factor_start_date = None if configuration.start_date == "auto" else configuration.start_date factor_end_date = None if configuration.end_date == "auto" else configuration.end_date by_factor_factor_returns_arrow = load_by_factor_returns_arrow( path=configuration.factor_returns_by_factor_directory, available_factors=available_factors, portfolio_weights=portfolio_weights_arrow, start_date=factor_start_date, end_date=factor_end_date, ) # --- Align all tables to the same date range --- _date_col = "date" all_start_dates = [ pc.min(returns_investible_assets_arrow.table.column(_date_col)).as_py(), pc.min(portfolio_weights_arrow.table.column(_date_col)).as_py(), pc.min(benchmark_weights_arrow.table.column(_date_col)).as_py(), pc.min(benchmark_returns_arrow.table.column(_date_col)).as_py(), *(pc.min(fr.table.column(_date_col)).as_py() for fr in by_factor_factor_returns_arrow.values()), ] all_end_dates = [ pc.max(returns_investible_assets_arrow.table.column(_date_col)).as_py(), pc.max(portfolio_weights_arrow.table.column(_date_col)).as_py(), pc.max(benchmark_weights_arrow.table.column(_date_col)).as_py(), pc.max(benchmark_returns_arrow.table.column(_date_col)).as_py(), *(pc.max(fr.table.column(_date_col)).as_py() for fr in by_factor_factor_returns_arrow.values()), ] common_start = max(all_start_dates) common_end = min(all_end_dates) common_start_arrow = pa.scalar(common_start, type=pa.timestamp("ns")) common_end_arrow = pa.scalar(common_end, type=pa.timestamp("ns")) returns_investible_assets_arrow = ReturnsTable( table=_filter_arrow_table_by_date_range( returns_investible_assets_arrow.table, common_start_arrow, common_end_arrow ) ) portfolio_weights_arrow = PortfolioWeights( table=_filter_arrow_table_by_date_range( portfolio_weights_arrow.table, common_start_arrow, common_end_arrow ) ) benchmark_weights_arrow = PortfolioWeights( table=_filter_arrow_table_by_date_range( benchmark_weights_arrow.table, common_start_arrow, common_end_arrow ) ) benchmark_returns_arrow = ReturnsTable( table=_filter_arrow_table_by_date_range( benchmark_returns_arrow.table, common_start_arrow, common_end_arrow ) ) by_factor_factor_returns_arrow = { factor: FactorReturns( table=_filter_arrow_table_by_date_range(fr.table, common_start_arrow, common_end_arrow), factor_name=factor, ) for factor, fr in by_factor_factor_returns_arrow.items() } logger.info( "Aligned all tables to common date range: %s to %s", common_start.strftime('%Y-%m-%d'), common_end.strftime('%Y-%m-%d'), ) # --- Brinson-Fachler attribution --- brinson_fachler_indexes_arrow = None if configuration.brinson_fachler_method: logger.info("Running Brinson-Fachler attribution") brinston_fachler_arrow_result = BrinstonFachlerArrowAttribution( returns_investable_assets=returns_investible_assets_arrow.table, complete_portfolio_weights=portfolio_weights_arrow.table, complete_benchmark_weights=benchmark_weights_arrow.table, date_column=_date_col, ) brinston_fachler_arrow_result.time_series_calculation() brinston_fachler_arrow_result.attribution_plots() brinson_fachler_indexes_arrow = brinston_fachler_arrow_result.brinston_fach_indexes # --- Factor model attribution --- factor_model_arrow = None if configuration.factor_model_method: logger.info("Running factor model attribution") factor_model_arrow = FactorModelArrowAttribution( daily_portfolio_weights=portfolio_weights_arrow.table, by_factor_factor_returns={ name: fr.table for name, fr in by_factor_factor_returns_arrow.items() }, asset_returns=returns_investible_assets_arrow.table, benchmark_returns=benchmark_returns_arrow.table, date_column=_date_col, ) factor_model_arrow.run() if not launch_dashboard: logger.info("Plotting factor model results") factor_model_arrow.attribution_plots(title='Factor Model Attribution') # --- Dashboard --- if launch_dashboard and factor_model_arrow is not None: from kaxanuk.attribution_analysis.dashboard.attribution_dashboard import ( # noqa: PLC0415 create_dashboard_from_attribution, ) logger.info("Preparing dashboard data") factor_model_arrow.calc_pct_area() total_pct_decomposition = factor_model_arrow.cummulative_pct_decomp() pr_series = factor_model_arrow.portfolio_returns.column("portfolio_returns").to_pandas() stats_df = summary_stats(pr_series) stats_dict = {col: float(stats_df[col].iloc[0]) for col in stats_df.columns} attribution_results = { 'portfolio_returns': factor_model_arrow.portfolio_returns, 'simulated_rets': factor_model_arrow.simulated_rets, 'portfolio_attribution_ts': factor_model_arrow.portfolio_attribution_ts, 'pct_df_returns': factor_model_arrow.pct_df_returns, 'total_pct_decomposition': total_pct_decomposition, 'summary_stats': stats_dict, 'brinson_fachler_indexes': brinson_fachler_indexes_arrow, } dashboard = create_dashboard_from_attribution( attribution_results=attribution_results, portfolio_name=getattr(configuration, 'portfolio_file_name', 'Portfolio'), benchmark_name=getattr(configuration, 'benchmark_file_name', None), days_of_smoothing=45, ) logger.info("Launching dashboard on port %s", dashboard_port) dashboard.run_server(port=dashboard_port)