diff --git a/tests/pipeline/test_statistical.py b/tests/pipeline/test_statistical.py index afd0671e..80028b43 100644 --- a/tests/pipeline/test_statistical.py +++ b/tests/pipeline/test_statistical.py @@ -362,6 +362,30 @@ class StatisticalBuiltInsTestCase(WithTradingEnvironment, ZiplineTestCase): end_date, ) + def test_require_length_greater_than_one(self): + my_asset = Equity(0, exchange="TEST") + + with self.assertRaises(ValueError): + RollingPearsonOfReturns( + target=my_asset, + returns_length=3, + correlation_length=1, + ) + + with self.assertRaises(ValueError): + spearman_factor = RollingSpearmanOfReturns( + target=my_asset, + returns_length=3, + correlation_length=1, + ) + + with self.assertRaises(ValueError): + regression_factor = RollingLinearRegressionOfReturns( + target=my_asset, + returns_length=3, + regression_length=1, + ) + class StatisticalMethodsTestCase(WithSeededRandomPipelineEngine, ZiplineTestCase): @@ -581,7 +605,7 @@ class StatisticalMethodsTestCase(WithSeededRandomPipelineEngine, regression_length=regression_length, ) - @parameter_space(correlation_length=[1, 2, 3, 4]) + @parameter_space(correlation_length=[2, 3, 4]) def test_factor_correlation_methods_two_factors(self, correlation_length): """ Tests for `Factor.pearsonr` and `Factor.spearmanr` when passed another @@ -682,7 +706,7 @@ class StatisticalMethodsTestCase(WithSeededRandomPipelineEngine, ) assert_frame_equal(spearman_results, expected_spearman_results) - @parameter_space(regression_length=[1, 2, 3, 4]) + @parameter_space(regression_length=[2, 3, 4]) def test_factor_regression_method_two_factors(self, regression_length): """ Tests for `Factor.linear_regression` when passed another 2D factor diff --git a/zipline/pipeline/factors/statistical.py b/zipline/pipeline/factors/statistical.py index 6949641a..fc3684d9 100644 --- a/zipline/pipeline/factors/statistical.py +++ b/zipline/pipeline/factors/statistical.py @@ -12,7 +12,7 @@ from zipline.pipeline.filters import SingleAsset from zipline.pipeline.mixins import SingleInputMixin from zipline.pipeline.sentinels import NotSpecified from zipline.pipeline.term import AssetExists -from zipline.utils.input_validation import expect_dtypes +from zipline.utils.input_validation import expect_bounded, expect_dtypes from zipline.utils.numpy_utils import float64_dtype, int64_dtype from .technical import Returns @@ -24,6 +24,7 @@ ALLOWED_DTYPES = (float64_dtype, int64_dtype) class _RollingCorrelation(CustomFactor, SingleInputMixin): @expect_dtypes(base_factor=ALLOWED_DTYPES, target=ALLOWED_DTYPES) + @expect_bounded(correlation_length=(2, None)) def __new__(cls, base_factor, target, @@ -31,6 +32,7 @@ class _RollingCorrelation(CustomFactor, SingleInputMixin): mask=NotSpecified): if target.ndim == 2 and base_factor.mask is not target.mask: raise IncompatibleTerms(term_1=base_factor, term_2=target) + return super(_RollingCorrelation, cls).__new__( cls, inputs=[base_factor, target], @@ -167,6 +169,7 @@ class RollingLinearRegression(CustomFactor, SingleInputMixin): outputs = ['alpha', 'beta', 'r_value', 'p_value', 'stderr'] @expect_dtypes(dependent=ALLOWED_DTYPES, independent=ALLOWED_DTYPES) + @expect_bounded(regression_length=(2, None)) def __new__(cls, dependent, independent, @@ -174,6 +177,7 @@ class RollingLinearRegression(CustomFactor, SingleInputMixin): mask=NotSpecified): if independent.ndim == 2 and dependent.mask is not independent.mask: raise IncompatibleTerms(term_1=dependent, term_2=independent) + return super(RollingLinearRegression, cls).__new__( cls, inputs=[dependent, independent], diff --git a/zipline/utils/input_validation.py b/zipline/utils/input_validation.py index c8347cd3..dfde4fbd 100644 --- a/zipline/utils/input_validation.py +++ b/zipline/utils/input_validation.py @@ -503,6 +503,97 @@ def expect_element(*_pos, **named): return preprocess(**valmap(_expect_element, named)) +def expect_bounded(**named): + """ + Preprocessing decorator that verifies inputs fall between upper and lower + bounds. + + Usage + ----- + >>> @expect_bounded(x=(1, 5)) + ... def foo(x): + ... return x + 1 + ... + >>> foo(1) + 2 + >>> foo(5) + 6 + >>> foo(6) # doctest: +NORMALIZE_WHITESPACE +ELLIPSIS + Traceback (most recent call last): + ... + ValueError: ...foo() expected a value between 1 and 5 for argument 'x', + but got 6 instead. + + Notes + ----- + None can be passed as the lower or upper bound to signify that a value only + has an upper or lower bound. + + >>> @expect_bounded(x=(2, None)) + ... def foo(x): + ... return x + ... + >>> foo(100000) + 100000 + >>> foo(1) # doctest: +NORMALIZE_WHITESPACE +ELLIPSIS + Traceback (most recent call last): + ... + ValueError: ...foo() expected a value greater than or equal to 2 for + argument 'x', but got 1 instead. + + >>> @expect_bounded(x=(None, 5)) + ... def foo(x): + ... return x + ... + >>> foo(6) # doctest: +NORMALIZE_WHITESPACE +ELLIPSIS + Traceback (most recent call last): + ... + ValueError: ...foo() expected a value less than or equal to 5 for + argument 'x', but got 6 instead. + """ + def valid_bounds(t): + return ( + isinstance(t, tuple) + and len(t) == 2 + and t != (None, None) + ) + + for name, bounds in iteritems(named): + if not valid_bounds(bounds): + raise TypeError( + "expect_bounded() expected a tuple of bounds for" + " argument '{name}', but got {bounds} instead.".format( + name=name, + bounds=bounds, + ) + ) + + def _expect_bounded(bounds): + (lower, upper) = bounds + if lower is None: + should_fail = lambda value: value > upper + predicate_descr = "less than or equal to " + str(upper) + elif upper is None: + should_fail = lambda value: value < lower + predicate_descr = "greater than or equal to " + str(lower) + else: + should_fail = lambda value: not (lower <= value <= upper) + predicate_descr = "between %s and %s" % bounds + + template = ( + "%(funcname)s() expected a value {predicate}" + " for argument '%(argname)s', but got %(actual)s instead." + ).format(predicate=predicate_descr) + + return make_check( + exc_type=ValueError, + template=template, + pred=should_fail, + actual=repr, + ) + return preprocess(**valmap(_expect_bounded, named)) + + def expect_dimensions(**dimensions): """ Preprocessing decorator that verifies inputs are numpy arrays with a