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
| 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",
dataset="Bayesline-US-All-1y",
)
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(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")
)
| 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.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")
)
| 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.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")
)
| fund | date | input_asset_id | PnL |
|---|---|---|---|
| str | date | str | f32 |
| "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)
)
| 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.139866 | 0.586922 | 0.081641 | 0.0 |
| "AGTHX" | 2026-01-05 | "Factors" | "style" | "Dividend" | -0.055837 | 0.02517 | -0.047439 | 0.000067 | 0.0 |
| "AGTHX" | 2026-01-05 | "Factors" | "style" | "Growth" | 0.903092 | 0.016619 | 0.151997 | 0.002281 | 0.0 |
| "AGTHX" | 2026-01-05 | "Factors" | "style" | "Leverage" | -0.154088 | 0.0235 | -0.271476 | 0.000983 | 0.0 |
| "AGTHX" | 2026-01-05 | "Factors" | "style" | "Momentum" | 0.876027 | 0.058266 | 0.233155 | 0.011901 | 0.0 |
| … | … | … | … | … | … | … | … | … | … |
| "AGTHX" | 2026-01-05 | "Factors" | "trbc" | "Institutions, Associations & O… | 0.0 | 0.175683 | -0.450882 | 0.0 | 0.0 |
| "AGTHX" | 2026-01-05 | "Factors" | "trbc" | "Real Estate" | 0.0 | 0.076858 | -0.256836 | 0.0 | 0.0 |
| "AGTHX" | 2026-01-05 | "Factors" | "trbc" | "Technology" | 0.99452 | 0.050532 | 0.345648 | 0.01737 | 0.0 |
| "AGTHX" | 2026-01-05 | "Factors" | "trbc" | "Utilities" | 0.0 | 0.111708 | -0.191097 | 0.0 | 0.0 |
| "AGTHX" | 2026-01-05 | "Idiosyncratic" | "" | "" | 0.99452 | 0.147677 | 0.640991 | 0.094659 | 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.05366 | 0.128875 | 0.583411 | 0.004035 | 0.0 |
| "AGTHX" | "Volatility" | 2026-01-05 | "2588173" | -0.260326 | 0.128875 | 0.583411 | -0.019573 | 0.0 |
| "AGTHX" | "Volatility" | 2026-01-05 | "67066G10" | 0.0 | 0.128875 | 0.583411 | 0.0 | 0.0 |
| "AGTHX" | "Volatility" | 2026-01-05 | "85371710" | 0.0 | 0.128875 | 0.583411 | 0.0 | 0.0 |
| "AGTHX" | "Volatility" | 2026-01-06 | "02079K305" | -0.009767 | 0.128487 | 0.56265 | -0.000706 | 0.0 |
| … | … | … | … | … | … | … | … | … |
| "AGTHX" | "Volatility" | 2026-01-29 | "85371710" | 0.0 | 0.120525 | 0.526526 | 0.0 | 0.0 |
| "AGTHX" | "Volatility" | 2026-01-30 | "02079K305" | -0.034229 | 0.124058 | 0.54978 | -0.002335 | 0.0 |
| "AGTHX" | "Volatility" | 2026-01-30 | "2588173" | -0.098798 | 0.124058 | 0.54978 | -0.006739 | 0.0 |
| "AGTHX" | "Volatility" | 2026-01-30 | "67066G10" | 0.0 | 0.124058 | 0.54978 | 0.0 | 0.0 |
| "AGTHX" | "Volatility" | 2026-01-30 | "85371710" | 0.0 | 0.124058 | 0.54978 | 0.0 | 0.0 |