Skip to content

Commit 1c0978d

Browse files
author
Scott Sanderson
authored
Merge pull request #85 from quantopian/fix-cum_returns-index
BUG: Preserve output index in `cum_returns`.
2 parents 52687e1 + 34570a4 commit 1c0978d

File tree

3 files changed

+88
-30
lines changed

3 files changed

+88
-30
lines changed

empyrical/stats.py

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ def unary_vectorized_roll(arr, window, out=None, **kwargs):
3737
window : int
3838
Size of the rolling window in terms of the periodicity of the data.
3939
out : array-like, optional
40-
The array to store the store the output.
40+
Array to use as output buffer.
41+
If not passed, a new array will be created.
4142
**kwargs
4243
Forwarded to :func:`~empyrical.{name}`.
4344
@@ -58,7 +59,7 @@ def unary_vectorized_roll(arr, window, out=None, **kwargs):
5859
out = np.empty(0, dtype='float64')
5960

6061
if allocated_output and isinstance(arr, pd.Series):
61-
out = pd.Series(out)
62+
out = pd.Series(out, index=arr.index[-len(out):])
6263

6364
return out
6465

@@ -84,7 +85,8 @@ def binary_vectorized_roll(lhs, rhs, window, out=None, **kwargs):
8485
window : int
8586
Size of the rolling window in terms of the periodicity of the data.
8687
out : array-like, optional
87-
The array to store the store the output.
88+
Array to use as output buffer.
89+
If not passed, a new array will be created.
8890
**kwargs
8991
Forwarded to :func:`~empyrical.{name}`.
9092
@@ -109,9 +111,9 @@ def binary_vectorized_roll(lhs, rhs, window, out=None, **kwargs):
109111

110112
if allocated_output:
111113
if out.ndim == 1 and isinstance(lhs, pd.Series):
112-
out = pd.Series(out)
114+
out = pd.Series(out, index=lhs.index[-len(out):])
113115
elif out.ndim == 2 and isinstance(lhs, pd.Series):
114-
out = pd.DataFrame(out)
116+
out = pd.DataFrame(out, index=lhs.index[-len(out):])
115117
return out
116118

117119
binary_vectorized_roll.__doc__ = binary_vectorized_roll.__doc__.format(
@@ -203,16 +205,16 @@ def cum_returns(returns, starting_value=0, out=None):
203205
starting_value : float, optional
204206
The starting returns.
205207
out : array-like, optional
206-
The array to store the store the output.
208+
Array to use as output buffer.
209+
If not passed, a new array will be created.
207210
208211
Returns
209212
-------
210213
cumulative_returns : array-like
211214
Series of cumulative returns.
212215
"""
213-
214216
if len(returns) < 1:
215-
return type(returns)([])
217+
return returns.copy()
216218

217219
nanmask = np.isnan(returns)
218220
if np.any(nanmask):
@@ -233,9 +235,9 @@ def cum_returns(returns, starting_value=0, out=None):
233235

234236
if allocated_output:
235237
if returns.ndim == 1 and isinstance(returns, pd.Series):
236-
out = pd.Series(out)
238+
out = pd.Series(out, index=returns.index)
237239
elif isinstance(returns, pd.DataFrame):
238-
out = pd.DataFrame(out)
240+
out = pd.DataFrame(out, index=returns.index)
239241

240242
return out
241243

@@ -318,7 +320,8 @@ def max_drawdown(returns, out=None):
318320
Daily returns of the strategy, noncumulative.
319321
- See full explanation in :func:`~empyrical.stats.cum_returns`.
320322
out : array-like, optional
321-
The array to store the store the output.
323+
Array to use as output buffer.
324+
If not passed, a new array will be created.
322325
323326
Returns
324327
-------
@@ -471,7 +474,8 @@ def annual_volatility(returns,
471474
returns into annual returns. Value should be the annual frequency of
472475
`returns`.
473476
out : array-like, optional
474-
The array to store the store the output.
477+
Array to use as output buffer.
478+
If not passed, a new array will be created.
475479
476480
Returns
477481
-------
@@ -635,7 +639,8 @@ def sharpe_ratio(returns,
635639
returns into annual returns. Value should be the annual frequency of
636640
`returns`.
637641
out : array-like, optional
638-
The array to store the store the output.
642+
Array to use as output buffer.
643+
If not passed, a new array will be created.
639644
640645
Returns
641646
-------
@@ -713,7 +718,8 @@ def sortino_ratio(returns,
713718
The downside risk of the given inputs, if known. Will be calculated if
714719
not provided.
715720
out : array-like, optional
716-
The array to store the store the output.
721+
Array to use as output buffer.
722+
If not passed, a new array will be created.
717723
718724
Returns
719725
-------
@@ -792,7 +798,8 @@ def downside_risk(returns,
792798
returns into annual returns. Value should be the annual frequency of
793799
`returns`.
794800
out : array-like, optional
795-
The array to store the store the output.
801+
Array to use as output buffer.
802+
If not passed, a new array will be created.
796803
797804
Returns
798805
-------
@@ -857,7 +864,8 @@ def excess_sharpe(returns, factor_returns, out=None):
857864
factor_returns: float / series
858865
Benchmark return to compare returns against.
859866
out : array-like, optional
860-
The array to store the store the output.
867+
Array to use as output buffer.
868+
If not passed, a new array will be created.
861869
862870
Returns
863871
-------
@@ -962,7 +970,8 @@ def alpha_beta(returns,
962970
returns into annual returns. Value should be the annual frequency of
963971
`returns`.
964972
out : array-like, optional
965-
The array to store the store the output.
973+
Array to use as output buffer.
974+
If not passed, a new array will be created.
966975
967976
Returns
968977
-------
@@ -994,7 +1003,8 @@ def roll_alpha_beta(returns, factor_returns, window=10, **kwargs):
9941003
window : int
9951004
Size of the rolling window in terms of the periodicity of the data.
9961005
out : array-like, optional
997-
The array to store the store the output.
1006+
Array to use as output buffer.
1007+
If not passed, a new array will be created.
9981008
**kwargs
9991009
Forwarded to :func:`~empyrical.alpha_beta`.
10001010
"""
@@ -1046,7 +1056,8 @@ def alpha_beta_aligned(returns,
10461056
returns into annual returns. Value should be the annual frequency of
10471057
`returns`.
10481058
out : array-like, optional
1049-
The array to store the store the output.
1059+
Array to use as output buffer.
1060+
If not passed, a new array will be created.
10501061
10511062
Returns
10521063
-------
@@ -1114,7 +1125,8 @@ def alpha(returns,
11141125
The beta for the given inputs, if already known. Will be calculated
11151126
internally if not provided.
11161127
out : array-like, optional
1117-
The array to store the store the output.
1128+
Array to use as output buffer.
1129+
If not passed, a new array will be created.
11181130
11191131
Returns
11201132
-------
@@ -1182,7 +1194,8 @@ def alpha_aligned(returns,
11821194
The beta for the given inputs, if already known. Will be calculated
11831195
internally if not provided.
11841196
out : array-like, optional
1185-
The array to store the store the output.
1197+
Array to use as output buffer.
1198+
If not passed, a new array will be created.
11861199
11871200
Returns
11881201
-------
@@ -1241,7 +1254,8 @@ def beta(returns, factor_returns, risk_free=0.0, out=None):
12411254
Constant risk-free return throughout the period. For example, the
12421255
interest rate on a three month us treasury bill.
12431256
out : array-like, optional
1244-
The array to store the store the output.
1257+
Array to use as output buffer.
1258+
If not passed, a new array will be created.
12451259
12461260
Returns
12471261
-------
@@ -1282,7 +1296,8 @@ def beta_aligned(returns, factor_returns, risk_free=0.0, out=None):
12821296
Constant risk-free return throughout the period. For example, the
12831297
interest rate on a three month us treasury bill.
12841298
out : array-like, optional
1285-
The array to store the store the output.
1299+
Array to use as output buffer.
1300+
If not passed, a new array will be created.
12861301
12871302
Returns
12881303
-------

empyrical/tests/test_stats.py

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from __future__ import division
22

3-
import random
43
from copy import copy
54
from operator import attrgetter
5+
import random
66
from unittest import TestCase, SkipTest
77

88
from parameterized import parameterized
@@ -13,13 +13,31 @@
1313
from scipy import stats
1414
from six import iteritems, wraps
1515

16+
try:
17+
from pandas.testing import assert_index_equal
18+
except ImportError:
19+
# This moved in pandas 0.20.
20+
from pandas.util.testing import assert_index_equal
21+
1622
import empyrical
1723
import empyrical.utils as emutils
1824

1925
DECIMAL_PLACES = 8
2026

2127

22-
class TestStats(TestCase):
28+
class BaseTestCase(TestCase):
29+
def assert_indexes_match(self, result, expected):
30+
"""
31+
Assert that two pandas objects have the same indices.
32+
33+
This is a method instead of a free function so that we can override it
34+
to be a no-op in suites like TestStatsArrays that unwrap pandas objects
35+
into ndarrays.
36+
"""
37+
assert_index_equal(result.index, expected.index)
38+
39+
40+
class TestStats(BaseTestCase):
2341

2442
# Simple benchmark, no drawdown
2543
simple_benchmark = pd.Series(
@@ -157,6 +175,8 @@ def test_cum_returns(self, returns, starting_value, expected):
157175
expected[i],
158176
4)
159177

178+
self.assert_indexes_match(cum_returns, returns)
179+
160180
@parameterized.expand([
161181
(empty_returns, 0, np.nan),
162182
(one_return, 0, one_return[0]),
@@ -996,6 +1016,8 @@ def test_roll_max_drawdown(self, returns, window, expected):
9961016
np.asarray(expected),
9971017
4)
9981018

1019+
self.assert_indexes_match(test, returns[-len(expected):])
1020+
9991021
@parameterized.expand([
10001022
(empty_returns, 6, []),
10011023
(negative_returns, 6, [-18.09162052, -26.79897486, -26.69138263,
@@ -1009,6 +1031,8 @@ def test_roll_sharpe_ratio(self, returns, window, expected):
10091031
np.asarray(expected),
10101032
DECIMAL_PLACES)
10111033

1034+
self.assert_indexes_match(test, returns[-len(expected):])
1035+
10121036
@parameterized.expand([
10131037
(empty_returns, empty_returns, np.nan),
10141038
(one_return, one_return, 1.),
@@ -1057,6 +1081,7 @@ def test_roll_alpha_beta(self, returns, benchmark, window, expected):
10571081
window,
10581082
)
10591083
if isinstance(test, pd.DataFrame):
1084+
self.assert_indexes_match(test, benchmark[-len(expected):])
10601085
test = test.values
10611086

10621087
alpha_test = [t[0] for t in test]
@@ -1114,9 +1139,11 @@ def test_roll_down_capture(self, returns, factor_returns, window,
11141139
np.asarray(expected),
11151140
DECIMAL_PLACES)
11161141

1142+
self.assert_indexes_match(test, returns[-len(expected):])
1143+
11171144
@parameterized.expand([
11181145
(empty_returns, empty_returns, 1, []),
1119-
(one_return, one_return, 1, 1.),
1146+
(one_return, one_return, 1, [1.]),
11201147
(mixed_returns, mixed_returns, 6, [1., 1., 1., 1.]),
11211148
(positive_returns, mixed_returns,
11221149
6, [0.00128406, 0.00291564, 0.00171499, 0.0777048]),
@@ -1132,6 +1159,8 @@ def test_roll_up_capture(self, returns, factor_returns, window, expected):
11321159
np.asarray(expected),
11331160
DECIMAL_PLACES)
11341161

1162+
self.assert_indexes_match(test, returns[-len(expected):])
1163+
11351164
@parameterized.expand([
11361165
(empty_returns, simple_benchmark, (np.nan, np.nan)),
11371166
(one_return, one_return, (np.nan, np.nan)),
@@ -1290,6 +1319,9 @@ class TestStatsArrays(TestStats):
12901319
def empyrical(self):
12911320
return PassArraysEmpyricalProxy(self, (np.ndarray, float))
12921321

1322+
def assert_indexes_match(self, result, expected):
1323+
pass
1324+
12931325

12941326
class TestStatsIntIndex(TestStats):
12951327
"""
@@ -1308,8 +1340,11 @@ def empyrical(self):
13081340
lambda obj: type(obj)(obj.values, index=np.arange(len(obj))),
13091341
)
13101342

1343+
def assert_indexes_match(self, result, expected):
1344+
pass
1345+
13111346

1312-
class TestHelpers(TestCase):
1347+
class TestHelpers(BaseTestCase):
13131348
"""
13141349
Tests for helper methods and utils.
13151350
"""
@@ -1376,7 +1411,7 @@ def test_roll_max_window(self):
13761411
self.assertTrue(res.size == 0)
13771412

13781413

1379-
class Test2DStats(TestCase):
1414+
class Test2DStats(BaseTestCase):
13801415
"""
13811416
Tests for functions that are capable of outputting a DataFrame.
13821417
"""
@@ -1429,6 +1464,8 @@ def test_cum_returns_df(self, returns, starting_value, expected):
14291464
4,
14301465
)
14311466

1467+
self.assert_indexes_match(cum_returns, returns)
1468+
14321469
@property
14331470
def empyrical(self):
14341471
"""
@@ -1455,6 +1492,9 @@ class Test2DStatsArrays(Test2DStats):
14551492
def empyrical(self):
14561493
return PassArraysEmpyricalProxy(self, np.ndarray)
14571494

1495+
def assert_indexes_match(self, result, expected):
1496+
pass
1497+
14581498

14591499
class ReturnTypeEmpyricalProxy(object):
14601500
"""

empyrical/utils.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,10 +166,13 @@ def _roll_ndarray(func, window, *args, **kwargs):
166166

167167
def _roll_pandas(func, window, *args, **kwargs):
168168
data = {}
169+
index_values = []
169170
for i in range(window, len(args[0]) + 1):
170171
rets = [s.iloc[i-window:i] for s in args]
171-
data[args[0].index[i - 1]] = func(*rets, **kwargs)
172-
return pd.Series(data)
172+
index_value = args[0].index[i - 1]
173+
index_values.append(index_value)
174+
data[index_value] = func(*rets, **kwargs)
175+
return pd.Series(data, index=type(args[0].index)(index_values))
173176

174177

175178
def cache_dir(environ=environ):

0 commit comments

Comments
 (0)