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.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")
)
| 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.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")
)
| fund | date | input_asset_id | PnL |
|---|---|---|---|
| str | date | str | f32 |
| "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)
)
| ticker | date | type | factor_group | factor | Exposure | Volatility | Correlation | Contribution | Beta |
|---|---|---|---|---|---|---|---|---|---|
| str | date | str | str | str | f32 | f32 | f32 | f32 | f32 |
| "AGTHX" | 2026-01-05 | "Factors" | "market" | "Market" | 0.99452 | 0.12103 | 0.546291 | 0.065755 | 0.0 |
| "AGTHX" | 2026-01-05 | "Factors" | "style" | "Dividend" | 0.012541 | 0.024745 | -0.097627 | -0.00003 | 0.0 |
| "AGTHX" | 2026-01-05 | "Factors" | "style" | "Growth" | 0.919413 | 0.014901 | 0.140019 | 0.001918 | 0.0 |
| "AGTHX" | 2026-01-05 | "Factors" | "style" | "Leverage" | -0.138758 | 0.025394 | -0.398334 | 0.001404 | 0.0 |
| "AGTHX" | 2026-01-05 | "Factors" | "style" | "Momentum" | 1.109064 | 0.058216 | 0.307112 | 0.019829 | 0.0 |
| … | … | … | … | … | … | … | … | … | … |
| "AGTHX" | 2026-01-05 | "Factors" | "trbc" | "Institutions, Associations & O… | 0.0 | 0.213903 | 0.538492 | 0.0 | 0.0 |
| "AGTHX" | 2026-01-05 | "Factors" | "trbc" | "Real Estate" | 0.0 | 0.076078 | -0.240873 | 0.0 | 0.0 |
| "AGTHX" | 2026-01-05 | "Factors" | "trbc" | "Technology" | 0.99452 | 0.051384 | 0.395165 | 0.020194 | 0.0 |
| "AGTHX" | 2026-01-05 | "Factors" | "trbc" | "Utilities" | 0.0 | 0.112429 | -0.158385 | 0.0 | 0.0 |
| "AGTHX" | 2026-01-05 | "Idiosyncratic" | "" | "" | 0.99452 | 0.148344 | 0.625843 | 0.09284 | 0.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)
)
| ticker | factor | date | input_asset_id | Exposure | Volatility | Correlation | Contribution | Beta |
|---|---|---|---|---|---|---|---|---|
| str | str | date | str | f32 | f32 | f32 | f32 | f32 |
| "AGTHX" | "Volatility" | 2026-01-05 | "02079K305" | 0.118184 | 0.138218 | 0.585508 | 0.009564 | 0.0 |
| "AGTHX" | "Volatility" | 2026-01-05 | "2588173" | -0.17853 | 0.138218 | 0.585508 | -0.014448 | 0.0 |
| "AGTHX" | "Volatility" | 2026-01-05 | "67066G10" | 0.0 | 0.138218 | 0.585508 | 0.0 | 0.0 |
| "AGTHX" | "Volatility" | 2026-01-05 | "85371710" | 0.0 | 0.138218 | 0.585508 | 0.0 | 0.0 |
| "AGTHX" | "Volatility" | 2026-01-06 | "02079K305" | 0.1163 | 0.137789 | 0.586121 | 0.009392 | 0.0 |
| … | … | … | … | … | … | … | … | … |
| "AGTHX" | "Volatility" | 2026-01-29 | "85371710" | 0.0 | 0.129539 | 0.54212 | 0.0 | 0.0 |
| "AGTHX" | "Volatility" | 2026-01-30 | "02079K305" | 0.055277 | 0.132773 | 0.545279 | 0.004002 | 0.0 |
| "AGTHX" | "Volatility" | 2026-01-30 | "2588173" | -0.065528 | 0.132773 | 0.545279 | -0.004744 | 0.0 |
| "AGTHX" | "Volatility" | 2026-01-30 | "67066G10" | 0.0 | 0.132773 | 0.545279 | 0.0 | 0.0 |
| "AGTHX" | "Volatility" | 2026-01-30 | "85371710" | 0.0 | 0.132773 | 0.545279 | 0.0 | 0.0 |