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,
    ReturnAttributionReportSettingsV2,
    XSRReportSettingsV2,
)

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", 
    dataset="Bayesline-US-All-1y",
)
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(dataset="Bayesline-US-All-1y"),
    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 = ReturnAttributionReportSettingsV2(
    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)
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.01711
2026-01-05"book_1""Factors""style""Dividend"0.001105
2026-01-05"book_1""Factors""style""Growth"0.001282
2026-01-05"book_1""Factors""style""Leverage"-0.000288
2026-01-05"book_1""Factors""style""Momentum"-0.002527
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.004924
2026-01-05"book_1""Factors""trbc""Utilities"0.0
2026-01-05"book_1""Idiosyncratic"""""-0.023967

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.004296
2026-01-05"book_1""Factors""market""Market""2588173"0.004173
2026-01-05"book_1""Factors""market""Market""67066G10"0.008641
2026-01-05"book_1""Factors""market""Market""85371710"0.0
2026-01-05"book_1""Factors""style""Dividend""02079K305"0.000264
2026-01-05"book_1""Factors""trbc""Utilities""85371710"0.0
2026-01-05"book_1""Idiosyncratic""""""02079K305"-0.003616
2026-01-05"book_1""Idiosyncratic""""""2588173"-0.003289
2026-01-05"book_1""Idiosyncratic""""""67066G10"-0.017061
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.007955
"some_fund"2026-01-05"2588173"-0.008552
"some_fund"2026-01-05"67066G10"-0.027298
"some_fund"2026-01-05"85371710"0.0
"some_fund"2026-01-06"02079K305"-0.023256
"some_fund"2026-01-29"85371710"0.0
"some_fund"2026-01-30"02079K305"0.025848
"some_fund"2026-01-30"2588173"-0.004347
"some_fund"2026-01-30"67066G10"0.022921
"some_fund"2026-01-30"85371710"0.0

We can now to the same for risk attribution / decomposition

report_settings = XSRReportSettingsV2(
    portfolio_hierarchy_settings=portfoliohierarchy_settings,
    factor_model_settings=factorriskmodel_settings,
    normalize_holdings=False,
)
report_engine = bln.equity.reports.load(report_settings)
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.1398660.5869220.0816410.0
"AGTHX"2026-01-05"Factors""style""Dividend"-0.0558370.02517-0.0474390.0000670.0
"AGTHX"2026-01-05"Factors""style""Growth"0.9030920.0166190.1519970.0022810.0
"AGTHX"2026-01-05"Factors""style""Leverage"-0.1540880.0235-0.2714760.0009830.0
"AGTHX"2026-01-05"Factors""style""Momentum"0.8760270.0582660.2331550.0119010.0
"AGTHX"2026-01-05"Factors""trbc""Institutions, Associations & O…0.00.175683-0.4508820.00.0
"AGTHX"2026-01-05"Factors""trbc""Real Estate"0.00.076858-0.2568360.00.0
"AGTHX"2026-01-05"Factors""trbc""Technology"0.994520.0505320.3456480.017370.0
"AGTHX"2026-01-05"Factors""trbc""Utilities"0.00.111708-0.1910970.00.0
"AGTHX"2026-01-05"Idiosyncratic"""""0.994520.1476770.6409910.0946590.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.053660.1288750.5834110.0040350.0
"AGTHX""Volatility"2026-01-05"2588173"-0.2603260.1288750.583411-0.0195730.0
"AGTHX""Volatility"2026-01-05"67066G10"0.00.1288750.5834110.00.0
"AGTHX""Volatility"2026-01-05"85371710"0.00.1288750.5834110.00.0
"AGTHX""Volatility"2026-01-06"02079K305"-0.0097670.1284870.56265-0.0007060.0
"AGTHX""Volatility"2026-01-29"85371710"0.00.1205250.5265260.00.0
"AGTHX""Volatility"2026-01-30"02079K305"-0.0342290.1240580.54978-0.0023350.0
"AGTHX""Volatility"2026-01-30"2588173"-0.0987980.1240580.54978-0.0067390.0
"AGTHX""Volatility"2026-01-30"67066G10"0.00.1240580.549780.00.0
"AGTHX""Volatility"2026-01-30"85371710"0.00.1240580.549780.00.0