Performance¶
beacon.analysis.performance measures how a run did: its returns, its risk,
its drawdowns, and its figures against the index it tracked or any other
series. PerformanceAnalytics gives the run's own figures; RelativeAnalytics,
CrisisAnalytics and RegimeAnalytics measure it against one comparator.
Every figure is computed with beacon.analysis.performance.formulas, which
holds each formula once as a pure function of returns or levels. A backtest's
summary(), a report and a stored run's analysis all call the same functions,
so they cannot disagree.
A run's analytics¶
A finished backtest carries its analytics as result.analytics, built on
first read and kept.
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")
analytics = result.analytics
print(analytics.headline()) # total_return 0.457, sharpe_ratio 1.53, ...
print(analytics.summary()) # with the monthly hit rate and tracking figures
The analytics read a RunData, everything the analysis of a run needs:
result.run_data for a finished run. Written to a folder, it reads back
exactly as it was, whatever has happened to the data since, so a run stored
months ago gives the same figures as when it finished.
from pathlib import Path
from beacon.analysis import RunData
from beacon.analysis.performance import PerformanceAnalytics
result.run_data.write(Path("stored-run"))
stored = PerformanceAnalytics(RunData.from_record(Path("stored-run")))
print(stored.headline() == analytics.headline()) # True
What the figures are measured over¶
AnalysisSettings says what a run's figures are measured over and against.
Every field left as None takes the run's own.
| Setting | Means | The run's own |
|---|---|---|
start, end |
The first and last day measured | The run's first and last day |
periods_per_year |
How many daily returns make a year | The run's ModellingAssumptions, 252 by default |
risk_free_rate |
The annual rate the Sharpe ratio and excess returns are measured over | The run's ModellingAssumptions, 0 by default |
tracked |
The comparator tracking is measured against: index, optimised or benchmark |
The optimised index when the run solved one, else the index |
from beacon.analysis.performance import AnalysisSettings
year = PerformanceAnalytics(result.run_data,
AnalysisSettings(start="2024-01-01", risk_free_rate=0.04))
print(year.headline()["sharpe_ratio"])
For result.analytics to use settings, pass them in the run's analysis
request: Backtest.run(..., analysis=AnalysisRequest(options=AnalysisOptions(window=settings))),
with AnalysisRequest from beacon.analysis.request and AnalysisOptions
from beacon.analysis.reports.options. See reports for what
else a request decides.
Over the whole run, the first return runs from the initial capital to the first day's close, so the cost of buying in on the first day counts in the total return, volatility and drawdown. Over a window that starts later, the figures run from the close before its first day. With flows, every return is a unit's (time-weighted), so money arriving is not a return.
Definitions¶
The definitions a reader needs to read the figures correctly:
| Figure | Definition |
|---|---|
| Annualised return | (1 + total) ^ (periods_per_year / days) - 1 |
| Volatility | The sample standard deviation of the daily returns, times √periods_per_year |
| Sharpe ratio | The mean daily excess return over its standard deviation, times √periods_per_year |
| Tracking error | The volatility of the active returns (the fund's daily return less the comparator's) |
| Tracking difference | The fund's compounded return less the comparator's |
| Information ratio | The mean active return over its standard deviation, times √periods_per_year |
| Maximum drawdown | The largest fall from a running peak, measured from the starting value |
| Correlation, beta | Of the daily returns over the days both series have; beta is their covariance over the comparator's variance |
| Money-weighted return | The annual internal rate of return of the investors' cash flows, over calendar days (ACT/365); only for a run with flows, and None when the flows do not change sign |
The Sharpe and information ratios are a daily average over a daily spread, annualised by the square root of the days a year. Both halves come from the same returns, so the ratio does not change with leverage, and multiplied by the square root of the years it is the t-statistic of the mean.
The Sharpe ratio's risk-free rate is the daily rate that compounds to the
annual one, (1 + rate) ^ (1 / periods_per_year) - 1, subtracted from each
day's return. Every excess return in the module (Sortino, the regressions,
the bootstrap) uses the same daily rate.
A series that does not vary has nothing to relate. A spread of at most
1e-12 a day counts as none (a perfectly smooth series has a standard
deviation of about 1e-17 in floating point). Then the Sharpe ratio,
correlation and beta are 0, and the information ratio is None, as there is no
active risk to measure it against. A figure that cannot be measured, from too
few returns, is None, unless its definition says otherwise: volatility and the
Sharpe ratio are 0 with fewer than two.
Ratios and drawdowns¶
ratios() gives the risk-adjusted ratios beyond the Sharpe ratio. A ratio
over a risk that is not there (no day below the target, no drawdown) is None.
| Ratio | What it is |
|---|---|
sortino |
The mean excess return over the downside deviation (the root mean square of the shortfalls below the risk-free rate, every day counted), annualised |
kappa_3 |
The mean excess return over the cube root of the third lower moment, which weighs larger shortfalls more. Daily, not annualised |
upside_potential |
The mean gain above the risk-free rate over the downside deviation |
omega |
The sum of gains above the risk-free rate over the sum of shortfalls below it |
calmar |
The annualised return over the deepest drawdown |
martin |
The annualised mean excess return over the ulcer index |
sterling |
The annualised return over the mean of the three deepest drawdowns |
burke |
The annualised return over the root of the sum of every drawdown squared |
gain_to_pain |
The sum of monthly returns over the losing months' sum |
probabilistic_sharpe |
The probability the true Sharpe ratio is above sharpe_benchmark (0 a year unless given), allowing for skew and fat tails |
deflated_sharpe |
The same against the best Sharpe ratio trials strategies would show by luck; None unless trials and trial_variance are given |
lo_sharpe |
The Sharpe ratio corrected for serial correlation over 10 days of lags |
ratios = analytics.ratios(trials=20, trial_variance=0.25)
print(ratios["sortino"], ratios["deflated_sharpe"])
drawdowns = analytics.drawdowns()
deepest = drawdowns.worst[0]
print(deepest.peak.date(), deepest.trough.date(), deepest.depth, deepest.recovery)
print(drawdowns.time_below_peak, drawdowns.ulcer_index)
A drawdown starts on a peak, reaches its trough and ends on the first day the
level is back at the peak; one still open on the last day has no recovery and
is current_drawdown. Lengths are in trading days from the peak.
drawdowns(worst=5) returns a DrawdownFigures with the deepest worst,
the current fall below the peak, the average depth and length, the share of
days below a peak, the longest drawdown, the recovery factor (total return
over the deepest), the ulcer index (the root mean square of the daily
drawdown) and the pain index (its mean size). drawdown_series() is the
daily drawdown itself, and levels() the level everything is measured along.
Return figures¶
| Method | Gives |
|---|---|
trailing_returns() |
1M, 3M, 6M, 1Y, 3Y, 5Y, 10Y, MTD, QTD, YTD and inception, each a WindowReturn; None for a window longer than the run |
trailing_table() |
The same as a table, for the fund, its index and benchmark, and the fund's less each's |
periods(frequency) |
Each calendar year, quarter or month (annual, quarterly, monthly) with the headline figures and each comparator's |
period_table(frequency) |
The same as a table |
monthly_grid(of) |
The calendar grid of monthly returns, a row a year, with each year's; of is fund, a comparator's name, or difference index |
month_of_year() |
The mean return in each calendar month over the years |
best_and_worst() |
The best and worst day, week, month, quarter and year, each marked partial when the run covered only part of it |
positive_shares() |
The share of days, months and years that gained |
gains_and_losses(frequency) |
The average gain, average loss and their ratio |
longest_streaks() |
The longest run of months that all gained, and that all lost |
means() |
The arithmetic and geometric mean daily return, and each a year |
A trailing return runs from the close on or before the same date that many months back from the last day. Only windows longer than a year are annualised (3 years and more, and since inception when it spans more than a calendar year), since a shorter return annualised overstates what was earned. Since inception is the headline's total return, so over the whole run it includes the first day's cost. Returns to date run from the close before the month, quarter or year began. Calendar periods compound their own days, so the months compound to the year and the years to the run.
print(analytics.trailing_table().loc[["1Y", "YTD", "inception"]])
print(analytics.monthly_grid().round(4))
best, worst = analytics.best_and_worst()["monthly"]
print(best.period, best.value, worst.period, worst.value)
Distribution and bootstrap¶
distribution() describes the shape of the daily returns: the downside
deviation (below the risk-free rate) and semi-variance (below the mean), both
annualised; skew and excess kurtosis, bias-corrected as a spreadsheet's are;
the tail ratio (the 95th percentile over the size of the 5th); the serial
correlation at lags of 1 to 5 days; and a Histogram of 50 bins with the
counts a normal distribution of the same mean and spread would give.
bootstrap() says how far the annualised return and the Sharpe ratio could
have come out otherwise. It draws the run's own days again, 1,000 runs of the
same length by default, in blocks of block days to keep any dependence
between neighbouring days, and returns each figure as an Interval holding
95% of the resampled runs. The same seed draws the same runs.
interval = analytics.bootstrap(block=5)["sharpe_ratio"]
print(interval.value, interval.low, interval.high)
Rolling figures¶
rolling(window) gives each day's return, volatility, Sharpe ratio, Sortino
ratio and maximum drawdown over the window days ending that day (252, a
year, by default; 63 is a quarter), blank until the first full window. Each uses
the same definition as the static figure. With a comparator, by name or by
its levels, it adds the relative return, tracking error, information ratio,
beta and correlation. rolling_outcomes() summarises every full window: the
worst, median and best return, the share that gained and the share ahead of
the comparator.
As with the static figures, a rolling Sharpe ratio, beta or correlation over a spread of none is 0, and a rolling information ratio is blank. Against its own index the sample is one such case: after the first day's buying cost it tracks to rounding, so every full window's information ratio is blank.
Against a comparator¶
The sample run tracks its index almost exactly, so its relative figures are near zero. An equal-weighted index over the same names gives something to compare against.
from beacon.index.calculation import IndexCalculator
from beacon.index.methodology import EqualWeighted
equal = IndexDefinition(
index_id="EQUAL", index_name="Sample Equal-Weighted Index",
base_date="2023-01-03", base_value=1000.0, currency="USD",
eligibility_rules=[], weighting_scheme=EqualWeighted(),
rebalancing_frequency="QUARTERLY", calendar="XNYS",
universe_identifiers=["AAA", "BBB", "CCC", "DDD", "EEE"],
)
levels = IndexCalculator(equal, dataset.data_fetcher()).run(end_date="2024-12-31").index_levels
comparison = analytics.comparison(levels)
print(comparison.own["total_return"]) # the comparator's own figures
print(comparison.relative["information_ratio"]) # the run's against it
print(analytics.rolling(63, comparator=levels)["information_ratio"].dropna().tail(2))
comparison(comparator) takes a comparator's name (index, optimised,
benchmark) or its levels, and measures over the days both have. Its own
holds the comparator's headline figures and monthly hit rate; its relative
the relative and annualised relative return, tracking error, information
ratio, correlation, beta and the share of months the run beat it. The
comparator's first day returns nothing, from its level that day, and when it
starts after the run, the run's first day does too.
RelativeAnalytics(run, comparator) goes further against one comparator:
| Method | Gives |
|---|---|
regression() |
Jensen's regression of the daily excess returns on the comparator's: alpha (a year), beta, r_squared, alpha_t, its two-sided alpha_p, and residual_risk a year. None with fewer than three days. Against a comparator that does not vary the beta is 0, as formulas.beta gives it, and alpha is the mean excess return |
treynor() |
The annual mean excess return per unit of beta |
capture() |
Up and down capture on months, as Morningstar measures them: the fund's geometric mean monthly return in the months the comparator rose (fell) over the comparator's, and their ratio |
batting_average() |
The share of months the fund beat the comparator |
information_ratio_t() |
The information ratio times the square root of the years, and its two-sided probability by chance |
downside_tracking_error() |
The root mean square of the days the fund fell behind, every day counted, a year |
relative_levels(), relative_drawdowns() |
The fund's wealth over the comparator's, and its stretches of falling behind |
tracking_difference_by_year() |
Each calendar year's return less the comparator's, and the standard deviation of those differences |
figures() |
All of these by name, with the comparison's relative figures |
from beacon.analysis.performance import RelativeAnalytics
relative = RelativeAnalytics(result.run_data, levels)
print(relative.regression())
print(relative.capture(), relative.batting_average())
Crises and regimes¶
CrisisAnalytics measures how the run held up in crises. By default a crisis
is each fall of a broad market series deeper than 15%, from its peak to its
trough. Pass named= a list of Episode(name, start, end) to use your own
instead; NAMED_CRISES holds eight, S&P 500 closing peak to closing trough.
Both are in beacon.analysis.performance.crises. The market is the run's
benchmark when it has one, else its index, or any series given as market=;
it need not be the comparator.
Each Crisis gives the fund's and comparator's return over the days after
the start through the end, the outperformance, each's deepest fall inside,
the beta over the window, the alpha beyond what the whole run's beta
predicts, the returns 3 and 6 months after the trough, and the trading days
the fund took to get back to its value at the start. A window the run covers
only part of is marked partial. summary() gives the average
outperformance, the share of crises outperformed, the crisis capture and the
pooled alpha over every crisis day, a year.
RegimeAnalytics splits the run's days by the market's behaviour: months it
rose and fell, the calmest and most volatile fifth of days by 63-day rolling
volatility, bear markets (falls of more than 20%) and corrections (10% to
20%). Each Regime compounds the fund's and comparator's returns over its
days, as if invested on those days alone, annualises their mean so regimes of
different lengths compare, and gives the beta and Jensen's alpha (over the
risk-free rate) over them. A regime the run never saw is None. A crisis's
alpha, by contrast, is a total over the window with no risk-free rate.
from beacon.analysis.performance import CrisisAnalytics, RegimeAnalytics
crises = CrisisAnalytics(result.run_data, levels, market=levels, threshold=0.05)
print(len(crises.crises()), crises.summary())
regimes = RegimeAnalytics(result.run_data, levels, market=levels).regimes()
print(regimes["down months"])
The sample's market never fell 15%, so the example lowers the threshold to 5% and has no bear markets or corrections.
Where it sits¶
Downstream of the backtest: it reads a finished or stored run and recomputes nothing. What the run held, traded and paid is in portfolio; its risk in risk analysis; the sources of its return in attribution. Which figures a report shows, and how it lays them out, is in reports.
The full API is in the analysis reference.