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,
    ReportSettings,
    FactorAttributionDrilldownReportSettings,
    FactorAttributionDrilldownMeasureSettings,
    RiskDecompositionReportSettings,
)

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(2025, 1, 1), dt.date(2025, 1, 31), 
        dt.date(2025, 1, 1), dt.date(2025, 1, 31),

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

        # VADGX
        dt.date(2025, 1, 1), dt.date(2025, 1, 31), 
        dt.date(2025, 1, 1), dt.date(2025, 1, 31), 
        dt.date(2025, 1, 1), dt.date(2025, 1, 31), 
        
        # SPX
        dt.date(2025, 1, 1), dt.date(2025, 1, 31), 
        dt.date(2025, 1, 1), dt.date(2025, 1, 31), 
        dt.date(2025, 1, 1), dt.date(2025, 1, 31), 
        dt.date(2025, 1, 1), dt.date(2025, 1, 31), 
    ],
    "value": [
        # 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,
    ],
})

portfolio_df
shape: (20, 5)
portfolio_idasset_idasset_id_typedatevalue
strstrstrdatef64
"AGTHX""02079K305""cusip9"2025-01-010.5
"AGTHX""02079K305""cusip9"2025-01-310.55
"AGTHX""2588173""sedol7"2025-01-010.5
"AGTHX""2588173""sedol7"2025-01-310.45
"FCNTX""67066G10""cusip8"2025-01-011.0
"SPX""2588173""sedol7"2025-01-310.2
"SPX""67066G10""cusip8"2025-01-010.3
"SPX""67066G10""cusip8"2025-01-310.25
"SPX""85371710""cusip8"2025-01-010.3
"SPX""85371710""cusip8"2025-01-310.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(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.

# construct the report
report_settings = ReportSettings(
    report=FactorAttributionDrilldownReportSettings(
        measures=[
            FactorAttributionDrilldownMeasureSettings(  # return attribution
                normalize_holdings=True,
                return_aggregation_type="geometric",
            ),
            FactorAttributionDrilldownMeasureSettings(  # PnL attribution
                normalize_holdings=False,
                return_aggregation_type="arithmetic",
            ),
        ],
    ),
    risk_model=factorriskmodel_settings,
)
report_engine = bln.equity.portfolioreport.load(
    report_settings,
    hierarchy_ref_or_settings=portfoliohierarchy_settings,
)

The order defines the axes over which we can aggregate. For example by putting fund, book and ticker in our portfolio axis, we can filter and aggregate along this dimension at a later stage. The concepts of fund, book, and ticker were defined through the PortfolioHierarchySettings above.

order = {
    "date": ["date"],
    "portfolio": ["fund", "book", "ticker"],
    "factor_and_idio": ["type", "factor", "factor_group"],
    "asset": ["input_asset_id"],
}
report_accessor = report_engine.get_report(
    order,
    date_start="2025-01-03",
    date_end="2025-01-31",
    subtotals=["date", "portfolio", "factor_and_idio", "asset"],
)

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=("Return", "PnL"),  # exclude benchmark / active
    )
    .select("ticker", "date", "input_asset_id", "Return", "PnL")
)
shape: (57, 5)
tickerdateinput_asset_idReturnPnL
strstrstrf32f32
"AGTHX""2025-01-03""02079K305"0.0062530.006233
"AGTHX""2025-01-03""2588173"0.0056760.005658
"AGTHX""2025-01-03""67066G10"0.00.0
"AGTHX""2025-01-06""02079K305"0.0133010.013418
"AGTHX""2025-01-06""2588173"0.0052920.005338
"AGTHX""2025-01-30""2588173"-0.031159-0.032432
"AGTHX""2025-01-30""67066G10"0.00.0
"AGTHX""2025-01-31""02079K305"0.0081340.00832
"AGTHX""2025-01-31""2588173"0.0000810.000083
"AGTHX""2025-01-31""67066G10"0.00.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", "2025-01-03"), ("book", "book_1")],  # filters
        expand=("type", "factor_group", "factor",),
        value_cols=("Return", "PnL"),
    )
    .select("date", "book", "type", "factor_group", "factor", "Return", "PnL")
)
shape: (22, 7)
datebooktypefactor_groupfactorReturnPnL
strstrstrstrstrf32f32
"2025-01-03""book_1""Factors""market""Market"0.0143250.029034
"2025-01-03""book_1""Factors""trbc""Energy"0.00.0
"2025-01-03""book_1""Factors""trbc""Basic Materials"0.00.0
"2025-01-03""book_1""Factors""trbc""Industrials"0.00.0
"2025-01-03""book_1""Factors""trbc""Consumer Cyclicals"0.00.0
"2025-01-03""book_1""Factors""style""Volatility"0.0000370.000075
"2025-01-03""book_1""Factors""style""Momentum"0.0018560.003763
"2025-01-03""book_1""Factors""style""Dividend"0.0000840.000169
"2025-01-03""book_1""Factors""style""Leverage"0.0005960.001207
"2025-01-03""book_1""Idiosyncratic"""""0.0060570.012276

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", "2025-01-03"), ("book", "book_1")],  # filters
        expand=("type", "factor_group", "factor", "input_asset_id"),
        value_cols=("Return", "PnL"),
    )
    .select("date", "book", "type", "factor_group", "factor", "input_asset_id", "Return", "PnL")
)
shape: (66, 8)
datebooktypefactor_groupfactorinput_asset_idReturnPnL
strstrstrstrstrstrf32f32
"2025-01-03""book_1""Factors""market""Market""02079K305"0.0035360.007167
"2025-01-03""book_1""Factors""market""Market""2588173"0.0035090.007113
"2025-01-03""book_1""Factors""market""Market""67066G10"0.0072790.014754
"2025-01-03""book_1""Factors""trbc""Energy""02079K305"0.00.0
"2025-01-03""book_1""Factors""trbc""Energy""2588173"0.00.0
"2025-01-03""book_1""Factors""style""Leverage""2588173"-0.000066-0.000133
"2025-01-03""book_1""Factors""style""Leverage""67066G10"0.0002230.000452
"2025-01-03""book_1""Idiosyncratic""""""02079K305"-0.001828-0.003706
"2025-01-03""book_1""Idiosyncratic""""""2588173"0.000150.000304
"2025-01-03""book_1""Idiosyncratic""""""67066G10"0.0077350.015678

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=("Return", "PnL"),
    )
    .select("fund", "date", "input_asset_id", "Return", "PnL")
)
shape: (57, 5)
funddateinput_asset_idReturnPnL
strstrstrf32f32
"some_fund""2025-01-03""02079K305"-0.002018-0.008152
"some_fund""2025-01-03""2588173"0.0001960.00079
"some_fund""2025-01-03""67066G10"0.006210.025085
"some_fund""2025-01-06""02079K305"0.004170.017272
"some_fund""2025-01-06""2588173"0.0007550.003125
"some_fund""2025-01-30""2588173"-0.022417-0.089075
"some_fund""2025-01-30""67066G10"-0.001504-0.005977
"some_fund""2025-01-31""02079K305"0.0049680.019537
"some_fund""2025-01-31""2588173"0.0004550.001789
"some_fund""2025-01-31""67066G10"-0.013462-0.052937

We can now to the same for risk attribution / decomposition

# construct the report
report_settings = ReportSettings(
    report=RiskDecompositionReportSettings(),  # by default in dollar space
    risk_model=factorriskmodel_settings,
)
report_engine = bln.equity.portfolioreport.load(
    report_settings,
    hierarchy_ref_or_settings=portfoliohierarchy_settings,
)

Orders work the same as for attribution

order = {
    "date": ["date"],
    "portfolio": ["fund", "book", "ticker"],
    "factor_and_idio": ["type", "factor", "factor_group"],
    "asset": ["input_asset_id"],
}
report_accessor = report_engine.get_report(
    order,
    date_start="2025-01-03",
    date_end="2025-01-31",
    subtotals=["date", "portfolio", "factor_and_idio", "asset"],
)
# for a specific ticker and date, the risk attribution across the factors
(
    report_accessor.get_data(
        [("ticker", "AGTHX"), ("date", "2025-01-03")],  # 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, 8)
tickerdatetypefactor_groupfactorExposureStandaloneVolatilityVarianceContribution
strstrstrstrstrf32f32f32
"AGTHX""2025-01-03""Factors""market""Market"1.0087720.1483920.39577
"AGTHX""2025-01-03""Factors""trbc""Energy"0.00.00.0
"AGTHX""2025-01-03""Factors""trbc""Basic Materials"0.00.00.0
"AGTHX""2025-01-03""Factors""trbc""Industrials"0.00.00.0
"AGTHX""2025-01-03""Factors""trbc""Consumer Cyclicals"0.00.00.0
"AGTHX""2025-01-03""Factors""style""Volatility"-0.6659610.064153-0.173285
"AGTHX""2025-01-03""Factors""style""Momentum"0.5951160.0300520.041436
"AGTHX""2025-01-03""Factors""style""Dividend"-0.0896750.0022280.003271
"AGTHX""2025-01-03""Factors""style""Leverage"-0.2282540.0050050.008653
"AGTHX""2025-01-03""Idiosyncratic"""""1.0087720.1444530.450151
# 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: (57, 7)
tickerfactordateinput_asset_idExposureStandaloneVolatilityVarianceContribution
strstrstrstrf32f32f32
"AGTHX""Volatility""2025-01-03""02079K305"-0.236840.022815-0.061626
"AGTHX""Volatility""2025-01-03""2588173"-0.4291210.041338-0.111658
"AGTHX""Volatility""2025-01-03""67066G10"0.00.00.0
"AGTHX""Volatility""2025-01-06""02079K305"-0.2198810.021113-0.056636
"AGTHX""Volatility""2025-01-06""2588173"-0.4108830.039453-0.105834
"AGTHX""Volatility""2025-01-30""2588173"-0.3153650.031569-0.078712
"AGTHX""Volatility""2025-01-30""67066G10"0.00.00.0
"AGTHX""Volatility""2025-01-31""02079K305"-0.1343440.01332-0.033997
"AGTHX""Volatility""2025-01-31""2588173"-0.2836670.028124-0.071784
"AGTHX""Volatility""2025-01-31""67066G10"0.00.00.0