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
| portfolio_id | asset_id | asset_id_type | date | value |
|---|---|---|---|---|
| str | str | str | date | f64 |
| "AGTHX" | "02079K305" | "cusip9" | 2025-01-01 | 0.5 |
| "AGTHX" | "02079K305" | "cusip9" | 2025-01-31 | 0.55 |
| "AGTHX" | "2588173" | "sedol7" | 2025-01-01 | 0.5 |
| "AGTHX" | "2588173" | "sedol7" | 2025-01-31 | 0.45 |
| "FCNTX" | "67066G10" | "cusip8" | 2025-01-01 | 1.0 |
| … | … | … | … | … |
| "SPX" | "2588173" | "sedol7" | 2025-01-31 | 0.2 |
| "SPX" | "67066G10" | "cusip8" | 2025-01-01 | 0.3 |
| "SPX" | "67066G10" | "cusip8" | 2025-01-31 | 0.25 |
| "SPX" | "85371710" | "cusip8" | 2025-01-01 | 0.3 |
| "SPX" | "85371710" | "cusip8" | 2025-01-31 | 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(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")
)
| ticker | date | input_asset_id | Return | PnL |
|---|---|---|---|---|
| str | str | str | f32 | f32 |
| "AGTHX" | "2025-01-03" | "02079K305" | 0.006253 | 0.006233 |
| "AGTHX" | "2025-01-03" | "2588173" | 0.005676 | 0.005658 |
| "AGTHX" | "2025-01-03" | "67066G10" | 0.0 | 0.0 |
| "AGTHX" | "2025-01-06" | "02079K305" | 0.013301 | 0.013418 |
| "AGTHX" | "2025-01-06" | "2588173" | 0.005292 | 0.005338 |
| … | … | … | … | … |
| "AGTHX" | "2025-01-30" | "2588173" | -0.031159 | -0.032432 |
| "AGTHX" | "2025-01-30" | "67066G10" | 0.0 | 0.0 |
| "AGTHX" | "2025-01-31" | "02079K305" | 0.008134 | 0.00832 |
| "AGTHX" | "2025-01-31" | "2588173" | 0.000081 | 0.000083 |
| "AGTHX" | "2025-01-31" | "67066G10" | 0.0 | 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", "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")
)
| date | book | type | factor_group | factor | Return | PnL |
|---|---|---|---|---|---|---|
| str | str | str | str | str | f32 | f32 |
| "2025-01-03" | "book_1" | "Factors" | "market" | "Market" | 0.014325 | 0.029034 |
| "2025-01-03" | "book_1" | "Factors" | "trbc" | "Energy" | 0.0 | 0.0 |
| "2025-01-03" | "book_1" | "Factors" | "trbc" | "Basic Materials" | 0.0 | 0.0 |
| "2025-01-03" | "book_1" | "Factors" | "trbc" | "Industrials" | 0.0 | 0.0 |
| "2025-01-03" | "book_1" | "Factors" | "trbc" | "Consumer Cyclicals" | 0.0 | 0.0 |
| … | … | … | … | … | … | … |
| "2025-01-03" | "book_1" | "Factors" | "style" | "Volatility" | 0.000037 | 0.000075 |
| "2025-01-03" | "book_1" | "Factors" | "style" | "Momentum" | 0.001856 | 0.003763 |
| "2025-01-03" | "book_1" | "Factors" | "style" | "Dividend" | 0.000084 | 0.000169 |
| "2025-01-03" | "book_1" | "Factors" | "style" | "Leverage" | 0.000596 | 0.001207 |
| "2025-01-03" | "book_1" | "Idiosyncratic" | "" | "" | 0.006057 | 0.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")
)
| date | book | type | factor_group | factor | input_asset_id | Return | PnL |
|---|---|---|---|---|---|---|---|
| str | str | str | str | str | str | f32 | f32 |
| "2025-01-03" | "book_1" | "Factors" | "market" | "Market" | "02079K305" | 0.003536 | 0.007167 |
| "2025-01-03" | "book_1" | "Factors" | "market" | "Market" | "2588173" | 0.003509 | 0.007113 |
| "2025-01-03" | "book_1" | "Factors" | "market" | "Market" | "67066G10" | 0.007279 | 0.014754 |
| "2025-01-03" | "book_1" | "Factors" | "trbc" | "Energy" | "02079K305" | 0.0 | 0.0 |
| "2025-01-03" | "book_1" | "Factors" | "trbc" | "Energy" | "2588173" | 0.0 | 0.0 |
| … | … | … | … | … | … | … | … |
| "2025-01-03" | "book_1" | "Factors" | "style" | "Leverage" | "2588173" | -0.000066 | -0.000133 |
| "2025-01-03" | "book_1" | "Factors" | "style" | "Leverage" | "67066G10" | 0.000223 | 0.000452 |
| "2025-01-03" | "book_1" | "Idiosyncratic" | "" | "" | "02079K305" | -0.001828 | -0.003706 |
| "2025-01-03" | "book_1" | "Idiosyncratic" | "" | "" | "2588173" | 0.00015 | 0.000304 |
| "2025-01-03" | "book_1" | "Idiosyncratic" | "" | "" | "67066G10" | 0.007735 | 0.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")
)
| fund | date | input_asset_id | Return | PnL |
|---|---|---|---|---|
| str | str | str | f32 | f32 |
| "some_fund" | "2025-01-03" | "02079K305" | -0.002018 | -0.008152 |
| "some_fund" | "2025-01-03" | "2588173" | 0.000196 | 0.00079 |
| "some_fund" | "2025-01-03" | "67066G10" | 0.00621 | 0.025085 |
| "some_fund" | "2025-01-06" | "02079K305" | 0.00417 | 0.017272 |
| "some_fund" | "2025-01-06" | "2588173" | 0.000755 | 0.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.004968 | 0.019537 |
| "some_fund" | "2025-01-31" | "2588173" | 0.000455 | 0.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)
)
| ticker | date | type | factor_group | factor | Exposure | StandaloneVolatility | VarianceContribution |
|---|---|---|---|---|---|---|---|
| str | str | str | str | str | f32 | f32 | f32 |
| "AGTHX" | "2025-01-03" | "Factors" | "market" | "Market" | 1.008772 | 0.148392 | 0.39577 |
| "AGTHX" | "2025-01-03" | "Factors" | "trbc" | "Energy" | 0.0 | 0.0 | 0.0 |
| "AGTHX" | "2025-01-03" | "Factors" | "trbc" | "Basic Materials" | 0.0 | 0.0 | 0.0 |
| "AGTHX" | "2025-01-03" | "Factors" | "trbc" | "Industrials" | 0.0 | 0.0 | 0.0 |
| "AGTHX" | "2025-01-03" | "Factors" | "trbc" | "Consumer Cyclicals" | 0.0 | 0.0 | 0.0 |
| … | … | … | … | … | … | … | … |
| "AGTHX" | "2025-01-03" | "Factors" | "style" | "Volatility" | -0.665961 | 0.064153 | -0.173285 |
| "AGTHX" | "2025-01-03" | "Factors" | "style" | "Momentum" | 0.595116 | 0.030052 | 0.041436 |
| "AGTHX" | "2025-01-03" | "Factors" | "style" | "Dividend" | -0.089675 | 0.002228 | 0.003271 |
| "AGTHX" | "2025-01-03" | "Factors" | "style" | "Leverage" | -0.228254 | 0.005005 | 0.008653 |
| "AGTHX" | "2025-01-03" | "Idiosyncratic" | "" | "" | 1.008772 | 0.144453 | 0.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)
)
| ticker | factor | date | input_asset_id | Exposure | StandaloneVolatility | VarianceContribution |
|---|---|---|---|---|---|---|
| str | str | str | str | f32 | f32 | f32 |
| "AGTHX" | "Volatility" | "2025-01-03" | "02079K305" | -0.23684 | 0.022815 | -0.061626 |
| "AGTHX" | "Volatility" | "2025-01-03" | "2588173" | -0.429121 | 0.041338 | -0.111658 |
| "AGTHX" | "Volatility" | "2025-01-03" | "67066G10" | 0.0 | 0.0 | 0.0 |
| "AGTHX" | "Volatility" | "2025-01-06" | "02079K305" | -0.219881 | 0.021113 | -0.056636 |
| "AGTHX" | "Volatility" | "2025-01-06" | "2588173" | -0.410883 | 0.039453 | -0.105834 |
| … | … | … | … | … | … | … |
| "AGTHX" | "Volatility" | "2025-01-30" | "2588173" | -0.315365 | 0.031569 | -0.078712 |
| "AGTHX" | "Volatility" | "2025-01-30" | "67066G10" | 0.0 | 0.0 | 0.0 |
| "AGTHX" | "Volatility" | "2025-01-31" | "02079K305" | -0.134344 | 0.01332 | -0.033997 |
| "AGTHX" | "Volatility" | "2025-01-31" | "2588173" | -0.283667 | 0.028124 | -0.071784 |
| "AGTHX" | "Volatility" | "2025-01-31" | "67066G10" | 0.0 | 0.0 | 0.0 |