Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH Bootstrap analysis #261

Merged
merged 23 commits into from
Jan 5, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ea86ebf
ENH Implement bootstrap analysis for all performance statistics.
twiecki Dec 18, 2015
ff71d08
WIP Move function and fix global variable names.
twiecki Dec 21, 2015
ff0ae26
TST Add unittests for calc_bootstrap.
twiecki Dec 30, 2015
4f92640
DOC Move functions and add doc-strings.
twiecki Dec 30, 2015
f18fdbd
BUG Fix boostrap -> bootstrap
twiecki Dec 30, 2015
b7b5146
ENH Add bootstrap option to show_perf_stats.
twiecki Dec 30, 2015
a94b359
BUG Use .iloc to index in annual_returns function.
twiecki Dec 30, 2015
0a74017
MAINT Specify dtype for bootstrap DataFrame
twiecki Dec 30, 2015
83ae38d
DOC Add bootstrap kwarg doc string.
twiecki Dec 30, 2015
db4f097
BUG Various fixes to bootstrap code. Mainly fixing duplciated indices.
twiecki Dec 30, 2015
9ace89c
ENH Add bootstrap option to tearsheet functions and tests.
twiecki Dec 30, 2015
78430a9
BUG Pass bootstrap kwarg from tearsheet to show_perf_stats function.
twiecki Dec 30, 2015
f4c061e
ENH Add performance statistics boxplot. Refactored bootstrapped perfo…
twiecki Dec 30, 2015
a36fb6e
BUG Apply over index, then transpose.
twiecki Dec 30, 2015
2a25881
STY Wrong indent.
twiecki Dec 30, 2015
883377b
TST Typo, missing comma in expand_params.
twiecki Dec 30, 2015
9d790fd
MAINT Revert to use numpy.round instead of DataFrame.round to keep pa…
twiecki Dec 30, 2015
033e6b1
MAINT factor_returns are now a kwarg instead of an arg.
twiecki Jan 5, 2016
82c73da
ENH Add IQR to distribution stats.
twiecki Jan 5, 2016
07f8ab8
MAINT Rename values to x. Use np.percentile instead of scipy scoreatp…
twiecki Jan 5, 2016
7328ffd
MAINT Use reduce instead of tuple unpacking.
twiecki Jan 5, 2016
39cfc0a
ENH Also display median in bootstrap analysis.
twiecki Jan 5, 2016
e542033
DOC Also displays median in bootstrap analysis.
twiecki Jan 5, 2016
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 50 additions & 8 deletions pyfolio/plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,42 @@ def plot_drawdown_underwater(returns, ax=None, **kwargs):
return ax


def show_perf_stats(returns, factor_returns, live_start_date=None):
def plot_perf_stats(returns, factor_returns, ax=None):
"""Create box plot of some performance metrics of the strategy.
The width of the box whiskers is determined by a bootstrap.

Parameters
----------
returns : pd.Series
Daily returns of the strategy, noncumulative.
- See full explanation in tears.create_full_tear_sheet.
factor_returns : pd.DataFrame, optional
data set containing the Fama-French risk factors. See
utils.load_portfolio_risk_factors.
ax : matplotlib.Axes, optional
Axes upon which to plot.

Returns
-------
ax : matplotlib.Axes
The axes that were plotted on.

"""
if ax is None:
ax = plt.gca()

bootstrap_values = timeseries.perf_stats_bootstrap(returns,
factor_returns,
return_stats=False)
bootstrap_values = bootstrap_values.drop('kurtosis', axis='columns')

sns.boxplot(bootstrap_values, orient='h', ax=ax)

return ax


def show_perf_stats(returns, factor_returns, live_start_date=None,
bootstrap=False):
"""Prints some performance metrics of the strategy.

- Shows amount of time the strategy has been run in backtest and
Expand All @@ -482,23 +517,30 @@ def show_perf_stats(returns, factor_returns, live_start_date=None):
factor_returns : pd.Series
Daily noncumulative returns of the benchmark.
- This is in the same style as returns.
bootstrap : boolean (optional)
Whether to perform bootstrap analysis for the performance
metrics.
- For more information, see timeseries.perf_stats_bootstrap

"""

if bootstrap:
perf_func = timeseries.perf_stats_bootstrap
else:
perf_func = timeseries.perf_stats

if live_start_date is not None:
live_start_date = utils.get_utc_timestamp(live_start_date)
returns_backtest = returns[returns.index < live_start_date]
returns_live = returns[returns.index > live_start_date]

perf_stats_live = np.round(timeseries.perf_stats(
perf_stats_live = np.round(perf_func(
returns_live,
factor_returns=factor_returns), 2)
perf_stats_live.columns = ['Out_of_Sample']

perf_stats_all = np.round(timeseries.perf_stats(
perf_stats_all = np.round(perf_func(
returns,
factor_returns=factor_returns), 2)
perf_stats_all.columns = ['All_History']

print('Out-of-Sample Months: ' +
str(int(len(returns_live) / APPROX_BDAYS_PER_MONTH)))
Expand All @@ -508,16 +550,16 @@ def show_perf_stats(returns, factor_returns, live_start_date=None):
print('Backtest Months: ' +
str(int(len(returns_backtest) / APPROX_BDAYS_PER_MONTH)))

perf_stats = np.round(timeseries.perf_stats(
perf_stats = np.round(perf_func(
returns_backtest,
factor_returns=factor_returns), 2)

if live_start_date is not None:
perf_stats = pd.DataFrame(OrderedDict([
perf_stats = pd.concat(OrderedDict([
('Backtest', perf_stats),
('Out of sample', perf_stats_live),
('All history', perf_stats_all),
]))
]), axis=1)

print(perf_stats)

Expand Down
18 changes: 18 additions & 0 deletions pyfolio/tears.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ def create_full_tear_sheet(returns,
round_trips=False,
hide_positions=False,
cone_std=(1.0, 1.5, 2.0),
bootstrap=False,
set_context=True):
"""
Generate a number of tear sheets that are useful
Expand Down Expand Up @@ -131,6 +132,9 @@ def create_full_tear_sheet(returns,
If tuple, Tuple of standard deviation values to use for the cone plots
- The cone is a normal distribution with this standard deviation
centered around a linear regression.
bootstrap : boolean (optional)
Whether to perform bootstrap analysis for the performance
metrics. Takes a few minutes longer.
set_context : boolean, optional
If True, set default plotting style context.
- See plotting.context().
Expand All @@ -156,6 +160,7 @@ def create_full_tear_sheet(returns,
live_start_date=live_start_date,
cone_std=cone_std,
benchmark_rets=benchmark_rets,
bootstrap=bootstrap,
set_context=set_context)

create_interesting_times_tear_sheet(returns,
Expand Down Expand Up @@ -190,6 +195,7 @@ def create_full_tear_sheet(returns,
def create_returns_tear_sheet(returns, live_start_date=None,
cone_std=(1.0, 1.5, 2.0),
benchmark_rets=None,
bootstrap=False,
return_fig=False):
"""
Generate a number of plots for analyzing a strategy's returns.
Expand Down Expand Up @@ -218,6 +224,9 @@ def create_returns_tear_sheet(returns, live_start_date=None,
benchmark_rets : pd.Series, optional
Daily noncumulative returns of the benchmark.
- This is in the same style as returns.
bootstrap : boolean (optional)
Whether to perform bootstrap analysis for the performance
metrics. Takes a few minutes longer.
return_fig : boolean, optional
If True, returns the figure that was plotted on.
set_context : boolean, optional
Expand All @@ -240,6 +249,7 @@ def create_returns_tear_sheet(returns, live_start_date=None,
print('\n')

plotting.show_perf_stats(returns, benchmark_rets,
bootstrap=bootstrap,
live_start_date=live_start_date)

if live_start_date is not None:
Expand All @@ -248,6 +258,9 @@ def create_returns_tear_sheet(returns, live_start_date=None,
else:
vertical_sections = 10

if bootstrap:
vertical_sections += 1

fig = plt.figure(figsize=(14, vertical_sections * 6))
gs = gridspec.GridSpec(vertical_sections, 3, wspace=0.5, hspace=0.5)
ax_rolling_returns = plt.subplot(gs[:2, :])
Expand Down Expand Up @@ -317,6 +330,11 @@ def create_returns_tear_sheet(returns, live_start_date=None,
df_monthly,
ax=ax_return_quantiles)

if bootstrap:
ax_bootstrap = plt.subplot(gs[10, :])
plotting.plot_perf_stats(returns, benchmark_rets,
ax=ax_bootstrap)

for ax in fig.axes:
plt.setp(ax.get_xticklabels(), visible=True)

Expand Down
2 changes: 2 additions & 0 deletions pyfolio/tests/test_tears.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ class PositionsTestCase(TestCase):
({'round_trips': True},),
({'hide_positions': True},),
({'cone_std': 1},),
({'bootstrap': True},),
])
@cleanup
def test_create_full_tear_sheet_breakdown(self, kwargs):
Expand All @@ -73,6 +74,7 @@ def test_create_full_tear_sheet_breakdown(self, kwargs):
({'live_start_date':
test_returns.index[-20]},),
({'cone_std': 1},),
({'bootstrap': True},),
])
@cleanup
def test_create_returns_tear_sheet_breakdown(self, kwargs):
Expand Down
38 changes: 38 additions & 0 deletions pyfolio/tests/test_timeseries.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,3 +391,41 @@ def test_bootstrap_cone_against_linear_cone_normal_returns(self):
for col, vals in bootstrap_cone.iteritems():
expected = normal_cone[col].values
assert_allclose(vals.values, expected, rtol=.005)


class TestBootstrap(TestCase):
@parameterized.expand([
(0., 1., 1000),
(1., 2., 500),
(-1., 0.1, 10),
])
def test_calc_bootstrap(self, true_mean, true_sd, n):
"""Compare bootstrap distribution of the mean to sampling distribution
of the mean.

"""
np.random.seed(123)
func = np.mean
returns = pd.Series((np.random.randn(n) * true_sd) +
true_mean)

samples = timeseries.calc_bootstrap(func, returns,
n_samples=10000)

# Calculate statistics of sampling distribution of the mean
mean_of_mean = np.mean(returns)
sd_of_mean = np.std(returns) / np.sqrt(n)

assert_almost_equal(
np.mean(samples),
mean_of_mean,
3,
'Mean of bootstrap does not match theoretical mean of'
'sampling distribution')

assert_almost_equal(
np.std(samples),
sd_of_mean,
3,
'SD of bootstrap does not match theoretical SD of'
'sampling distribution')
126 changes: 125 additions & 1 deletion pyfolio/timeseries.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ def annual_return(returns, period=DAILY):
num_years = float(len(returns)) / ann_factor
df_cum_rets = cum_returns(returns, starting_value=100)
start_value = 100
end_value = df_cum_rets[-1]
end_value = df_cum_rets.iloc[-1]

total_return = (end_value - start_value) / start_value
annual_return = (1. + total_return) ** (1 / num_years) - 1
Expand Down Expand Up @@ -752,6 +752,130 @@ def perf_stats(returns, factor_returns=None):
return stats


def perf_stats_bootstrap(returns, factor_returns=None, return_stats=True):
"""Calculates various bootstrapped performance metrics of a strategy.

Parameters
----------
returns : pd.Series
Daily returns of the strategy, noncumulative.
- See full explanation in tears.create_full_tear_sheet.
factor_returns : pd.Series (optional)
Daily noncumulative returns of the benchmark.
- This is in the same style as returns.
If None, do not compute alpha, beta, and information ratio.
return_stats : boolean (optional)
If True, returns a DataFrame of mean, median, 5 and 95 percentiles
for each perf metric.
If False, returns a DataFrame with the bootstrap samples for
each perf metric.

Returns
-------
pd.DataFrame
if return_stats is True:
- Distributional statistics of bootstrapped sampling
distribution of performance metrics.
if return_stats is False:
- Bootstrap samples for each performance metric.
"""
bootstrap_values = OrderedDict()

for stat_func in SIMPLE_STAT_FUNCS:
stat_name = stat_func.__name__
bootstrap_values[stat_name] = calc_bootstrap(stat_func,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could go straight to the DataFrame here, but the dict -> df is fine

returns)

if factor_returns is not None:
for stat_func in FACTOR_STAT_FUNCS:
stat_name = stat_func.__name__
bootstrap_values[stat_name] = calc_bootstrap(
stat_func,
returns,
factor_returns=factor_returns)

bootstrap_values = pd.DataFrame(bootstrap_values)

if return_stats:
stats = bootstrap_values.apply(calc_distribution_stats)
return stats.T[['mean', 'median', '5%', '95%']]
else:
return bootstrap_values


def calc_bootstrap(func, returns, *args, **kwargs):
"""Performs a bootstrap analysis on a user-defined function returning
a summary statistic.

Parameters
----------
func : function
Function that either takes a single array (commonly returns)
or two arrays (commonly returns and factor returns) and
returns a single value (commonly a summary
statistic). Additional args and kwargs are passed as well.
returns : pd.Series
Daily returns of the strategy, noncumulative.
- See full explanation in tears.create_full_tear_sheet.
factor_returns : pd.Series (optional)
Daily noncumulative returns of the benchmark.
- This is in the same style as returns.
n_samples : int (optional)
Number of bootstrap samples to draw. Default is 1000.
Increasing this will lead to more stable / accurate estimates.

Returns
-------
numpy.ndarray
Bootstrapped sampling distribution of passed in func.
"""

n_samples = kwargs.pop('n_samples', 1000)
out = np.empty(n_samples)

factor_returns = kwargs.pop('factor_returns', None)

for i in range(n_samples):
idx = np.random.randint(len(returns), size=len(returns))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not 100% clear on the benefits/problems with sampling a random collection of daily returns vs. sampling a random window of daily returns. Is the assumption that daily returns are independent problematic?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neither am I but my hunch is that the difference won't be huge. There are various different ways to do a blocked bootstrap and we might want to add these. I'd lean towards sticking with the simplest one for now and if the need arises we can add different sampling schemes later.

returns_i = returns.iloc[idx].reset_index(drop=True)
if factor_returns is not None:
factor_returns_i = factor_returns.iloc[idx].reset_index(drop=True)
out[i] = func(returns_i, factor_returns_i,
*args, **kwargs)
else:
out[i] = func(returns_i,
*args, **kwargs)

return out


def calc_distribution_stats(x):
"""Calculate various summary statistics of data.

Parameters
----------
x : numpy.ndarray or pandas.Series
Array to compute summary statistics for.

Returns
-------
pandas.Series
Series containing mean, median, std, as well as 5, 25, 75 and
95 percentiles of passed in values.

"""
return pd.Series({'mean': np.mean(x),
'median': np.median(x),
'std': np.std(x),
'5%': np.percentile(x, 5),
'25%': np.percentile(x, 25),
'75%': np.percentile(x, 75),
'95%': np.percentile(x, 95),
'IQR': np.subtract.reduce(
np.percentile(x, [75, 25])),
})


def get_max_drawdown_underwater(underwater):
"""Determines peak, valley, and recovery dates given and 'underwater'
DataFrame.
Expand Down