Factor Attribution

Contents

Factor Attribution#

In this tutorial we are going to show how to run a factor-based attribution (return and risk) using the Reporting API. This API is currently under active development.

The steps are as follows:

  • Uploading a set of portfolios

  • Organizing the portfolios into a hierarchy

  • Creating a factor model

  • Running a factor return attribution report

  • Pulling out different aggregations and drill-downs

  • Running a factor risk attribution report

Imports & Setup#

For this tutorial notebook, you will need to import the following packages.

import datetime as dt
import polars as pl

from bayesline.apiclient import BayeslineApiClient
from bayesline.api.equity import (
    FactorRiskModelSettings,
    UniverseSettings,
    ExposureSettings,
    ContinuousExposureGroupSettings,
    CategoricalExposureGroupSettings,
    ModelConstructionSettings,
    PortfolioHierarchySettings,
    ReturnAttributionReportSettings,
    XSRReportSettings,
)

We will also need to have a Bayesline API client configured.

bln = BayeslineApiClient.new_client(
    endpoint="https://[ENDPOINT]",
    api_key="[API-KEY]",
)

We will first upload a portfolio and set a hierarchy. The steps followed here are the same as in the Portfolio Hierarchies Tutorial.

uploader = bln.equity.uploaders.get_data_type("portfolios").create_or_replace_dataset("portfolio-attribution-demo")
portfolio_df = pl.DataFrame({
    "portfolio_id": [
        "AGTHX", "AGTHX", "AGTHX", "AGTHX",
        "FCNTX", "FCNTX",
        "VADGX", "VADGX", "VADGX", "VADGX", "VADGX", "VADGX",
        "SPX", "SPX", "SPX", "SPX", "SPX", "SPX", "SPX", "SPX",
    ],
    "asset_id": [
        # AGTHX
        "02079K305", "02079K305", 
        "2588173", "2588173",

        # FCNTX
        "67066G10", "67066G10",

        # VADGX
        "02079K305", "02079K305", 
        "2588173", "2588173", 
        "67066G10", "67066G10",

        # SPX
        "02079K305", "02079K305", 
        "2588173", "2588173",
        "67066G10", "67066G10",
        "85371710", "85371710",

    ],  
    "asset_id_type": [
        # AGTHX
        "cusip9", "cusip9", "sedol7", "sedol7",

        # FCNTX
        "cusip8", "cusip8",

        # VADGX
        "cusip9", "cusip9", "sedol7", "sedol7", "cusip8", "cusip8",

        # SPX
        "cusip9", "cusip9", "sedol7", "sedol7", "cusip8", "cusip8", "cusip8", "cusip8",
        
    ],
    "date": [
        # AGTHX
        dt.date(2026, 1, 1), dt.date(2026, 1, 31), 
        dt.date(2026, 1, 1), dt.date(2026, 1, 31),

        # FCNTX
        dt.date(2026, 1, 1), dt.date(2026, 1, 31),

        # VADGX
        dt.date(2026, 1, 1), dt.date(2026, 1, 31), 
        dt.date(2026, 1, 1), dt.date(2026, 1, 31), 
        dt.date(2026, 1, 1), dt.date(2026, 1, 31), 
        
        # SPX
        dt.date(2026, 1, 1), dt.date(2026, 1, 31), 
        dt.date(2026, 1, 1), dt.date(2026, 1, 31), 
        dt.date(2026, 1, 1), dt.date(2026, 1, 31), 
        dt.date(2026, 1, 1), dt.date(2026, 1, 31), 
    ],
    "currency": [None] * 20,
    "share_qty": [None] * 20,
    "nav": [
        # AGTHX
        0.5, 0.55, 
        0.5, 0.45,

        # FCNTX
        1.0, 1.0,

        # VADGX
        0.3, 0.5, 
        0.4, 0.2,
        0.3, 0.25,

        # SPX
        0.3, 0.3, 
        0.4, 0.2,
        0.3, 0.25,
        0.3, 0.25,
    ],
}).with_columns(pl.col("currency").cast(pl.String), pl.col("share_qty").cast(pl.Float64))

portfolio_df
shape: (20, 7)
portfolio_idasset_idasset_id_typedatecurrencyshare_qtynav
strstrstrdatestrf64f64
"AGTHX""02079K305""cusip9"2026-01-01nullnull0.5
"AGTHX""02079K305""cusip9"2026-01-31nullnull0.55
"AGTHX""2588173""sedol7"2026-01-01nullnull0.5
"AGTHX""2588173""sedol7"2026-01-31nullnull0.45
"FCNTX""67066G10""cusip8"2026-01-01nullnull1.0
"SPX""2588173""sedol7"2026-01-31nullnull0.2
"SPX""67066G10""cusip8"2026-01-01nullnull0.3
"SPX""67066G10""cusip8"2026-01-31nullnull0.25
"SPX""85371710""cusip8"2026-01-01nullnull0.3
"SPX""85371710""cusip8"2026-01-31nullnull0.25
uploader.fast_commit(portfolio_df, mode="append")
UploadCommitResult(version=1, committed_names=[])
portfolio_hierarchy_df = pl.DataFrame(
    {
        "fund": ["some_fund", "some_fund", "some_fund", "some_fund"],
        "book": ["book_1", "book_1", "book_2", "book_2"],
        "ticker": ["AGTHX", "FCNTX", "VADGX", "SPX"],
        "portfolio_id": ["AGTHX", "FCNTX", "VADGX", "SPX"],
    }
)
portfoliohierarchy_settings = PortfolioHierarchySettings.from_polars(
    portfolio_hierarchy_df, 
    portfolio_source="portfolio-attribution-demo",
)
portfoliohierarchy_settings.to_polars()
shape: (4, 5)
fundbooktickerportfolio_idbenchmark_id
strstrstrstrnull
"some_fund""book_1""AGTHX""AGTHX"null
"some_fund""book_1""FCNTX""FCNTX"null
"some_fund""book_2""VADGX""VADGX"null
"some_fund""book_2""SPX""SPX"null

Now that we have the portfolio hiearachy in place, we define a basic factor model.

factorriskmodel_settings = FactorRiskModelSettings(
    universe=UniverseSettings(),
    exposures=ExposureSettings(
        exposures=[
            ContinuousExposureGroupSettings(hierarchy="market"),
            CategoricalExposureGroupSettings(hierarchy="trbc"),
            ContinuousExposureGroupSettings(hierarchy="style"),
        ]
    ),
    modelconstruction=ModelConstructionSettings(
        estimation_universe=None,
        zero_sum_constraints={"trbc": "mcap_weighted"}
    ),
)

We then define and load the report. We have different multi-period aggregation settings available, which we do not go into here.

report_settings = ReturnAttributionReportSettings(
    portfolio_hierarchy_settings=portfoliohierarchy_settings,
    factor_model_settings=factorriskmodel_settings,
    normalize_holdings=False, 
    return_aggregation_type="arithmetic",
)
report_engine = bln.equity.reports.load(
    report_settings.with_dataset("bayesline/Bayesline-US-All-1y")
)
report = report_engine.calculate(start_date="2026-01-05", end_date="2026-01-30")

Let’s first look at the return attribution from all sources at the ticker level (recall that ticker is defined in the portfolio hierarchy). For a specific ticker, we want to know the return attribution to different assets at each date.

# for a specific ticker, the return attribution across dates and assets
(
    report.accessor.get_data(
        [("ticker", "AGTHX")],  # filters: [ticker=AGTHX]
        expand=("date", "input_asset_id",), # combinations define the rows
        value_cols=("PnL",),  # exclude benchmark / active
    )
    .select("ticker", "date", "input_asset_id", "PnL")
)
shape: (76, 4)
tickerdateinput_asset_idPnL
strdatestrf32
"AGTHX"2026-01-05"02079K305"0.002221
"AGTHX"2026-01-05"2588173"-0.000093
"AGTHX"2026-01-05"67066G10"0.0
"AGTHX"2026-01-05"85371710"0.0
"AGTHX"2026-01-06"02079K305"-0.003514
"AGTHX"2026-01-29"85371710"0.0
"AGTHX"2026-01-30"02079K305"-0.000399
"AGTHX"2026-01-30"2588173"-0.003319
"AGTHX"2026-01-30"67066G10"0.0
"AGTHX"2026-01-30"85371710"0.0
# for a specific ticker, the return attribution across dates and assets
(
    report.accessor.get_data(
        [("ticker", "AGTHX")],  # filters: [ticker=AGTHX]
        expand=("date", "input_asset_id",), # combinations define the rows
        value_cols=("PnL",),  # exclude benchmark / active
    )
    .select("ticker", "date", "input_asset_id", "PnL")
)
shape: (76, 4)
tickerdateinput_asset_idPnL
strdatestrf32
"AGTHX"2026-01-05"02079K305"0.002221
"AGTHX"2026-01-05"2588173"-0.000093
"AGTHX"2026-01-05"67066G10"0.0
"AGTHX"2026-01-05"85371710"0.0
"AGTHX"2026-01-06"02079K305"-0.003514
"AGTHX"2026-01-29"85371710"0.0
"AGTHX"2026-01-30"02079K305"-0.000399
"AGTHX"2026-01-30"2588173"-0.003319
"AGTHX"2026-01-30"67066G10"0.0
"AGTHX"2026-01-30"85371710"0.0

Using different settings, we can split out the returns across all sources (factors and idio) at a different level. Below we show the returns on a specific date, for a specific book, split out by factor.

# for a specific book on a specific date, the factor return attribution across assets
(
    report.accessor.get_data(
        [("date", "2026-01-05"), ("book", "book_1")],  # filters
        expand=("type", "factor_group", "factor",),
        value_cols=("PnL",),
    )
    .select("date", "book", "type", "factor_group", "factor", "PnL")
)
shape: (22, 6)
datebooktypefactor_groupfactorPnL
datestrstrstrstrf32
2026-01-05"book_1""Factors""market""Market"0.014521
2026-01-05"book_1""Factors""style""Dividend"0.000741
2026-01-05"book_1""Factors""style""Growth"0.001469
2026-01-05"book_1""Factors""style""Leverage"-0.00059
2026-01-05"book_1""Factors""style""Momentum"-0.002752
2026-01-05"book_1""Factors""trbc""Institutions, Associations & O…0.0
2026-01-05"book_1""Factors""trbc""Real Estate"0.0
2026-01-05"book_1""Factors""trbc""Technology"-0.004956
2026-01-05"book_1""Factors""trbc""Utilities"0.0
2026-01-05"book_1""Idiosyncratic"""""-0.022892

We can drill down further by including the asset dimension. This decomposes the returns of the assets in a specific book, on a specific date.

# for a specific book on a specific date, the factor return attribution by asset
(
    report.accessor.get_data(
        [("date", "2026-01-05"), ("book", "book_1")],  # filters
        expand=("type", "factor_group", "factor", "input_asset_id"),
        value_cols=("PnL",),
    )
    .select("date", "book", "type", "factor_group", "factor", "input_asset_id", "PnL")
)
shape: (88, 7)
datebooktypefactor_groupfactorinput_asset_idPnL
datestrstrstrstrstrf32
2026-01-05"book_1""Factors""market""Market""02079K305"0.003646
2026-01-05"book_1""Factors""market""Market""2588173"0.003541
2026-01-05"book_1""Factors""market""Market""67066G10"0.007334
2026-01-05"book_1""Factors""market""Market""85371710"0.0
2026-01-05"book_1""Factors""style""Dividend""02079K305"0.000107
2026-01-05"book_1""Factors""trbc""Utilities""85371710"0.0
2026-01-05"book_1""Idiosyncratic""""""02079K305"-0.002582
2026-01-05"book_1""Idiosyncratic""""""2588173"-0.002947
2026-01-05"book_1""Idiosyncratic""""""67066G10"-0.017364
2026-01-05"book_1""Idiosyncratic""""""85371710"0.0

By using the filters, we can also isolate the specific return attribution.

# the idiosyncratic returns in a specific fund
(
    report.accessor.get_data(
        [("fund", "some_fund"), ("type", "Idiosyncratic")],  # filters
        expand=("date", "input_asset_id",),
        value_cols=("PnL",),
    )
    .select("fund", "date", "input_asset_id", "PnL")
)
shape: (76, 4)
funddateinput_asset_idPnL
strdatestrf32
"some_fund"2026-01-05"02079K305"-0.005679
"some_fund"2026-01-05"2588173"-0.007663
"some_fund"2026-01-05"67066G10"-0.027782
"some_fund"2026-01-05"85371710"0.0
"some_fund"2026-01-06"02079K305"-0.024212
"some_fund"2026-01-29"85371710"0.0
"some_fund"2026-01-30"02079K305"0.0266
"some_fund"2026-01-30"2588173"-0.001122
"some_fund"2026-01-30"67066G10"0.023794
"some_fund"2026-01-30"85371710"0.0

We can now to the same for risk attribution / decomposition

report_settings = XSRReportSettings(
    portfolio_hierarchy_settings=portfoliohierarchy_settings,
    factor_model_settings=factorriskmodel_settings,
    normalize_holdings=False,
)
report_engine = bln.equity.reports.load(
    report_settings.with_dataset("bayesline/Bayesline-US-All-1y")
)
report = report_engine.calculate(start_date="2026-01-05", end_date="2026-01-30")
# for a specific ticker and date, the risk attribution across the factors
(
    report.accessor.get_data(
        [("ticker", "AGTHX"), ("date", "2026-01-05")],  # filters: [ticker=AGTHX]
        expand=("type", "factor_group", "factor",), # combinations define the rows
        value_cols=report.accessor.metric_cols,  # Exposure, Stand-alone Volatility, Variance Contribution
    )
    .select("ticker", "date", "type", "factor_group", "factor", *report.accessor.metric_cols)
)
shape: (22, 10)
tickerdatetypefactor_groupfactorExposureVolatilityCorrelationContributionBeta
strdatestrstrstrf32f32f32f32f32
"AGTHX"2026-01-05"Factors""market""Market"0.994520.121030.5462910.0657550.0
"AGTHX"2026-01-05"Factors""style""Dividend"0.0125410.024745-0.097627-0.000030.0
"AGTHX"2026-01-05"Factors""style""Growth"0.9194130.0149010.1400190.0019180.0
"AGTHX"2026-01-05"Factors""style""Leverage"-0.1387580.025394-0.3983340.0014040.0
"AGTHX"2026-01-05"Factors""style""Momentum"1.1090640.0582160.3071120.0198290.0
"AGTHX"2026-01-05"Factors""trbc""Institutions, Associations & O…0.00.2139030.5384920.00.0
"AGTHX"2026-01-05"Factors""trbc""Real Estate"0.00.076078-0.2408730.00.0
"AGTHX"2026-01-05"Factors""trbc""Technology"0.994520.0513840.3951650.0201940.0
"AGTHX"2026-01-05"Factors""trbc""Utilities"0.00.112429-0.1583850.00.0
"AGTHX"2026-01-05"Idiosyncratic"""""0.994520.1483440.6258430.092840.0
# for a specific ticker and factor, the risk attribution across dates and assets
(
    report.accessor.get_data(
        [("ticker", "AGTHX"), ("factor", "Volatility")],  # filters
        expand=("date", "input_asset_id",), # combinations define the rows
        value_cols=report.accessor.metric_cols,  # Exposure, Stand-alone Volatility, Variance Contribution
    )
    .select("ticker", "factor", "date", "input_asset_id", *report.accessor.metric_cols)
)
shape: (76, 9)
tickerfactordateinput_asset_idExposureVolatilityCorrelationContributionBeta
strstrdatestrf32f32f32f32f32
"AGTHX""Volatility"2026-01-05"02079K305"0.1181840.1382180.5855080.0095640.0
"AGTHX""Volatility"2026-01-05"2588173"-0.178530.1382180.585508-0.0144480.0
"AGTHX""Volatility"2026-01-05"67066G10"0.00.1382180.5855080.00.0
"AGTHX""Volatility"2026-01-05"85371710"0.00.1382180.5855080.00.0
"AGTHX""Volatility"2026-01-06"02079K305"0.11630.1377890.5861210.0093920.0
"AGTHX""Volatility"2026-01-29"85371710"0.00.1295390.542120.00.0
"AGTHX""Volatility"2026-01-30"02079K305"0.0552770.1327730.5452790.0040020.0
"AGTHX""Volatility"2026-01-30"2588173"-0.0655280.1327730.545279-0.0047440.0
"AGTHX""Volatility"2026-01-30"67066G10"0.00.1327730.5452790.00.0
"AGTHX""Volatility"2026-01-30"85371710"0.00.1327730.5452790.00.0