Skip to content

Commit ee928a3

Browse files
Calvin DeBoertwiecki
Calvin DeBoer
authored andcommitted
ENH Allow non-daily timeseries to be passed into annual_return and annual_volatility calcs.
Also refactors how DAILY, MONTHLY and YEARYL frequencies are encoded.
1 parent 889eba0 commit ee928a3

File tree

3 files changed

+157
-38
lines changed

3 files changed

+157
-38
lines changed

pyfolio/tests/test_timeseries.py

+40-11
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,21 @@ class TestStats(TestCase):
210210
'2000-1-3',
211211
periods=500,
212212
freq='D'))
213+
214+
simple_week_rets = pd.Series(
215+
[0.1] * 3 + [0] * 497,
216+
pd.date_range(
217+
'2000-1-31',
218+
periods=500,
219+
freq='W'))
220+
221+
simple_month_rets = pd.Series(
222+
[0.1] * 3 + [0] * 497,
223+
pd.date_range(
224+
'2000-1-31',
225+
periods=500,
226+
freq='M'))
227+
213228
simple_benchmark = pd.Series(
214229
[0.03] * 4 + [0] * 496,
215230
pd.date_range(
@@ -221,25 +236,39 @@ class TestStats(TestCase):
221236
dt = pd.date_range('2000-1-3', periods=3, freq='D')
222237

223238
@parameterized.expand([
224-
(simple_rets, 'calendar', 0.10584000000000014),
225-
(simple_rets, 'compound', 0.16317653888658334),
226-
(simple_rets, 'calendar', 0.10584000000000014),
227-
(simple_rets, 'compound', 0.16317653888658334)
239+
(simple_rets, 'calendar', utils.DAILY, 0.10584000000000014),
240+
(simple_rets, 'compound', utils.DAILY, 0.16317653888658334),
241+
(simple_rets, 'calendar', utils.DAILY, 0.10584000000000014),
242+
(simple_rets, 'compound', utils.DAILY, 0.16317653888658334),
243+
(simple_week_rets, 'compound', utils.WEEKLY, 0.031682168889005213),
244+
(simple_week_rets, 'calendar', utils.WEEKLY, 0.021840000000000033),
245+
(simple_month_rets, 'compound', utils.MONTHLY, 0.0072238075842128158),
246+
(simple_month_rets, 'calendar', utils.MONTHLY, 0.0050400000000000071)
228247
])
229-
def test_annual_ret(self, returns, style, expected):
248+
def test_annual_ret(self, returns, style, period, expected):
230249
self.assertEqual(
231250
timeseries.annual_return(
232251
returns,
233-
style=style),
252+
style=style, period=period),
234253
expected)
235254

236255
@parameterized.expand([
237-
(simple_rets, 0.12271674212427248),
238-
(simple_rets, 0.12271674212427248)
256+
(simple_rets, utils.DAILY, 0.12271674212427248),
257+
(simple_rets, utils.DAILY, 0.12271674212427248),
258+
(simple_week_rets, utils.WEEKLY, 0.055744909991675112),
259+
(simple_week_rets, utils.WEEKLY, 0.055744909991675112),
260+
(simple_month_rets, utils.MONTHLY, 0.026778988562993072),
261+
(simple_month_rets, utils.MONTHLY, 0.026778988562993072)
239262
])
240-
def test_annual_volatility(self, returns, expected):
241-
self.assertAlmostEqual(timeseries.annual_volatility(returns),
242-
expected, DECIMAL_PLACES)
263+
def test_annual_volatility(self, returns, period, expected):
264+
self.assertAlmostEqual(
265+
timeseries.annual_volatility(
266+
returns,
267+
period=period
268+
),
269+
expected,
270+
DECIMAL_PLACES
271+
)
243272

244273
@parameterized.expand([
245274
(simple_rets, 'calendar', 0.8624740045072119),

pyfolio/timeseries.py

+103-27
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
from . import utils
2929
from .utils import APPROX_BDAYS_PER_MONTH, APPROX_BDAYS_PER_YEAR
30+
from .utils import DAILY, WEEKLY, MONTHLY, YEARLY, ANNUALIZATION_FACTORS
3031
from .interesting_periods import PERIODS
3132

3233

@@ -135,19 +136,21 @@ def aggregate_returns(df_daily_rets, convert_to):
135136
def cumulate_returns(x):
136137
return cum_returns(x)[-1]
137138

138-
if convert_to == 'weekly':
139+
if convert_to == WEEKLY:
139140
return df_daily_rets.groupby(
140141
[lambda x: x.year,
141142
lambda x: x.month,
142143
lambda x: x.isocalendar()[1]]).apply(cumulate_returns)
143-
elif convert_to == 'monthly':
144+
elif convert_to == MONTHLY:
144145
return df_daily_rets.groupby(
145146
[lambda x: x.year, lambda x: x.month]).apply(cumulate_returns)
146-
elif convert_to == 'yearly':
147+
elif convert_to == YEARLY:
147148
return df_daily_rets.groupby(
148149
[lambda x: x.year]).apply(cumulate_returns)
149150
else:
150-
ValueError('convert_to must be weekly, monthly or yearly')
151+
ValueError(
152+
'convert_to must be {}, {} or {}'.format(WEEKLY, MONTHLY, YEARLY)
153+
)
151154

152155

153156
def max_drawdown(returns):
@@ -188,20 +191,24 @@ def max_drawdown(returns):
188191
return -1 * MDD
189192

190193

191-
def annual_return(returns, style='compound'):
194+
def annual_return(returns, style='compound', period=DAILY):
192195
"""Determines the annual returns of a strategy.
193196
194197
Parameters
195198
----------
196199
returns : pd.Series
197-
Daily returns of the strategy, noncumulative.
200+
Periodic returns of the strategy, noncumulative.
198201
- See full explanation in tears.create_full_tear_sheet.
199202
style : str, optional
200203
- If 'compound', then return will be calculated in geometric
201204
terms: (1+mean(all_daily_returns))^252 - 1.
202205
- If 'calendar', then return will be calculated as
203206
((last_value - start_value)/start_value)/num_of_years.
204207
- Otherwise, return is simply mean(all_daily_returns)*252.
208+
period : str, optional
209+
- defines the periodicity of the 'returns' data for purposes of
210+
annualizing. Can be 'monthly', 'weekly', or 'daily'
211+
- defaults to 'daily'.
205212
206213
Returns
207214
-------
@@ -213,27 +220,41 @@ def annual_return(returns, style='compound'):
213220
if returns.size < 1:
214221
return np.nan
215222

223+
try:
224+
ann_factor = ANNUALIZATION_FACTORS[period]
225+
except KeyError:
226+
raise ValueError(
227+
"period cannot be '{}'. "
228+
"Must be '{}', '{}', or '{}'".format(
229+
period, DAILY, WEEKLY, MONTHLY
230+
)
231+
)
232+
216233
if style == 'calendar':
217-
num_years = len(returns) / APPROX_BDAYS_PER_YEAR
234+
num_years = len(returns) / ann_factor
218235
df_cum_rets = cum_returns(returns, starting_value=100)
219236
start_value = df_cum_rets[0]
220237
end_value = df_cum_rets[-1]
221238
return ((end_value - start_value) / start_value) / num_years
222239
if style == 'compound':
223-
return pow((1 + returns.mean()), APPROX_BDAYS_PER_YEAR) - 1
240+
return pow((1 + returns.mean()), ann_factor) - 1
224241
else:
225-
return returns.mean() * APPROX_BDAYS_PER_YEAR
242+
return returns.mean() * ann_factor
226243

227244

228-
def annual_volatility(returns):
245+
def annual_volatility(returns, period=DAILY):
229246
"""
230247
Determines the annual volatility of a strategy.
231248
232249
Parameters
233250
----------
234251
returns : pd.Series
235-
Daily returns of the strategy, noncumulative.
252+
Periodic returns of the strategy, noncumulative.
236253
- See full explanation in tears.create_full_tear_sheet.
254+
period : str, optional
255+
- defines the periodicity of the 'returns' data for purposes of
256+
annualizing volatility. Can be 'monthly' or 'weekly' or 'daily'.
257+
- defaults to 'daily'
237258
238259
Returns
239260
-------
@@ -244,10 +265,20 @@ def annual_volatility(returns):
244265
if returns.size < 2:
245266
return np.nan
246267

247-
return returns.std() * np.sqrt(APPROX_BDAYS_PER_YEAR)
268+
try:
269+
ann_factor = ANNUALIZATION_FACTORS[period]
270+
except KeyError:
271+
raise ValueError(
272+
"period cannot be: '{}'."
273+
" Must be '{}', '{}', or '{}'".format(
274+
period, DAILY, WEEKLY, MONTHLY
275+
)
276+
)
277+
278+
return returns.std() * np.sqrt(ann_factor)
248279

249280

250-
def calmar_ratio(returns, returns_style='calendar'):
281+
def calmar_ratio(returns, returns_style='calendar', period=DAILY):
251282
"""
252283
Determines the Calmar ratio, or drawdown ratio, of a strategy.
253284
@@ -258,6 +289,11 @@ def calmar_ratio(returns, returns_style='calendar'):
258289
- See full explanation in tears.create_full_tear_sheet.
259290
returns_style : str, optional
260291
See annual_returns' style
292+
period : str, optional
293+
- defines the periodicity of the 'returns' data for purposes of
294+
annualizing. Can be 'monthly', 'weekly', or 'daily'
295+
- defaults to 'daily'.
296+
261297
262298
Returns
263299
-------
@@ -273,7 +309,9 @@ def calmar_ratio(returns, returns_style='calendar'):
273309
if temp_max_dd < 0:
274310
temp = annual_return(
275311
returns=returns,
276-
style=returns_style) / abs(max_drawdown(returns=returns))
312+
style=returns_style,
313+
period=period
314+
) / abs(max_drawdown(returns=returns))
277315
else:
278316
return np.nan
279317

@@ -321,7 +359,7 @@ def omega_ratio(returns, annual_return_threshhold=0.0):
321359
return np.nan
322360

323361

324-
def sortino_ratio(returns, required_return=0):
362+
def sortino_ratio(returns, required_return=0, period=DAILY):
325363

326364
"""
327365
Determines the Sortino ratio of a strategy.
@@ -331,9 +369,14 @@ def sortino_ratio(returns, required_return=0):
331369
returns : pd.Series or pd.DataFrame
332370
Daily returns of the strategy, noncumulative.
333371
- See full explanation in tears.create_full_tear_sheet.
334-
372+
returns_style : str, optional
373+
See annual_returns' style
335374
required_return: float / series
336375
minimum acceptable return
376+
period : str, optional
377+
- defines the periodicity of the 'returns' data for purposes of
378+
annualizing. Can be 'monthly', 'weekly', or 'daily'
379+
- defaults to 'daily'.
337380
338381
Returns
339382
-------
@@ -344,14 +387,24 @@ def sortino_ratio(returns, required_return=0):
344387
Annualized Sortino ratio.
345388
346389
"""
390+
try:
391+
ann_factor = ANNUALIZATION_FACTORS[period]
392+
except KeyError:
393+
raise ValueError(
394+
"period cannot be: '{}'."
395+
" Must be '{}', '{}', or '{}'".format(
396+
period, DAILY, WEEKLY, MONTHLY
397+
)
398+
)
399+
347400
mu = np.nanmean(returns - required_return, axis=0)
348401
sortino = mu / downside_risk(returns, required_return)
349402
if len(returns.shape) == 2:
350403
sortino = pd.Series(sortino, index=returns.columns)
351-
return sortino * APPROX_BDAYS_PER_YEAR
404+
return sortino * ann_factor
352405

353406

354-
def downside_risk(returns, required_return=0):
407+
def downside_risk(returns, required_return=0, period=DAILY):
355408
"""
356409
Determines the downside deviation below a threshold
357410
@@ -363,6 +416,10 @@ def downside_risk(returns, required_return=0):
363416
364417
required_return: float / series
365418
minimum acceptable return
419+
period : str, optional
420+
- defines the periodicity of the 'returns' data for purposes of
421+
annualizing. Can be 'monthly', 'weekly', or 'daily'
422+
- defaults to 'daily'.
366423
367424
Returns
368425
-------
@@ -373,18 +430,28 @@ def downside_risk(returns, required_return=0):
373430
Annualized downside deviation
374431
375432
"""
433+
try:
434+
ann_factor = ANNUALIZATION_FACTORS[period]
435+
except KeyError:
436+
raise ValueError(
437+
"period cannot be: '{}'."
438+
" Must be '{}', '{}', or '{}'".format(
439+
period, DAILY, WEEKLY, MONTHLY
440+
)
441+
)
442+
376443
downside_diff = returns - required_return
377444
mask = downside_diff > 0
378445
downside_diff[mask] = 0.0
379446
squares = np.square(downside_diff)
380447
mean_squares = np.nanmean(squares, axis=0)
381-
dside_risk = np.sqrt(mean_squares) * np.sqrt(APPROX_BDAYS_PER_YEAR)
448+
dside_risk = np.sqrt(mean_squares) * np.sqrt(ann_factor)
382449
if len(returns.shape) == 2:
383450
dside_risk = pd.Series(dside_risk, index=returns.columns)
384451
return dside_risk
385452

386453

387-
def sharpe_ratio(returns, returns_style='compound'):
454+
def sharpe_ratio(returns, returns_style='compound', period=DAILY):
388455
"""
389456
Determines the Sharpe ratio of a strategy.
390457
@@ -395,6 +462,10 @@ def sharpe_ratio(returns, returns_style='compound'):
395462
- See full explanation in tears.create_full_tear_sheet.
396463
returns_style : str, optional
397464
See annual_returns' style
465+
period : str, optional
466+
- defines the periodicity of the 'returns' data for purposes of
467+
annualizing. Can be 'monthly', 'weekly', or 'daily'
468+
- defaults to 'daily'.
398469
399470
Returns
400471
-------
@@ -406,8 +477,8 @@ def sharpe_ratio(returns, returns_style='compound'):
406477
See https://en.wikipedia.org/wiki/Sharpe_ratio for more details.
407478
"""
408479

409-
numer = annual_return(returns, style=returns_style)
410-
denom = annual_volatility(returns)
480+
numer = annual_return(returns, style=returns_style, period=period)
481+
denom = annual_volatility(returns, period=period)
411482

412483
if denom > 0.0:
413484
return numer / denom
@@ -661,7 +732,8 @@ def calc_alpha_beta(returns, factor_returns):
661732
def perf_stats(
662733
returns,
663734
returns_style='compound',
664-
return_as_dict=False):
735+
return_as_dict=False,
736+
period=DAILY):
665737
"""Calculates various performance metrics of a strategy, for use in
666738
plotting.show_perf_stats.
667739
@@ -674,6 +746,10 @@ def perf_stats(
674746
See annual_returns' style
675747
return_as_dict : boolean, optional
676748
If True, returns the computed metrics in a dictionary.
749+
period : str, optional
750+
- defines the periodicity of the 'returns' data for purposes of
751+
annualizing. Can be 'monthly', 'weekly', or 'daily'
752+
- defaults to 'daily'.
677753
678754
Returns
679755
-------
@@ -685,14 +761,14 @@ def perf_stats(
685761
all_stats = OrderedDict()
686762
all_stats['annual_return'] = annual_return(
687763
returns,
688-
style=returns_style)
689-
all_stats['annual_volatility'] = annual_volatility(returns)
764+
style=returns_style, period=period)
765+
all_stats['annual_volatility'] = annual_volatility(returns, period=period)
690766
all_stats['sharpe_ratio'] = sharpe_ratio(
691767
returns,
692-
returns_style=returns_style)
768+
returns_style=returns_style, period=period)
693769
all_stats['calmar_ratio'] = calmar_ratio(
694770
returns,
695-
returns_style=returns_style)
771+
returns_style=returns_style, period=period)
696772
all_stats['stability'] = stability_of_timeseries(returns)
697773
all_stats['max_drawdown'] = max_drawdown(returns)
698774
all_stats['omega_ratio'] = omega_ratio(returns)

0 commit comments

Comments
 (0)