Risk analysis¶
The risk model page covers beacon.risk, the library that
estimates how assets move together from a returns panel. This page covers
beacon.analysis.risk, which applies it to a finished run: what the
portfolio was exposed to, how much it could lose, where its risk came from,
how concentrated and how liquid it was. It is reached as result.risk on a
backtest result.
import logging
from beacon.backtest import Backtest
from beacon.index.constructor import IndexDefinition
from beacon.index.methodology import MarketCapWeighted
from beacon.testing import dataset
logging.getLogger("beacon").setLevel(logging.ERROR) # keep the output short
definition = IndexDefinition(
index_id="SAMPLE", index_name="Sample Market-Cap Index",
base_date="2023-01-03", base_value=1000.0, currency="USD",
eligibility_rules=[], weighting_scheme=MarketCapWeighted(),
rebalancing_frequency="QUARTERLY", calendar="XNYS",
universe_identifiers=["AAA", "BBB", "CCC", "DDD", "EEE"],
)
backtest = Backtest(initial_capital=1_000_000.0, transaction_cost_bps=5.0,
data_provider=dataset.data_fetcher())
result = backtest.run(definition, start="2023-01-03", end="2024-12-31")
risk = result.risk
result.risk is a RiskAnalytics with five parts, each worked out the first
time it is read and kept:
| Part | Class | Answers |
|---|---|---|
risk.factors |
FactorAnalytics |
What the portfolio was exposed to, and what the factors earned |
risk.loss |
RiskOfLoss |
Value at risk, expected shortfall and stress windows |
risk.over_time |
RiskOverTime |
Expected risk at each rebalance, and where it came from |
risk.concentration |
ConcentrationAnalytics |
Concentration by weight, by risk and by outcome |
risk.liquidity |
LiquidityAnalytics |
How fast the portfolio could be sold |
The factors and traded values are measured from the data the run read, so a
result that kept no data source raises ValueError on result.risk.
Factors¶
A factor is a standardised score for each name. Unless the run's analysis
names its own (factors= on beacon.analysis.request.AnalysisRequest,
passed as analysis= to Backtest.run), the factors are
beacon.strategy.default_factors():
| Factor | Score | A higher score means |
|---|---|---|
size |
The log of the market cap, negated | A smaller company |
value |
Book to price: one over the price-to-book ratio | A cheaper company |
momentum |
The return over the last 252 trading days, leaving out the last 21 | A stronger trend |
Momentum needs at least 115 days of returns in its window, or the name has no
score. The sample dataset has no price-to-book ratios, so its value
exposures and factor returns are all 0.
Standardising. At each rebalance, each factor's values are centred on the universe's mean weighted by market cap, then divided by the standard deviation of the centred values across the universe and capped at 3 either side. The universe is the index's names that day. Centring on the cap-weighted mean means a portfolio holding the universe by market cap has no exposure to any factor, so an exposure reads as a tilt against the market; the sample's market-cap fund has exposures of 0 throughout. A name held outside the universe is scored on the universe's figures, a name with no value scores 0, and with fewer than two values in the universe every name scores 0.
A Factor can instead centre on the equally weighted mean
(centre="equal"), or within groups (centre_by="sector"). A group of
fewer than 5 names is centred on the group one level up, and so on to the
whole universe; fallbacks(date) lists which groups fell back and to what.
factors = risk.factors
day = factors.dates[-1] # the run's rebalances, a list
factors.exposures(day) # names by factors, at that rebalance
factors.caps(day) # the market caps the centring used
factors.holdings_exposures() # the fund's exposure at each rebalance
factors.active_exposures() # the fund's less the index's
Holdings-based exposures are the weights times the names' exposures,
from the weights at the close of each rebalance (or each day with
daily=True, on the last rebalance's exposures). Cash and names without
exposures add nothing. holdings_exposures takes "fund", "index" or
"benchmark"; active_exposures(against="index") is the fund's less a
comparator's.
Factor returns are estimated each day from the day after the first rebalance, by a cross-sectional regression of the universe's returns on their exposures at the last rebalance before the day, with a market intercept, each name weighted by its market cap:
r_i = f_market + Σ_k z_ik f_k + e_i
So a factor's return is what one unit of exposure earned that day with the
other factors held fixed. A day with no more names priced than there are
unknowns is left out. A factor naming its own series (returns= on
Factor, a vendor's factor index, say) takes that series' daily change
instead.
Returns-based betas regress a portfolio's daily returns on the factor
returns, the market's included, by ordinary least squares with an
intercept. The fund and the comparators are taken in excess of the
risk-free rate; "active" (the fund's return less the index's) is not. At
least 20 common days are needed.
factors.factor_returns() # a row a day, the market's first
fit = factors.regression() # "fund", a comparator such as "index", or "active"
fit.betas, fit.r_squared, fit.alpha_a_year
factors.rolling_betas(window=126) # each beta over the trailing 126 days
regression returns a FactorRegression: coefficients gives each
factor's (and the intercept's, alpha) coefficient, standard error and
t-statistic; alpha_a_year is the daily intercept times the days in a year.
rolling_betas defaults to a 252-day window, a row for each window's last
day.
Value at risk and expected shortfall¶
Both are losses, reported as positive numbers: a one-day 99% value at risk of 0.02 says the portfolio loses more than 2% on one day in a hundred. Expected shortfall is the mean loss on the days worse than that.
risk.loss.books() # ['fund', 'index', 'active']
risk.loss.table() # the fund's, every method, level and horizon
risk.loss.table("active") # the fund's return less the index's
The table has a row for each measure and method and a column for each level and horizon:
level 0.95 0.99
days 1 10 1 10
measure method
var historical 0.0132 0.0417 0.0186 0.0588
normal 0.0126 0.0398 0.0181 0.0574
cornish_fisher 0.0126 0.0399 0.0183 0.0578
es historical 0.0163 0.0515 0.0203 0.0641
normal 0.0160 0.0506 0.0209 0.0661
| Method | Value at risk | Expected shortfall |
|---|---|---|
historical |
Minus the returns' 1 - level quantile, linearly interpolated |
Minus the mean of the returns at or below that quantile |
normal |
-(mean + z × sd), with z the normal's 1 - level quantile |
-mean + sd × φ(z) / (1 - level) |
cornish_fisher |
The normal's, with z adjusted for the returns' skew and excess kurtosis |
Not given |
Cornish-Fisher lets fat tails and a lean to one side count. The expansion holds for moderate tails (skew within about 1 either side, excess kurtosis up to about 6); far beyond them it can fall below even the normal figure, and the historical one is the better guide.
The ten-day figure is the one-day figure times √10, the Basel convention,
not a measurement on ten-day returns. Each figure is measured over the
analysis window (start and end in the analysis settings) and is None
with fewer than 20 returns. table takes other levels and horizons.
stress_windows() gives each crisis the run saw: its crisis, start,
end and whether it was partial, with each portfolio's return and
deepest fall over it (fund_return, fund_drawdown, index_return, ...).
Crises are the market's falls of 15% or more from a peak, the market being
the benchmark when the run has one and the index otherwise, or named
episodes passed as named=. The sample's market never fell 15%, so its
table is empty.
The risk model at each rebalance¶
Expected risk comes from a factor model estimated once at each rebalance on the returns before it, never including that day, for every name the fund, its index or its benchmark held:
- Styles. The market and the factors above, fitted on the 252 sessions of daily returns before the rebalance, on the exposures that day.
- Global. At a rebalance whose names span more than one country, the styles with country, sector and currency factors beside them, fitted on the 104 weeks of weekly returns before it. See a global factor model for how it is estimated. A run whose reference data has no country field, like the sample's, always uses the styles model.
A name whose returns do not cover the whole window (one that listed during
it, say) is left out of that rebalance's model rather than shortening the
window for every name. risk.over_time.models.uncovered(date) lists them,
and covered_weight below says how much of the portfolio the model
describes.
The first rebalances may have no expected risk. A model needs its whole
window of returns before the rebalance, and the run's returns start with the
run, so the styles model starts a year in and the weekly global model two
years in. risk.over_time.dates() gives the rebalances that have one
(models.has_history(date) for any one); asking for another raises
CalculationError. The sample starts in January 2023, so its first is
2024-04-01.
RiskModelSettings (in beacon.analysis.risk.model) holds the choices, and
a report takes the same ones as its options' risk_model
section:
| Setting | Default | What it sets |
|---|---|---|
window |
252 | Sessions of daily returns before each rebalance, when fitted on daily returns |
structure |
"auto" |
"global", "styles", or "auto": global when the names span more than one country |
country_field |
"COUNTRY_DOMICILE" |
The reference field a name's country is read from (COUNTRY_LISTING, REGION, ...); none for no country factors |
minimum_names |
10 | The fewest names a country needs for its own factor; one with fewer joins its region's |
sectors, currencies |
True |
Whether the global model has sector and currency factors |
weekly |
True |
Whether the global model is fitted on weekly returns, or on window sessions of daily ones |
weeks |
104 | Weeks of returns before each rebalance, for the weekly model |
result.risk uses the defaults. For other choices, build a RiskOverTime
on the run's factors:
from beacon.analysis.risk.contributions import RiskOverTime
from beacon.analysis.risk.model import RiskModelSettings
half_year = RiskOverTime(risk.factors, settings=RiskModelSettings(window=126))
half_year.dates() # from 2023-10-02: half a year of returns is enough
Expected risk over time¶
over_time = risk.over_time
over_time.summary() # a row a rebalance with a model
day = over_time.dates()[-1]
over_time.expected(day) # the same figures for one rebalance
over_time.contributions(day) # by name
over_time.group_contributions(day, "SECTOR")
over_time.measured() # over the run, from its returns
expected(date, book="fund", against="index") holds the portfolio at its
weights at the close of the rebalance. The model's covariance is
B F Bᵀ + D, so the variance splits exactly into a factor part and a
specific part:
| Figure | Meaning |
|---|---|
volatility |
Expected annualised volatility |
factor_volatility, specific_volatility |
The square roots of its two variance parts: their squares add up to the volatility's square, not the figures themselves |
factor_share |
The factor part's share of the variance |
tracking_error, factor_tracking_error, specific_tracking_error |
The same on the active weights against against |
diversification_ratio |
Σ w_i σ_i / σ: the weighted names' own volatilities over the portfolio's; 1 means no diversification |
average_correlation |
The single correlation that, with these weights and volatilities, gives the portfolio's variance |
covered_weight |
The share of the portfolio's weight in names the model covers |
summary() is expected at every rebalance in dates().
forecast_check(book="fund", against="index") sets each rebalance's
expected volatility and tracking error beside the ones measured over the days
after it, up to and including the next rebalance (or the run's last day):
days, expected_volatility, realised_volatility, volatility_ratio
(realised over expected, above 1 where the model under-forecast) and the same
three for the tracking error, left out when against is None.
bias_statistics(book="fund", against="index") pools the days: each day's
return (or active return) over the daily forecast in force at the close
before, and the standard deviation of those. It is 1 when the forecasts were
right on average; with n days its standard error is about 1/√(2n).
over_time.forecast_check().round(4)
over_time.bias_statistics() # {"volatility": ..., "tracking_error": ...}
contributions(date, book="fund", against=None) splits the expected
volatility by name (or, with against, the expected tracking error):
weight, marginal (the change in volatility per unit of weight),
contribution (weight times marginal, adding up to the volatility) and
share (adding up to 1), largest contribution first. A contribution can be
negative: a name that hedges the rest reduces risk. A tracking error below a
millionth of the portfolio's own volatility is rounding in the weights (a
fund that holds its index exactly, say), so its shares are given as 0 rather
than split as noise. group_contributions
adds them up by a reference field as it stood on the day, "Unknown" for a
name without one. See risk contribution
for the decomposition itself.
measured(group=None) looks back instead. Each name's daily contribution to
the fund's return is its weight at the previous close times its return that
day, and other is whatever else moved the fund (cash, costs, futures). A
contribution's share of the fund's measured variance is its covariance
with the fund's return over that variance, and the shares add up to 1
exactly; volatility is the share times the fund's measured annualised
volatility. Set it beside the expected contributions to see whether the
names that were expected to carry the risk did.
Concentration¶
concentration = risk.concentration
concentration.by_weight() # at each rebalance's close
concentration.by_weight(field="SECTOR") # across sectors
concentration.curve(day) # names largest first
concentration.by_risk() # from the expected risk
concentration.absorption_ratio(day)
concentration.by_outcome() # over the whole run
By weight. The weights above 0 are scaled to add up to 1, so cash is left out:
| Figure | Meaning |
|---|---|
names |
Names held |
herfindahl |
H = Σ w_i² |
normalised_herfindahl |
(H - 1/N) / (1 - 1/N): 0 equally weighted, 1 a single name |
effective_number |
1 / H, the equally weighted names that would be as concentrated |
largest, top_5 ... top_25 |
The largest weight, and the top 5, 10, 20 and 25 together |
names_for_50, names_for_80, names_for_90 |
The fewest names making that share of the portfolio |
gini, entropy, entropy_effective_number |
With optional=True: the Gini coefficient, -Σ w_i ln w_i and its exponential |
by_weight takes book ("fund", "index", "benchmark"), field for
group weights and daily=True for every day; against("index") is the
fund's figures less the index's. curve(date) lists the names largest
first with each one's weight, the cumulative weight, and the Herfindahl and
effective number of the top k on their own, with the change the k-th name
made to each. by_period("Y") (or "M") and in_crises() average the
daily figures over each year or month, and inside each crisis and on the
calm days outside them, within the analysis window, as the stress windows
are. drift() is each day's half sum of the absolute
gaps between the weights and the last rebalance's: the share of the
portfolio that would trade to put it back.
By risk, at each rebalance in risk.over_time.dates(), from each name's
share of the expected volatility: their Herfindahl (risk_herfindahl), the
effective number of risk sources (1 / Σ p_i²), the top names' share of the
risk, and the diversification ratio and average correlation.
The absorption ratio is the share of the total variance of the model's
names (all the fund, index and benchmark held) in the risk model's largest
eigenvalues, one per independent factor by default: the factor covariance's
rank, which is one fewer per constrained set in a global model, and leaves
out a factor whose exposures are all 0, as the sample's value is. It reads
how much of the risk the common drivers absorb. It depends on the names held,
not on their weights; pass components= to count a different number.
By outcome, over a window (start, end, the whole run by default):
return, the daily contributions added up (not compounded), the top 10
contributors' share of it (top_10_share), and breadth, the share of the
names with a contribution whose contribution was positive.
Liquidity¶
At each rebalance and month end (risk.liquidity.dates()), a holding worth
V is sold at no more than a share of the name's average daily traded value
ADV a day:
days = V / (participation × ADV)
and within d days the portfolio can sell Σ min(V, d × participation × ADV).
The traded value is the close times the volume, in the run's currency, over
the name's last 20 days with a volume, read from the data the run used. A
holding's value is its weight times the fund's net assets, for the index and
benchmark as well, so all three read as if the fund held them. A name with no
traded value cannot be sold at all: its days are infinite and it is counted
apart.
liquidity = risk.liquidity
liquidity.days_to_sell(day) # each holding's value, traded value and days, slowest first
liquidity.profile() # a row a date
liquidity.traded_values() # dates by names
profile() gives sellable_1d, sellable_5d and sellable_20d (the
shares sellable within each horizon), days_to_sell_all (the slowest name
with a traded value), mean_days (weighted by value) and
no_traded_value (the share in names that cannot be sold). The defaults are
the liquidity options' (participation 20%, 20 days, horizons 1, 5 and 20);
for others, build one directly:
from beacon.analysis.risk.liquidity import LiquidityAnalytics
cautious = LiquidityAnalytics(result.run_data, dataset.data_fetcher(),
participation=0.05)
cautious.profile(horizons=(1, 2))
Where it sits¶
Downstream of the run: it reads a finished BacktestResult (its weights,
returns, reference snapshots and comparators) and the DataFetcher it read,
and calculates rather than re-simulates. The covariance and factor model
estimation is beacon.risk; return attribution and the
risk decomposition are in attribution; the return,
drawdown and ratio figures are in performance, and what
the portfolio held and traded in portfolio. The analysis
options behind each choice above are shown and set in reports.
The full API is in the analysis reference.