Attribution¶
Where did the return come from, and where did the risk come from. Two questions, both answered by splitting a total across the things that caused it, and both with the property that the parts must add to the whole.
Return attribution¶
attribute() takes series, not results: the return being explained, the
weights held, and the constituents' returns, all per period. Build them from
an IndexResult and prices. This example uses a capped market-cap index over
the five US-dollar names in the sample dataset, so local prices need no FX.
For a backtest, result.attribution does all of this from the run's own
records; see A run's attribution.
from beacon.analysis import attribute, cap_drag, cost_drag, drifted_weights
from beacon.backtest.main import Backtest
from beacon.index.calculation import IndexCalculator
from beacon.index.constructor import IndexDefinition
from beacon.index.methodology import MarketCapWeighted
from beacon.testing import dataset
names = ["AAA", "BBB", "CCC", "DDD", "EEE"]
definition = IndexDefinition(index_id="CAPPED",
index_name="Capped Index",
base_date="2024-01-02",
base_value=1000.0,
currency="USD",
eligibility_rules=[],
weighting_scheme=MarketCapWeighted(),
rebalancing_frequency="MONTHLY",
calendar="XNYS",
universe_identifiers=names,
max_constituent_weight=0.25)
fetcher = dataset.data_fetcher()
result = IndexCalculator(definition, fetcher).run(end_date="2024-06-28")
prices = dataset.prices()[names].loc["2024-01-02":"2024-06-28"]
weights = drifted_weights(result.weight_snapshots, prices)
asset_returns = prices.pct_change().reindex(weights.index)
period_returns = (weights.shift(1) * asset_returns).sum(axis=1)
attribution = attribute(period_returns, weights, asset_returns)
attribution.contributions # per constituent, largest first
attribution.residual # should be machine epsilon
attribution.to_frame()
Each entry in contributions is a Contribution with four fields:
| Field | Holds |
|---|---|
asset_id |
The constituent |
contribution |
Its linked contribution; these sum to the total return |
average_weight |
Its mean weight across the window, for context |
total_return |
The constituent's own return over the window |
The result also carries total_return, residual, periods, start and
end. reconciles() checks the residual against a tolerance of 1e-9.
Within a period the split is exact¶
R_t = Σ_i w_{i,t-1} × r_{i,t}
Each name's contribution is last period's weight times this period's return.
attribute() applies that shift itself, so pass the weights as held on each
date. Because the weighting scheme drives the index level, this holds against
the level to better than 1e-12 for every scheme, on rebalance days as well as
ordinary ones.
Across periods it is not, and the fix has a name¶
Returns compound; contributions add. So summing daily contributions undershoots the compounded total, by about a percentage point over a 130-day window on a modest example, which is far too large to write off as rounding.
Carino linking is the correction. Scale each period's contributions by
k_t / K, where k_t = ln(1+R_t)/R_t and K = ln(1+R)/R for the total return
R. Then the scaled contributions sum to R exactly, because
Σ_t (k_t/K) R_t = (1/K) Σ_t ln(1+R_t) = ln(1+R)/K = R
attribute() applies it. The residual is reported regardless and should sit at
machine epsilon. A residual that is not tiny means an assumption has broken
upstream, which is worth surfacing rather than rounding away. A period
return of -100% or worse cannot be linked and raises.
Weights drift, and using the wrong ones is the classic error¶
Between rebalances the index holds fixed units, so weights move with relative
performance. drifted_weights() reconstructs them from the rebalance
snapshots and prices. Attributing with target weights instead of held
weights attributes a return the index did not earn to a position it did not
hold.
The drags¶
cap_drag and cost_drag are not contributions. A contribution is a name's
share of what happened; a drag is what a decision cost against a
counterfactual: an uncapped index, or a frictionless one. attribute() takes
both as optional numbers and reports them alongside, never in the
contribution list, because that would put two different kinds of quantity in
one column. Two helpers compute them.
cap_drag(capped, uncapped, prices) is the capped index's return minus
the return of the same methodology uncapped, each path drifted from its own
snapshots. The uncapped weights are stored rather than estimated:
result.cap_reports holds a CapReport for every rebalance where the cap
bound, and its uncapped_weights is the counterfactual. A rebalance with no
report was not capped, so its published weights already are the uncapped
ones.
uncapped = {date: (result.cap_reports[date].uncapped_weights
if date in result.cap_reports else snapshot)
for date, snapshot in result.weight_snapshots.items()}
capping = cap_drag(result.weight_snapshots, uncapped, prices)
cost_drag(total_costs, initial_capital) is the transaction costs paid as
a fraction of starting capital, negated so it reads as a drag. It is the
direct effect only: it leaves out the compounding of the capital spent rather
than invested. For the full effect, compare against a zero-cost backtest. A
run's implementation() measures each cost as a share of the NAV on the day
it was paid instead, linked so it adds up with the rest of the tracking
difference (see Implementation).
backtest = Backtest(initial_capital=1_000_000.0,
transaction_cost_bps=10.0,
data_provider=fetcher).run(definition, end="2024-06-28")
paid = sum(trade.transaction_cost for trade in backtest.portfolio.transactions)
attribution = attribute(period_returns, weights, asset_returns,
cap_drag=capping,
cost_drag=cost_drag(paid, 1_000_000.0))
attribution.cap_drag, attribution.cost_drag
A run's attribution¶
A backtest result carries its own attribution: result.attribution is an
Attribution (beacon.analysis.attribution.Attribution) over the records the
run kept, so there are no weights or prices to rebuild. It reads each book's
daily weights, each name's daily total return in the fund's currency, the
reference data at each rebalance, and the run's cash and costs.
A book is "fund", "index" (the index the fund tracked), "optimised"
(the optimised index, for a strategy that tracks one) or "benchmark" (the
benchmark of record). Every method takes start and end, both
included, and covers the whole run by default. The days are linked as above:
by Carino's ln(1+R)/R for one book's return, and for a return over a
comparator by the active factor
k_t = (ln(1+R_t) - ln(1+R^b_t)) / (R_t - R^b_t)
over the same for the window (1/(1+R_t) where the two returns are equal),
so the parts add up to the fund's compounded return less the comparator's.
The examples run the capped index above as a fund, with an equal-weighted
index over the same names as its benchmark. A benchmark given as a level
series has no weights, so every method that reads them raises a KeyError
against it; only the returns-based factor attribution works there.
from beacon.index.methodology import EqualWeighted
equal = IndexCalculator(IndexDefinition(index_id="EQUAL",
index_name="Equal Index",
base_date="2024-01-02",
base_value=1000.0,
currency="USD",
eligibility_rules=[],
weighting_scheme=EqualWeighted(),
rebalancing_frequency="MONTHLY",
calendar="XNYS",
universe_identifiers=names),
fetcher).run(end_date="2024-06-28")
run = Backtest(initial_capital=1_000_000.0,
transaction_cost_bps=10.0,
benchmark=equal,
data_provider=fetcher).run(definition, end="2024-06-28")
run.attribution.contribution().to_frame() # each name
run.attribution.contribution("benchmark", field="SECTOR").to_frame()
run.attribution.active("benchmark").to_frame()
By name and by group¶
contribution(book="fund", field=None) is each name's contribution to a
book's return, as an AttributionResult like attribute()'s. With field, a
column of the reference data the run kept (SECTOR, CURRENCY and
EXCHANGE in the sample), it is each group's instead: a name counts in the
group it was in at the close before, which is its group at the last rebalance
before the day, and a group's return is what its names earned over what it
held.
active(against="index", field=None) does the same for the fund against a
comparator: the fund's weights less the comparator's, both as they drift and
over the union of their names, against the fund's daily return less the
comparator's. The days are linked by the active Carino factor, so its
total_return is the fund's compounded return less the comparator's, the same
total the Brinson, currency, factor and implementation results below explain.
Either way, what the names do not explain (cash, costs, fees, futures, the timing of dividends) is the residual, never folded into a name.
Brinson-Fachler¶
brinson(field, against="index") splits the fund's return over a comparator,
group by group, into how it weighted each group (allocation), what it held
within each (selection) and the two together (interaction). It is worked out
each day, with w and b the fund's and the comparator's weights at the
close before and R and R^b their returns, by group g and in all:
allocation_g = (w_g - b_g) (R^b_g - R^b)
selection_g = b_g (R_g - R^b_g)
interaction_g = (w_g - b_g) (R_g - R^b_g)
and the days are linked by the active factor. Interaction has a line of its own rather than being folded into selection, and cash is a group of its own: a book's weight in it is one less its names', and its return is the interest the fund earned over the cash it held at the close before.
A group the fund does not hold takes the comparator's group return as its own, so it has no selection or interaction, only the allocation of staying out. A group the comparator does not hold takes the comparator's whole return, so the fund's bet on it is all interaction.
The BrinsonResult carries fund_return, comparator_return and
active_return (the fund's compounded return less the comparator's),
explained (what the effects add up to: the holdings' active return) and
residual (the active return less that: costs, fees, dividends not yet paid,
a price index's missing dividends), with start, end and periods.
groups has a row a group: each book's mean weight and compounded return in
it, each effect and their total, largest first. A group neither book held is
left out; cash is always shown. totals adds each effect over the groups, and
reconciles() checks the residual against 1e-9.
sectors = run.attribution.brinson("SECTOR", against="benchmark")
sectors.active_return, sectors.explained, sectors.residual
sectors.groups[["allocation", "selection", "interaction", "total"]]
sectors.names.loc["Technology"] # AAA and BBB, adding up to the group's row
names opens each group into its names, indexed by group and name. With
W_g and B_g the two books' weights in the group, w_i and b_i a name's
and r_i its return:
d_i = (w_i / W_g - b_i / B_g) (r_i - R^b_g)
selection_i = B_g d_i
interaction_i = (W_g - B_g) d_i
A group's d_i add up to R_g - R^b_g, and the days are linked by the group
effects' factors, so a group's names add up to its selection and
interaction. Allocation is a decision about the group and stays on its row.
A name the comparator holds and the fund does not has a negative share, so it
shows what not holding it cost. A name that changed group has a row in each.
brinson_by_period(field, against="index", frequency="Y") repeats the
attribution over each year, or each month with "M", each period linked
within itself from the close before its first day: a row a period, with the
three effects, explained, active_return and residual. Periods compound,
so the rows do not add up to the whole run's figures.
Currency¶
A name's daily return in the fund's currency r is its return in its own
currency l and the move of that currency x: 1 + r = (1 + l)(1 + x), so
r = l + x + l x
currency(against=None, by_currency=False) gives each part the name's weight
at the close before and links the days: local, currency, and the cross
term on a line of its own. Local returns are worked out from the run's own
closes and corporate actions in each name's currency, as the fund's returns
are. With against, it explains the active return from the active weights;
with by_currency=True, its table splits each part by the names'
currencies. The sample's names are all in US dollars, so here it is all local.
It returns an EffectsResult, as factors() and implementation() do:
total_return (a book's compounded return, or the fund's less the
comparator's), effects (each effect linked, largest first), explained,
residual, table (empty unless a breakdown was asked for), start, end,
periods and reconciles().
Factors¶
factors(analytics, against=None, method="holdings") explains the return by
the run's factors. analytics is the run's FactorAnalytics,
run.risk.factors: each name's exposures at each rebalance, standardised over
the universe, and each day's factor returns, from a cross-sectional
regression on those exposures, weighted by market cap, with a market
intercept. run.risk needs the data source the run read.
Holdings-based ("holdings") splits every name's return as
r_i = f_market + Σ_k z_ik f_k + e_i
with z_ik its exposures at the last rebalance before the day and e_i
defined as what is left, so the split is exact. Holding w at the close
before, the fund earns (Σ w) f_market from the market, X_k f_k from each
factor (X_k = Σ w_i z_ik) and Σ w_i e_i of its own: the lines market,
one a factor, and specific.
Returns-based ("returns") regresses the fund's daily return over the
risk-free rate on the factor returns (the market's among them) over the
window, by ordinary least squares with an intercept. It splits the return into
risk_free, alpha, each beta times its factor's return, and the
regression's residual.
With against, the holdings method takes the active weights, and the returns
method regresses the active return, with no risk-free rate taken off. Any
other method raises a ValueError.
factors = run.risk.factors
run.attribution.factors(factors).effects
run.attribution.factors(factors, method="returns").effects
Implementation¶
implementation(against="index") splits the fund's tracking difference
against the index it tracked into what caused it, day by day from the run's
own accounting, linked by the active factor. Each day, V is the base the
fund's return is measured on: the NAV at the close before, grown by any units
dealt that day at yesterday's NAV per unit. w and b are the fund's and the
index's weights at the close before (the fund's scaled onto V, so money
dealt that day is held as cash), c = 1 - Σ w is the fund's cash, r the
names' total returns, and g = w - (1 - c) b the gap between the fund's
invested weights and the index's, g^R as it was at the last rebalance.
| Effect | What it is |
|---|---|
cap_and_screens |
Σ g^R r: what the cap, the screens, the smallest position and rounding left the fund holding differently |
drift |
Σ (g - g^R) r: how that gap has moved since, as flows, dividends and the cash band traded the fund and not the index |
cash_drag |
-c Σ b r plus the interest earned over V: the return the cash gave up against the index, less what it earned |
equitisation |
The futures' profit and loss before their costs, over V, which wins back the cash drag |
fixed_costs, market_impact, futures_rolls, futures_trading |
Each day's cost, over V |
fees |
The management fee charged, over V |
withholding_tax |
The tax withheld from dividends, over V |
creation_fees |
What dealing investors paid into the fund, over V |
dividend_treatment |
Σ b r less the index's own return: the dividends a price index leaves out, or a total return index's own timing |
They add up to the fund's return from its names, cash and futures, less its
costs and fees, less the index's return. explained is that sum; the
residual is whatever else moved the NAV, such as a currency hedge or trades
dealt away from the close. By default the breakdown starts on the run's first
day, whose close before is the starting capital all in cash, so the cost of
buying in is counted; with a start, it starts from that day's close.
costs = run.attribution.implementation()
costs.total_return # the tracking difference
costs.effects # here almost all fixed_costs: 10 bps on every trade
costs.residual
Against "benchmark" the accounting is the same, but the gap at each
rebalance is then the difference between two methodologies, so it reads as
cap_and_screens, and its movement since as drift.
Weights¶
weights(book="fund") is a book's daily weights, dates by names, as every
method above reads them: names as strings, blanks as zero. It raises a
KeyError if the run kept no weights for that book, as for a benchmark given
as levels, or if the book is none of the four.
Risk contribution¶
The same idea applied to volatility rather than return, and the reason a weights table is not a risk view.
A name at 8% of a quiet utility might account for 3% of volatility; a name at 4% of something volatile that moves with everything else accounts for 9%. Weight tells you what you own. Contribution tells you what you are exposed to.
from beacon.risk import (
active_risk_contributions, estimate_risk_model, risk_contributions,
)
model = estimate_risk_model(dataset.returns())
held = result.weight_snapshots[max(result.weight_snapshots)]
benchmark = dataset.equal_weights()
total = risk_contributions(held, model.covariance)
active = active_risk_contributions(held, benchmark, model.covariance)
total.volatility, total.contribution
active.volatility, active.contribution # volatility here is the tracking error
Weights can be a mapping or a pandas Series, such as
OptimisationResult.weights. The result is a RiskContributions with
volatility, per-name marginal and contribution, covered_weight and
uncovered.
For weights w and covariance S, portfolio volatility is √(w'Sw), and
each name's contribution is w_i × (Sw)_i / σ. These sum to σ exactly. It
follows from Euler's theorem, not from an approximation, so if the parts do
not add to the whole, something is wrong and there is no tolerance to hide
behind.
Active risk¶
The identical arithmetic on active weights w − b, where the reported
volatility is the tracking error against that benchmark. Usually the more
useful number for an index product: it answers which position is making me
diverge.
Contributions here can be negative, and that is the point. An active weight is signed, so a position pointing against the portfolio's overall active exposure genuinely reduces tracking error: it hedges. An absolute value would hide whichever position is doing the most useful thing in the portfolio.
Being underweight is not enough to contribute negatively: an underweight in a name the portfolio is also underweight overall contributes positively, because the active weight and its marginal share a sign.
Active weights are taken over the union of both universes. A benchmark constituent you do not hold is routinely the largest active position there is, and intersecting would drop it silently.
Names the model cannot price¶
A constituent added last week has too little history. Rather than dropping it
and renormalising, which claims the portfolio holds more of the covered names
than it does, the decomposition runs over the covered names at their actual
weights, reports covered_weight, and lists the rest in uncovered. The
identity still holds exactly over the part it describes. For active risk,
covered_weight is a share of gross active weight, since active weights sum
to roughly zero.
A number that describes 94% of an index and says so beats one that describes 100% of a portfolio nobody holds.
Where it sits¶
Downstream of everything. The functions read a completed IndexResult (and
a BacktestResult for costs) plus prices; a run's attribution reads the
records the run kept. Both compute rather than re-simulate.
AttributionResult, from attribute(), contribution() or active(),
carries a .plot accessor: contributions() draws the
per-constituent bars, with the total, the residual and any drags stated in
the chart's note rather than drawn as bars. See charts.
The full API is in the analysis reference.