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
| portfolio_id | asset_id | asset_id_type | date | currency | share_qty | nav |
|---|---|---|---|---|---|---|
| str | str | str | date | str | f64 | f64 |
| "AGTHX" | "02079K305" | "cusip9" | 2026-01-01 | null | null | 0.5 |
| "AGTHX" | "02079K305" | "cusip9" | 2026-01-31 | null | null | 0.55 |
| "AGTHX" | "2588173" | "sedol7" | 2026-01-01 | null | null | 0.5 |
| "AGTHX" | "2588173" | "sedol7" | 2026-01-31 | null | null | 0.45 |
| "FCNTX" | "67066G10" | "cusip8" | 2026-01-01 | null | null | 1.0 |
| … | … | … | … | … | … | … |
| "SPX" | "2588173" | "sedol7" | 2026-01-31 | null | null | 0.2 |
| "SPX" | "67066G10" | "cusip8" | 2026-01-01 | null | null | 0.3 |
| "SPX" | "67066G10" | "cusip8" | 2026-01-31 | null | null | 0.25 |
| "SPX" | "85371710" | "cusip8" | 2026-01-01 | null | null | 0.3 |
| "SPX" | "85371710" | "cusip8" | 2026-01-31 | null | null | 0.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()
| fund | book | ticker | portfolio_id | benchmark_id |
|---|---|---|---|---|
| str | str | str | str | null |
| "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")
)
| ticker | date | input_asset_id | PnL |
|---|---|---|---|
| str | date | str | f32 |
| "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")
)
| ticker | date | input_asset_id | PnL |
|---|---|---|---|
| str | date | str | f32 |
| "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")
)
| date | book | type | factor_group | factor | PnL |
|---|---|---|---|---|---|
| date | str | str | str | str | f32 |
| 2026-01-05 | "book_1" | "Factors" | "market" | "Market" | 0.014511 |
| 2026-01-05 | "book_1" | "Factors" | "style" | "Dividend" | 0.000738 |
| 2026-01-05 | "book_1" | "Factors" | "style" | "Growth" | 0.001497 |
| 2026-01-05 | "book_1" | "Factors" | "style" | "Leverage" | -0.000592 |
| 2026-01-05 | "book_1" | "Factors" | "style" | "Momentum" | -0.00276 |
| … | … | … | … | … | … |
| 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.005003 |
| 2026-01-05 | "book_1" | "Factors" | "trbc" | "Utilities" | 0.0 |
| 2026-01-05 | "book_1" | "Idiosyncratic" | "" | "" | -0.022818 |
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")
)
| date | book | type | factor_group | factor | input_asset_id | PnL |
|---|---|---|---|---|---|---|
| date | str | str | str | str | str | f32 |
| 2026-01-05 | "book_1" | "Factors" | "market" | "Market" | "02079K305" | 0.003643 |
| 2026-01-05 | "book_1" | "Factors" | "market" | "Market" | "2588173" | 0.003539 |
| 2026-01-05 | "book_1" | "Factors" | "market" | "Market" | "67066G10" | 0.007328 |
| 2026-01-05 | "book_1" | "Factors" | "market" | "Market" | "85371710" | 0.0 |
| 2026-01-05 | "book_1" | "Factors" | "style" | "Dividend" | "02079K305" | 0.000105 |
| … | … | … | … | … | … | … |
| 2026-01-05 | "book_1" | "Factors" | "trbc" | "Utilities" | "85371710" | 0.0 |
| 2026-01-05 | "book_1" | "Idiosyncratic" | "" | "" | "02079K305" | -0.002557 |
| 2026-01-05 | "book_1" | "Idiosyncratic" | "" | "" | "2588173" | -0.002929 |
| 2026-01-05 | "book_1" | "Idiosyncratic" | "" | "" | "67066G10" | -0.017331 |
| 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")
)
| fund | date | input_asset_id | PnL |
|---|---|---|---|
| str | date | str | f32 |
| "some_fund" | 2026-01-05 | "02079K305" | -0.005626 |
| "some_fund" | 2026-01-05 | "2588173" | -0.007616 |
| "some_fund" | 2026-01-05 | "67066G10" | -0.02773 |
| "some_fund" | 2026-01-05 | "85371710" | 0.0 |
| "some_fund" | 2026-01-06 | "02079K305" | -0.024163 |
| … | … | … | … |
| "some_fund" | 2026-01-29 | "85371710" | 0.0 |
| "some_fund" | 2026-01-30 | "02079K305" | 0.026639 |
| "some_fund" | 2026-01-30 | "2588173" | -0.001068 |
| "some_fund" | 2026-01-30 | "67066G10" | 0.023823 |
| "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)
)
| ticker | date | type | factor_group | factor | Exposure | Volatility | Correlation | Contribution | Beta | RelativeExposure |
|---|---|---|---|---|---|---|---|---|---|---|
| str | date | str | str | str | f32 | f32 | f32 | f32 | f32 | f32 |
| "AGTHX" | 2026-01-05 | "Factors" | "market" | "Market" | 0.99452 | 0.120916 | 0.546175 | 0.06568 | 0.0 | 1.0 |
| "AGTHX" | 2026-01-05 | "Factors" | "style" | "Dividend" | 0.013754 | 0.024773 | -0.096425 | -0.000033 | 0.0 | 1.0 |
| "AGTHX" | 2026-01-05 | "Factors" | "style" | "Growth" | 0.919413 | 0.014866 | 0.13578 | 0.001856 | 0.0 | 1.0 |
| "AGTHX" | 2026-01-05 | "Factors" | "style" | "Leverage" | -0.138386 | 0.02533 | -0.400817 | 0.001405 | 0.0 | 1.0 |
| "AGTHX" | 2026-01-05 | "Factors" | "style" | "Momentum" | 1.109093 | 0.058203 | 0.307536 | 0.019852 | 0.0 | 1.0 |
| … | … | … | … | … | … | … | … | … | … | … |
| "AGTHX" | 2026-01-05 | "Factors" | "trbc" | "Institutions, Associations & O… | 0.0 | 0.21371 | 0.53836 | 0.0 | 0.0 | NaN |
| "AGTHX" | 2026-01-05 | "Factors" | "trbc" | "Real Estate" | 0.0 | 0.076024 | -0.241505 | 0.0 | 0.0 | NaN |
| "AGTHX" | 2026-01-05 | "Factors" | "trbc" | "Technology" | 0.99452 | 0.051713 | 0.394284 | 0.020278 | 0.0 | 1.0 |
| "AGTHX" | 2026-01-05 | "Factors" | "trbc" | "Utilities" | 0.0 | 0.112146 | -0.158426 | 0.0 | 0.0 | NaN |
| "AGTHX" | 2026-01-05 | "Idiosyncratic" | "" | "" | 0.99452 | 0.148396 | 0.625941 | 0.092887 | 0.0 | NaN |
# 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)
)
| ticker | factor | date | input_asset_id | Exposure | Volatility | Correlation | Contribution | Beta | RelativeExposure |
|---|---|---|---|---|---|---|---|---|---|
| str | str | date | str | f32 | f32 | f32 | f32 | f32 | f32 |
| "AGTHX" | "Volatility" | 2026-01-05 | "02079K305" | 0.118184 | 0.138073 | 0.585547 | 0.009555 | 0.0 | -1.95841 |
| "AGTHX" | "Volatility" | 2026-01-05 | "2588173" | -0.17853 | 0.138073 | 0.585547 | -0.014434 | 0.0 | 2.95841 |
| "AGTHX" | "Volatility" | 2026-01-05 | "67066G10" | 0.0 | 0.138073 | 0.585547 | 0.0 | 0.0 | -0.0 |
| "AGTHX" | "Volatility" | 2026-01-05 | "85371710" | 0.0 | 0.138073 | 0.585547 | 0.0 | 0.0 | -0.0 |
| "AGTHX" | "Volatility" | 2026-01-06 | "02079K305" | 0.116228 | 0.137649 | 0.586168 | 0.009378 | 0.0 | -1.884338 |
| … | … | … | … | … | … | … | … | … | … |
| "AGTHX" | "Volatility" | 2026-01-29 | "85371710" | 0.0 | 0.129403 | 0.542277 | 0.0 | 0.0 | -0.0 |
| "AGTHX" | "Volatility" | 2026-01-30 | "02079K305" | 0.055408 | 0.132612 | 0.545529 | 0.004008 | 0.0 | -5.515079 |
| "AGTHX" | "Volatility" | 2026-01-30 | "2588173" | -0.065455 | 0.132612 | 0.545529 | -0.004735 | 0.0 | 6.515079 |
| "AGTHX" | "Volatility" | 2026-01-30 | "67066G10" | 0.0 | 0.132612 | 0.545529 | 0.0 | 0.0 | -0.0 |
| "AGTHX" | "Volatility" | 2026-01-30 | "85371710" | 0.0 | 0.132612 | 0.545529 | 0.0 | 0.0 | -0.0 |